feat(notice): 通知历史记录页面增强筛选与详情
Some checks failed
Build and Deploy (admin.xpcool.com) / build-and-deploy (push) Failing after 4s
Some checks failed
Build and Deploy (admin.xpcool.com) / build-and-deploy (push) Failing after 4s
新增通知历史记录多维筛选(关键字/分组/类型/事件/渠道/接收人/状态/时间/排序)、统计概览、详情抽屉、批量删除与按天数清空,并补充通知字典选项接口与常量兜底。
This commit is contained in:
parent
f70f5dcff8
commit
b585603d58
5
.npmrc
5
.npmrc
@ -3,3 +3,8 @@ registry=https://registry.npmmirror.com
|
||||
node-linker=hoisted
|
||||
# 关键:store 与项目同盘(E 盘),避免跨卷 copy,走同卷硬链接(快 + 无需管理员权限)
|
||||
store-dir=E:\.pnpm-store
|
||||
# pnpm 11.16.0 强依赖内置模块 node:sqlite,Node < 23.4 必须带 --experimental-sqlite 启动,
|
||||
# 否则 pnpm 自身都起不来(ERR_UNKNOWN_BUILTIN_MODULE)。
|
||||
# 注意:.npmrc 的 node-options 只对生命周期脚本生效、对 pnpm 自身无效,
|
||||
# 真正的修复写在 PowerShell profile / ~/.bashrc 里(见 agents.md 第 3 节)。
|
||||
engine-strict=false
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
# admin.xpcool.com 变更记录
|
||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||
|
||||
2026-09-15 | FIX | **「commit 卡死」真凶定位并修复:eslint 规则 `n/no-extraneous-import`(此前的"checkType 全量"判断为误判,特此更正)**:症状=IDE 提交后长时间无输出,进程链 `git commit` → `sh .git/hooks/pre-commit` → `lefthook.exe` → `node eslint.js`,eslint **满核空转 900~1400 秒 CPU**(本次卡了 23 分钟,不会自愈)。定位法:①`eslint --no-config-lookup` → 0.5s(排除本体)②`eslint --print-config` → 2.9s(排除配置加载)③同一文件带完整配置 lint → **>300s 不结束** ④用「按前缀关闭插件规则」的临时探针配置二分:关整个 `n/` → **1.7s**,再逐条缩到唯一元凶 `n/no-extraneous-import`(pnpm hoisted + workspace 包名 + `imports` 别名下解析代价近乎无界)。修复:`internal/lint-configs/eslint-config/src/configs/node.ts` 将 `n/no-extraneous-import` 置 `off`(含注释);因该包经 `dist/` 分发且 `dist` 被 gitignore,改 `src` 后**须重建才生效**,但本机 `pnpm --filter @vben/eslint-config run stub`(tsdown)报 `Failed to import module "unrun"`(tsdown 可选依赖 unrun 未落地,node_modules 与 lock 均无),故**手工同步本地 `dist/index.mjs`**(本地派生物)。验证:待提交的 **7 个文件 lint 由卡死 >15 分钟 → 1.9s** ✅,并暴露 2 处待 `--fix` 的排序问题(`api/notice.ts:181`、`notice/log/index.vue:3`)。附带:`lefthook.yml` 的 `checkType` 同步改为增量 `turbo run typecheck --filter=...[HEAD] --concurrency=4`(38 → 10 个任务,属缩时优化非根因)。已清理卡死进程链,`.git/index.lock` 无残留
|
||||
|
||||
2026-09-14 | FIX | **commit「卡死」排查(次要优化):pre-commit 类型检查改增量**:`lefthook.yml` 的 `checkType` 原为全量 `pnpm check:type`(`turbo run typecheck`,实测 **38 个任务**,turbo 默认并发 10 个 vue-tsc 进程,本机 16 核/31G 再加 dev server 与 IDE 极易打满内存),改为 `pnpm exec turbo run typecheck --filter=...[HEAD] --concurrency=4`(只校验「自 HEAD 起有改动的包 + 依赖它们的包」,实测降为 **10 个任务**,并限制并发)。应急跳过:`LEFTHOOK=0 git commit ...`。另核实:Git Bash 下 `pnpm --version` → 11.16.0(启动器 shim 生效,钩子执行环境正常,`pnpm exec oxlint` 0.8s)
|
||||
|
||||
2026-09-14 | FIX | **Git commit/push 异常修复**:①commit 报 `fatal: Unable to create '.git/index.lock': File exists` —— 23:46 一次中断的 Git 操作残留锁(IDE 状态栏 `git add` 失败即此),确认无 git 进程持有后手动删除 `.git/index.lock`,`git status` 恢复正常;②push 卡死 —— 系统级 `credential.helper=manager`(GCM 2.6.1)**未存 `git.xpcool.com` 凭证**(凭据管理器只有 `admin.git.ybtdev.com`),且 GCM 对自建站点自动探测 provider **超时 >2000ms**(`warning: auto-detection of host provider took too long`)导致长时间挂起;已加全局配置 `credential.https://git.xpcool.com.provider=generic` 消除探测超时,凭证需**首次在有交互的终端输一次账号密码**由 GCM 持久化(详见 agents.md 第 8 节)
|
||||
|
||||
2026-09-14 | FIX | **pnpm `node:sqlite` 崩溃的真正根因定位 + 最终修复**:环境变量路线无效(IDE 注入的进程级 `NODE_OPTIONS` 优先级高于用户级/profile),改从**启动器层面**修 —— `C:\Program Files\nodejs\` 下三个 shim(`pnpm.cmd` / `pnpm.ps1` / `pnpm`)给 node 加 `--experimental-sqlite` **并同时注入 `NODE_OPTIONS`**。关键机理:全局 pnpm 是 **10.26.2**,它发现 `packageManager: pnpm@11.16.0` 会**另起一个 node 子进程**去跑 11.16.0(报错栈里的 `.tools/pnpm/11.16.0_tmp_33856` 即此),命令行 flag 传不过去,**只有环境变量会被子进程继承**;而 11.16.0 有 12 处 `node:sqlite` 引用(10.26.2 为 0 处),叠加本机 Node v23.0.0(< 23.4 需 flag)必崩。实测 `pnpm.cmd --version` → `11.16.0` ✅。备份:`C:\Users\xxl\pnpm-shim-backup-20260914`。⚠️ 重装 Node 会覆盖 shim
|
||||
|
||||
2026-09-14 | FIX | pnpm 11.16.0 启动即崩 `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`(本机 Node v23.0.0,该内置模块 23.4 前需 `--experimental-sqlite` 才启用):①新建 PowerShell **AllHosts** profile `C:\Users\xxl\Documents\WindowsPowerShell\profile.ps1`(`Microsoft.PowerShell_profile.ps1` 被误建成目录,故改用 AllHosts)幂等追加 `NODE_OPTIONS=--experimental-sqlite`,保留 IDE 注入的 `--require` shim;②新建 `~/.bashrc` 同样幂等追加(Git Bash 场景);③`setx NODE_OPTIONS "--experimental-sqlite"` 用户级兜底(cmd/外部终端);④`.npmrc` 加 `engine-strict=false` + 说明注释(`.npmrc` 的 node-options 对 pnpm 自身无效)。根治方案:升级 Node 到 24.12+(满足 engines,node:sqlite 已默认启用,无需任何 flag)
|
||||
|
||||
2026-09-14 | CHG | **壁纸模块(admin 侧)上线**:①`api/wallpaper.ts`(244 行,12 接口,全 POST、URL 无参)——open 组 `sources/list/random/download-track`,admin 组 `list/upload/save/delete/stats/source-list/source-save/source-test`;上传走 multipart,**须由后端 fullBody 加密豁免**(见 service 侧 FIX);②`views/wallpaper/library/index.vue`(569 行)我的图库:多选/拖拽上传 + 进度、缩略图网格、标题/标签/来源编辑、分页、批量删除、原图预览**含全屏查看**;③`views/wallpaper/source/index.vue`(310 行)开源平台管理:Bing/Picsum/Unsplash/Pexels/Wallhaven 五平台启停 + 排序 + 密钥配置 + 连通性测试。菜单由 service 端 `018_wallpaper.sql` 动态下发(RBAC 按 POST+path 精确匹配)。**PC/移动双端适配**:≤768px 工具栏折行、卡片转单列列表。本站已在生产环境以 `VITE_GLOB_API_ENCRYPT=true` 全量加密模式完成端到端验证
|
||||
|
||||
2026-09-14 | CHG | 壁纸全屏功能(前台 + 后台双端):前台 xpcool.com 新增 `composables/useFullscreen.ts`,`ControlDock` 增全屏按钮,壁纸预览支持全屏;后台 admin 图库预览页支持图片全屏。统一走 Fullscreen API(`requestFullscreen`/`exitFullscreen`),监听 `fullscreenchange` 同步按钮态与 ESC 退出,不支持元素级全屏的浏览器降级为「新窗口打开原图」
|
||||
|
||||
2026-09-14 | CFG | **ICP 备案号更正为 `黔ICP备2026003933号`**(原 `闽ICP备19024351号`):与 xpcool.com 主站同域名同备案。改三处保持一致 —— ①`packages/preferences/src/index.ts` 的 `appCopyrightPreferences`(**真正生效处**,被 apps/web-tdesign/src/preferences.ts 覆盖引用)②`packages/@core/preferences/src/config.ts` 框架默认值 ③`__tests__/__snapshots__/config.test.ts.snap` 快照;push main/dev 由 Gitea Actions 自动构建部署到 /data/www/admin.xpcool.com
|
||||
|
||||
2026-08-27 | CHG | 登录接口豁免全量加密,永远只加密密码字段:loginApi 始终走 hybridEncryptLogin(提交 {username 明文, encryptedKey, encryptedData});request.ts PLAIN_ROUTE_FRAGMENTS 增 /auth/login(请求拦截器不再整体加密登录 body)。全量模式 5 项 + dev 15 项 E2E 验证通过
|
||||
|
||||
2026-08-27 | CHG | 全量请求/响应加密(生产 VITE_GLOB_API_ENCRYPT=true):①crypto.ts 增 encryptPayload(整体加密任意对象,返回 aesKeyB64 会话密钥)+ decryptPayload(AES-GCM 解密后端响应,密文=nonce||ciphertext||tag);②request.ts 增 installCryptoInterceptors——请求拦截器将整个 JSON body 混合加密(public-key 豁免、FormData/无 body 跳过)、响应解密拦截器最先注册(axios 响应拦截器按注册顺序执行)还原 {code,message,data},requestClient 与 baseRequestClient 均安装;apiFullBodyEncrypt/fetchPublicKeyForCrypto 导出;③auth.ts loginApi、system.ts createAdmin/resetAdminPassword 全量模式下直接传明文密码由拦截器整体加密(开发模式仍走密码字段混合加密);④.env.production VITE_GLOB_API_ENCRYPT=true / .env.development=false。⚠️TS 坑:Uint8Array.subarray 返回 ArrayBufferLike 泛型不满足 BufferSource,需 new Uint8Array() 拷贝;axios 类型从 @vben/request re-export 导入(web-tdesign 未声明 axios 依赖)。typecheck+oxlint 全绿,全量/仅密码两种模式 E2E 均通过
|
||||
|
||||
60
agents.md
60
agents.md
@ -49,6 +49,13 @@ CORE="/c/Users/ybtdevxxl/.workbuddy/binaries/node/versions/22.22.2/corepack.cmd"
|
||||
- 现象:`pnpm --version` / `pnpm install` 报 `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`(`at ../store/index/lib/index.js`)
|
||||
- 原因:**pnpm 11.16.0 在解析包元数据时强依赖 `node:sqlite`**,而系统 Node 23.0 / managed Node 22 默认未启用该内置模块(22.5+ 起在 `--experimental-sqlite` 标志后可用,23.4 才默认开)
|
||||
- 解法:`NODE_OPTIONS=--experimental-sqlite` 后再跑 pnpm(本次直接沿用该法装依赖、起服务均成功)
|
||||
- **持久化修复(2026-09-14,机器 `C:\Users\xxl`)**:注意 IDE 终端会进程级注入 `NODE_OPTIONS=--require=...node-language-shim.cjs`,**用户级环境变量会被它覆盖**,所以必须写进 shell 启动脚本做「幂等追加」(不能覆盖):
|
||||
- PowerShell:`C:\Users\xxl\Documents\WindowsPowerShell\profile.ps1`(AllHosts;`Microsoft.PowerShell_profile.ps1` 在该机被误建成目录,故不用)
|
||||
- Git Bash:`~/.bashrc`
|
||||
- 兜底:`setx NODE_OPTIONS "--experimental-sqlite"`
|
||||
- **根治**:把 Node 升到 24.12+(既满足 engines,node:sqlite 也已默认启用,无需任何 flag)
|
||||
- **最终生效的修复(启动器层面,推荐)**:本机全局 pnpm 是 **10.26.2**(自身 0 处 `node:sqlite`),但它检测到 `packageManager: pnpm@11.16.0` 会**另起 node 子进程**运行 11.16.0(含 12 处 `node:sqlite`),子进程用 `process.execPath` 启动 → **命令行 flag 传不过去,只有 `NODE_OPTIONS` 环境变量会被继承**。故直接改 `C:\Program Files\nodejs\` 三个启动器(`pnpm.cmd` / `pnpm.ps1` / `pnpm`):既给 node 命令行加 `--experimental-sqlite`,也注入/追加 `NODE_OPTIONS`。备份在 `C:\Users\xxl\pnpm-shim-backup-20260914`;⚠️ 重装 Node / 重装全局 pnpm 会覆盖,需重做
|
||||
- 自检命令(只读):`cmd /c C:\PROGRA~1\nodejs\pnpm.cmd --version` → 应输出 `11.16.0`
|
||||
|
||||
### 坑 2:NODE_OPTIONS 污染 / shim
|
||||
- 现象:`pnpm install` 报 `[ERROR] [safe-delete] 操作失败 ... Some operations were aborted`(WorkBuddy 注入 `--require=...genie-safe-delete.cjs --use-system-ca`)
|
||||
@ -141,6 +148,59 @@ packages/ 共享包:@core(核心 UI/布局)、effects(请求/权限
|
||||
- Windows 无开发者模式时保持 `.npmrc` 的 `node-linker=hoisted` 与 `store-dir=E:\.pnpm-store`
|
||||
4. 装依赖后先 `pnpm dev:tdesign` 验证 5999/6000 端口
|
||||
|
||||
## 8. Git 提交 / 推送排障(2026-09-14 实遇三连坑)
|
||||
|
||||
> 三个现象互相独立,都会让 IDE 里点「提交/推送」看起来"坏了"或"卡死"。
|
||||
|
||||
### 现象 A:`fatal: Unable to create '.git/index.lock': File exists`
|
||||
- **原因**:上一次 Git 操作(IDE 状态栏 `git add` / 被杀掉的提交)中断,残留 `.git/index.lock`,后续所有写索引操作全被拒。
|
||||
- **处理**:
|
||||
1. 先确认**没有进程真在写**(否则删锁会损坏索引):`tasklist | findstr /i git.exe`;要看命令行用 `Get-CimInstance Win32_Process -Filter "Name='git.exe'"`。
|
||||
2. 确认无 `git` 进程持有后删锁:`Remove-Item .git\index.lock -Force`,再 `git status` 验证。
|
||||
- ⚠️ 不要在有活跃 git 进程时直接删锁。
|
||||
|
||||
### 现象 B:`git push` 长时间挂起(无报错、不动)
|
||||
- **原因 1(凭证缺失)**:系统级 `credential.helper=manager`(GCM 2.6.1)里**没有 `git.xpcool.com` 的凭证**(凭据管理器只有 `admin.git.ybtdev.com`),GCM 于是走交互/GUI 流程,在非交互终端里就表现为挂死。
|
||||
- **原因 2(探测超时)**:GCM 对自建 Gitea 域名会尝试**自动探测 host provider**,实测 `warning: auto-detection of host provider took too long (>2000ms)`,每次 push 先卡 2 秒以上。
|
||||
- **修复(已完成)**:全局加 `credential.https://git.xpcool.com.provider=generic`(与已有 `admin.git.ybtdev.com` 同款写法),消除探测超时:
|
||||
```bash
|
||||
git config --global credential.https://git.xpcool.com.provider generic
|
||||
```
|
||||
- **仍需人工做一步**:凭证本身要**首次在有交互的终端里输一次账号/密码**(或在 IDE 里点一次推送的输入框),之后 GCM 存入 Windows 凭据管理器(`cmdkey /list` 可见 `LegacyGeneric:target=git:https://git.xpcool.com`),后续免输。
|
||||
- 排查技巧:`GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git push --dry-run origin main` —— 禁用交互可立刻暴露「到底是凭证问题还是网络问题」(会直接报 `could not read Username` 而不是挂住)。
|
||||
|
||||
### 现象 C:`git commit` 长时间无响应(钩子"卡死")—— 真凶是 **eslint 规则**,不是类型检查
|
||||
- **症状**:在 IDE 里提交后长时间无输出;进程链为 `git.exe commit` → `sh .git/hooks/pre-commit` → `lefthook.exe` → `node .../eslint.js`,其中 **eslint 进程累计吃满 900~1400 秒 CPU 仍在跑**(是满核计算,不是等待,故不会自己恢复;实测卡了 23 分钟)。
|
||||
- **根因**:`eslint-plugin-n` 的 **`n/no-extraneous-import`** 在本仓库(pnpm `node-linker=hoisted` + workspace 包名 `@vben/*` + 包内 `imports` 别名)下解析代价近乎无界:
|
||||
- 单个 10 行的 `apps/web-tdesign/src/main.ts`(仅 3 个 import)跑 **300 秒仍不结束**;
|
||||
- 关闭这条规则后同一文件 **1.7s** 完成;关闭整个 `n/` 插件也是 1.7s ⇒ 唯一元凶就是它;
|
||||
- 对本次待提交的 7 个文件:修复前卡死 >15 分钟 ⇒ 修复后 **1.9s**。
|
||||
- **修复(已完成)**:`internal/lint-configs/eslint-config/src/configs/node.ts` 里把 `n/no-extraneous-import` 置为 `'off'`(含中文注释说明原因)。未声明依赖的兜底改由 pnpm 严格依赖结构 + CI 的 install/build 承担。
|
||||
- ⚠️ 该包经 **`dist/` 分发**(`main: ./dist/index.mjs`,且 `dist` 被 `gitignore`、未被 git 跟踪):**改完 `src` 必须重建才生效**。
|
||||
- ⚠️ 本机重建当前不可用:`pnpm --filter @vben/eslint-config run stub`(tsdown)报 `Failed to import module "unrun"` —— `unrun` 是 tsdown 的可选依赖,hoisted 安装下未落地(`node_modules` 与 `pnpm-lock.yaml` 里都没有)。应急做法:**手工同步 `dist/index.mjs`**(本地派生物,不进仓库);根治可 `pnpm add -D unrun -w` 后再重建。
|
||||
- **可复用的排查方法论(本次靠它一锤定音)**:
|
||||
1. `eslint --no-config-lookup --rule no-unused-vars:error <file>` → 0.5s(排除 eslint 本体)
|
||||
2. `eslint --print-config <file>` → 2.9s(排除配置加载慢)
|
||||
3. 同一文件带完整配置直接 lint → **>300s 不结束** ⇒ 锁定「某条规则执行」阶段
|
||||
4. 用「按前缀批量关闭插件规则」的临时探针配置做二分:先关 `n/` → 1.7s,再逐条缩小到 `n/no-extraneous-import`(`TIMING=all` 需跑完才输出,本例跑不完,故用二分更快)
|
||||
- **附带优化(已完成,`lefthook.yml`)**:`checkType` 由全量改为增量:
|
||||
```yaml
|
||||
- name: checkType
|
||||
run: pnpm exec turbo run typecheck --filter=...[HEAD] --concurrency=4
|
||||
```
|
||||
`--filter=...[HEAD]` 只校验「自 HEAD 起有改动的包 + 依赖它们的包」→ 实测 **38 → 10 个任务**;`--concurrency=4` 限制并发峰值。**注意:这不是卡死根因,只是缩时优化。**
|
||||
- **应急跳过钩子**:`LEFTHOOK=0 git commit ...`(本次提交完全不跑钩子,仅用于纯文档/配置类提交)。
|
||||
- **提示**:跑提交前先停掉 `pnpm dev`,钩子会更快。
|
||||
- **若再次卡死**:先按进程链确认卡在哪个 job(`Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq <lefthook PID> }`),再决定终止:只杀 **叶子** 进程(eslint 等)后,务必确认 `.git/index.lock` 是否残留(见现象 A)。
|
||||
|
||||
### 环境自检命令(只读,秒级)
|
||||
```bash
|
||||
pnpm exec oxlint --version # 应 < 1s 返回 Version: x.y.z
|
||||
pnpm exec turbo run typecheck --dry=json | findstr /c:"\"taskId\"" # 看本次会跑多少任务
|
||||
cmd /c C:\PROGRA~1\nodejs\pnpm.cmd --version # 应为 11.16.0
|
||||
"C:\Program Files\Git\bin\bash.exe" -lc "cd /e/Project/admin.xpcool.com && pnpm --version" # 钩子执行环境(Git Bash)自检
|
||||
```
|
||||
|
||||
## 统一工作约定(xpcool.com 中央规则)
|
||||
|
||||
> 与中央 `../workbuddy.xpcool.com/.workbuddy/memory/MEMORY.md` 保持一致;本项目变更流水在 `.workbuddy/memory/CHANGELOG.md`。
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// 通知模块 API 客户端(渠道/规则/日志/测试)。
|
||||
// 通知模块 API 客户端(渠道/规则/历史记录/测试)。
|
||||
// 接口规范:全部 POST;URL 不含参数;入参一律 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
@ -71,41 +71,142 @@ export function deleteNoticeRule(data: { id: number }) {
|
||||
return requestClient.post('/api/service/admin/notice/rule/delete', data);
|
||||
}
|
||||
|
||||
// ---------- 日志 ----------
|
||||
// ---------- 通知历史记录 ----------
|
||||
export interface NoticeLogItem {
|
||||
id: number;
|
||||
/** 批次号:同一次业务触发(多渠道/多接收人)共用,便于按批次追溯 */
|
||||
batchId: string;
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
/** 事件编码,如 job_fail */
|
||||
eventType: string;
|
||||
/** 事件名称(后端派生),如「任务失败」 */
|
||||
eventName: string;
|
||||
/** 类型编码:job/security/recruit/test/manual/system */
|
||||
noticeType: string;
|
||||
/** 类型名称(后端派生) */
|
||||
typeName: string;
|
||||
/** 分组编码:auto_job/server/recruitment/system */
|
||||
group: string;
|
||||
/** 分组名称(后端派生) */
|
||||
groupName: string;
|
||||
channelCode: string;
|
||||
/** 渠道名称(后端派生) */
|
||||
channelName: string;
|
||||
userId: number;
|
||||
/** 接收人(后端派生:昵称或账号) */
|
||||
userName: string;
|
||||
target: string;
|
||||
title: string;
|
||||
body: string;
|
||||
/** 0 待发送 / 1 成功 / 2 失败 */
|
||||
status: number;
|
||||
statusName: string;
|
||||
/** 兼容旧字段:1 成功 / 0 失败 */
|
||||
result: number;
|
||||
error: string;
|
||||
retryCount: number;
|
||||
durationMs: number;
|
||||
/** 触发来源:auto / manual / test */
|
||||
source: string;
|
||||
remark: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 列表页顶部统计概览 */
|
||||
export interface NoticeLogStats {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
/** 成功率(百分比整数) */
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface NoticeLogListResp {
|
||||
list: NoticeLogItem[];
|
||||
total: number;
|
||||
stats: NoticeLogStats;
|
||||
}
|
||||
|
||||
export function getNoticeLogList(data: {
|
||||
channelCode?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
eventType?: string;
|
||||
export interface NoticeLogQuery {
|
||||
page: number;
|
||||
result?: number;
|
||||
size: number;
|
||||
}) {
|
||||
/** 关键字:标题/内容/目标/错误 模糊匹配 */
|
||||
keyword?: string;
|
||||
noticeType?: string;
|
||||
group?: string;
|
||||
eventType?: string;
|
||||
channelCode?: string;
|
||||
userId?: number;
|
||||
batchId?: string;
|
||||
/** 0 全部 / 1 成功 / 2 失败 */
|
||||
status?: number;
|
||||
/** 起始时间 yyyy-MM-dd HH:mm:ss */
|
||||
dateFrom?: string;
|
||||
/** 结束时间 yyyy-MM-dd HH:mm:ss */
|
||||
dateTo?: string;
|
||||
/** createdAt(默认) | durationMs */
|
||||
orderBy?: string;
|
||||
/** desc(默认) | asc */
|
||||
orderDir?: string;
|
||||
}
|
||||
|
||||
/** 通知历史记录分页查询 */
|
||||
export function getNoticeLogList(data: NoticeLogQuery) {
|
||||
return requestClient.post<NoticeLogListResp>(
|
||||
'/api/service/admin/notice/log/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 通知历史记录详情 */
|
||||
export function getNoticeLogDetail(data: { id: number }) {
|
||||
return requestClient.post<{ item: NoticeLogItem }>(
|
||||
'/api/service/admin/notice/log/detail',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量删除通知历史记录 */
|
||||
export function deleteNoticeLog(data: { ids: number[] }) {
|
||||
return requestClient.post<{ deleted: number }>(
|
||||
'/api/service/admin/notice/log/delete',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 清空通知历史记录:keepDays>0 保留最近 N 天;否则按时间范围;都为空则全清 */
|
||||
export function clearNoticeLog(data: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
keepDays?: number;
|
||||
}) {
|
||||
return requestClient.post<{ deleted: number }>(
|
||||
'/api/service/admin/notice/log/clear',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 字典选项 ----------
|
||||
export interface NoticeMetaItem {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NoticeMetaOptions {
|
||||
channels: NoticeMetaItem[];
|
||||
events: NoticeMetaItem[];
|
||||
types: NoticeMetaItem[];
|
||||
groups: NoticeMetaItem[];
|
||||
}
|
||||
|
||||
/** 通知字典选项(渠道/事件/类型/分组),前端下拉统一由此下发,避免前后端各写一份 */
|
||||
export function getNoticeMetaOptions() {
|
||||
return requestClient.post<NoticeMetaOptions>(
|
||||
'/api/service/admin/notice/log/options',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 测试 ----------
|
||||
export function testNotice(data: {
|
||||
body?: string;
|
||||
@ -121,10 +222,31 @@ export function testNotice(data: {
|
||||
);
|
||||
}
|
||||
|
||||
// 事件类型选项
|
||||
// ---------- 常量兜底(接口不可用时使用,正常情况走 getNoticeMetaOptions) ----------
|
||||
export const NOTICE_EVENT_OPTIONS = [
|
||||
{ label: '任务完成', value: 'job_done' },
|
||||
{ label: '任务失败', value: 'job_fail' },
|
||||
{ label: '安全告警', value: 'security_alert' },
|
||||
{ label: '招聘每日推送', value: 'recruit_daily' },
|
||||
];
|
||||
|
||||
/** 通知状态选项 */
|
||||
export const NOTICE_STATUS_OPTIONS = [
|
||||
{ label: '全部状态', value: 0 },
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 2 },
|
||||
];
|
||||
|
||||
/** 触发来源选项 */
|
||||
export const NOTICE_SOURCE_OPTIONS = [
|
||||
{ label: '自动', value: 'auto' },
|
||||
{ label: '手动', value: 'manual' },
|
||||
{ label: '测试', value: 'test' },
|
||||
];
|
||||
|
||||
/** 状态编码 -> TDesign Tag 主题 */
|
||||
export function noticeStatusTheme(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'danger';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
254
apps/web-tdesign/src/api/wallpaper.ts
Normal file
254
apps/web-tdesign/src/api/wallpaper.ts
Normal file
@ -0,0 +1,254 @@
|
||||
// 壁纸模块 API 客户端。
|
||||
// 接口规范:全部 POST;URL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
|
||||
// 唯一例外是「上传」——文件必须走 multipart/form-data,其余字段随之走表单。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 来源编码:自建图库固定为 mine(与后端 wallpaper.MineSourceCode 保持一致) */
|
||||
export const MINE_SOURCE = 'mine';
|
||||
|
||||
/** 朝向枚举(与后端 dto.WallpaperItem.Orientation 保持一致) */
|
||||
export const ORIENTATION_OPTIONS = [
|
||||
{ label: '不限', value: 0 },
|
||||
{ label: '横版', value: 1 },
|
||||
{ label: '竖版', value: 2 },
|
||||
{ label: '方形', value: 3 },
|
||||
];
|
||||
|
||||
/** 启用状态筛选项(-1 = 全部) */
|
||||
export const ENABLED_OPTIONS = [
|
||||
{ label: '全部', value: -1 },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
];
|
||||
|
||||
/** Wallhaven 分级过滤:默认只放 SFW,避免成人内容误上公网首页 */
|
||||
export const PURITY_OPTIONS = [
|
||||
{ label: 'SFW(安全)', value: 'sfw' },
|
||||
{ label: 'Sketchy(略带暗示)', value: 'sketchy' },
|
||||
{ label: 'NSFW(成人)', value: 'nsfw' },
|
||||
];
|
||||
|
||||
// ---------- 数据类型 ----------
|
||||
|
||||
/** 壁纸条目:开源平台与自建图库共用同一形状,界面只需一套渲染逻辑 */
|
||||
export interface WallpaperItem {
|
||||
id: string;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** 0未知 1横版 2竖版 3方形 */
|
||||
orientation: number;
|
||||
/** 列表网格用(480px 缩略图) */
|
||||
thumbUrl: string;
|
||||
/** 全屏展示用(长边 1920 预览图) */
|
||||
previewUrl: string;
|
||||
/** 下载用(原图) */
|
||||
fullUrl: string;
|
||||
source: string;
|
||||
sourceName: string;
|
||||
/** true=开源平台 false=自建图库 */
|
||||
fromOpen: boolean;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
pageUrl: string;
|
||||
license: string;
|
||||
tags: string;
|
||||
category: string;
|
||||
filesize: number;
|
||||
/** 以下三项仅自建图库有值(开源平台条目为默认值) */
|
||||
enabled: number;
|
||||
sort: number;
|
||||
remark: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 开源平台可用性描述:Enabled(开关)与 Configured(凭据齐备)需分开看 */
|
||||
export interface WallpaperSourceInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
enabled: number;
|
||||
sort: number;
|
||||
/** 凭据是否齐备 */
|
||||
configured: boolean;
|
||||
/** 启用 且 凭据齐备,前端据此置灰 */
|
||||
available: boolean;
|
||||
/** 不可用原因,可用时为空 */
|
||||
hint: string;
|
||||
remark: string;
|
||||
/** 只表示「是否已设置 Key」,明文不回传 */
|
||||
hasApiKey: boolean;
|
||||
/** 脱敏后的配置(apiKey/apiSecret 恒为空串),供编辑界面回显 */
|
||||
config: null | WallpaperSourceConfig;
|
||||
}
|
||||
|
||||
/** 平台配置(对应 wallpaper_source.config 的 JSON) */
|
||||
export interface WallpaperSourceConfig {
|
||||
apiKey?: string;
|
||||
apiSecret?: string;
|
||||
defaultQuery?: string;
|
||||
/** Wallhaven 分级:sfw / sketchy / nsfw */
|
||||
purity?: string;
|
||||
/** Wallhaven 分类:general/anime/people 组合 */
|
||||
categories?: string;
|
||||
/** 覆盖平台接口地址(自建反代或镜像) */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface WallpaperUploadResult {
|
||||
fileName: string;
|
||||
ok: boolean;
|
||||
/** 内容重复,命中已有图片(秒传) */
|
||||
duplicated: boolean;
|
||||
message: string;
|
||||
item: null | WallpaperItem;
|
||||
}
|
||||
|
||||
export interface WallpaperUploadResp {
|
||||
list: WallpaperUploadResult[];
|
||||
okCount: number;
|
||||
failCount: number;
|
||||
}
|
||||
|
||||
export interface WallpaperStat {
|
||||
total: number;
|
||||
enabled: number;
|
||||
disabled: number;
|
||||
portrait: number;
|
||||
landscape: number;
|
||||
/** 原图占用字节 */
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface WallpaperListResp {
|
||||
list: WallpaperItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------- 自建图库(admin) ----------
|
||||
|
||||
export interface WallpaperQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
query?: string;
|
||||
orientation?: number;
|
||||
/** -1 全部 0 停用 1 启用 */
|
||||
enabled?: number;
|
||||
}
|
||||
|
||||
export function getWallpaperList(data: WallpaperQuery) {
|
||||
return requestClient.post<WallpaperListResp>(
|
||||
'/api/service/admin/wallpaper/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传壁纸(可多选)。
|
||||
* 走 multipart/form-data:文件字段名为 files(后端 Files []*ghttp.UploadFile)。
|
||||
* 后端按文件逐条返回结果,允许部分成功。
|
||||
*/
|
||||
export function uploadWallpapers(
|
||||
formData: FormData,
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
return requestClient.post<WallpaperUploadResp>(
|
||||
'/api/service/admin/wallpaper/upload',
|
||||
formData,
|
||||
{
|
||||
// 大图上传耗时可观,需要进度反馈。
|
||||
// 参数类型交给 axios 推导,不手写 ProgressEvent(两者类型不兼容)。
|
||||
onUploadProgress: (event) => {
|
||||
const total = event.total ?? 0;
|
||||
if (onProgress && total > 0) {
|
||||
onProgress(Math.round(((event.loaded ?? 0) / total) * 100));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveWallpaper(data: {
|
||||
category?: string;
|
||||
enabled?: number;
|
||||
id: number;
|
||||
remark?: string;
|
||||
sort?: number;
|
||||
tags?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return requestClient.post<{ message: string }>(
|
||||
'/api/service/admin/wallpaper/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteWallpaper(data: { id: number }) {
|
||||
return requestClient.post<{ message: string }>(
|
||||
'/api/service/admin/wallpaper/delete',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function getWallpaperStats() {
|
||||
return requestClient.post<WallpaperStat>(
|
||||
'/api/service/admin/wallpaper/stats',
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 开源平台配置(admin) ----------
|
||||
|
||||
export function getWallpaperSourceList() {
|
||||
return requestClient.post<{ list: WallpaperSourceInfo[] }>(
|
||||
'/api/service/admin/wallpaper/source/list',
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存平台配置。
|
||||
* config 为 JSON 字符串;其中 apiKey 留空表示「不修改已保存的 Key」,
|
||||
* 这样界面不必把密钥明文回传再原样传回。
|
||||
*/
|
||||
export function saveWallpaperSource(data: {
|
||||
code: string;
|
||||
config?: string;
|
||||
enabled?: number;
|
||||
name?: string;
|
||||
remark?: string;
|
||||
sort?: number;
|
||||
}) {
|
||||
return requestClient.post<{ message: string }>(
|
||||
'/api/service/admin/wallpaper/source/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function testWallpaperSource(data: { code: string }) {
|
||||
return requestClient.post<{ message: string; ok: boolean }>(
|
||||
'/api/service/admin/wallpaper/source/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 展示辅助 ----------
|
||||
|
||||
/** 朝向码转中文名 */
|
||||
export function orientationName(orientation: number): string {
|
||||
return (
|
||||
ORIENTATION_OPTIONS.find((o) => o.value === orientation)?.label ?? '未知'
|
||||
);
|
||||
}
|
||||
|
||||
/** 字节数转可读体积(图库以 MB 为主,保留两位小数) */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
|
||||
}
|
||||
@ -1,47 +1,188 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ActionItem } from '@vben/common-ui';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { NoticeLogItem, NoticeMetaItem } from '#/api/notice';
|
||||
|
||||
import { h, reactive } from 'vue';
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'tdesign-vue-next';
|
||||
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
clearNoticeLog,
|
||||
deleteNoticeLog,
|
||||
getNoticeLogDetail,
|
||||
getNoticeLogList,
|
||||
type NoticeLogItem,
|
||||
getNoticeMetaOptions,
|
||||
NOTICE_STATUS_OPTIONS,
|
||||
noticeStatusTheme,
|
||||
} from '#/api/notice';
|
||||
import { getAdminList } from '#/api/system';
|
||||
|
||||
// 通知日志:查看每条通知的发送目标/渠道/结果
|
||||
// 通知历史记录:记录每次通知的渠道、类型、分组、接收人、详细内容与发送结果。
|
||||
// 支持分页、多维筛选(时间/关键字/分组/类型/事件/渠道/接收人/状态)与排序。
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
// ---------- 筛选条件 ----------
|
||||
const query = reactive({
|
||||
eventType: '',
|
||||
channelCode: '',
|
||||
result: 0,
|
||||
dateRange: [] as string[],
|
||||
keyword: '', // 标题 / 内容 / 目标 / 错误 模糊
|
||||
group: '', // 分组编码
|
||||
noticeType: '', // 类型编码
|
||||
eventType: '', // 事件编码
|
||||
channelCode: '', // 渠道编码
|
||||
userId: undefined as number | undefined, // 接收用户
|
||||
status: 0, // 0 全部 / 1 成功 / 2 失败
|
||||
});
|
||||
|
||||
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
|
||||
if (query.dateRange.length === 2 && query.dateRange[0] && query.dateRange[1]) {
|
||||
return {
|
||||
dateFrom: `${query.dateRange[0]} 00:00:00`,
|
||||
dateTo: `${query.dateRange[1]} 23:59:59`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
// 时间范围单独维护(日期选择器返回 YYYY-MM-DD)
|
||||
const dateRange = ref<string[]>([]);
|
||||
// 排序(前端维护,映射到后端 orderBy/orderDir)
|
||||
const sortKey = ref('time_desc');
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ label: '通知时间 ↓ 新→旧', value: 'time_desc' },
|
||||
{ label: '通知时间 ↑ 旧→新', value: 'time_asc' },
|
||||
{ label: '耗时 ↓ 长→短', value: 'dur_desc' },
|
||||
{ label: '耗时 ↑ 短→长', value: 'dur_asc' },
|
||||
];
|
||||
const sortMap: Record<string, { orderBy: string; orderDir: string }> = {
|
||||
time_desc: { orderBy: 'createdAt', orderDir: 'desc' },
|
||||
time_asc: { orderBy: 'createdAt', orderDir: 'asc' },
|
||||
dur_desc: { orderBy: 'durationMs', orderDir: 'desc' },
|
||||
dur_asc: { orderBy: 'durationMs', orderDir: 'asc' },
|
||||
};
|
||||
|
||||
// ---------- 字典选项(渠道/事件/类型/分组由后端统一下发) ----------
|
||||
const channelOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const eventOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const typeOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const groupOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const userOptions = ref<{ label: string; value: number }[]>([]);
|
||||
|
||||
/** 字典项转下拉选项 */
|
||||
function toSelect(list: NoticeMetaItem[]) {
|
||||
return (list ?? []).map((i) => ({ label: i.name, value: i.code }));
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
/** 加载筛选所需的字典与用户列表 */
|
||||
async function loadBase() {
|
||||
try {
|
||||
const meta = await getNoticeMetaOptions();
|
||||
channelOptions.value = toSelect(meta.channels);
|
||||
eventOptions.value = toSelect(meta.events);
|
||||
typeOptions.value = toSelect(meta.types);
|
||||
groupOptions.value = toSelect(meta.groups);
|
||||
} catch {
|
||||
// 字典接口异常不阻塞列表(关键字等条件仍可用)
|
||||
}
|
||||
try {
|
||||
const us = await getAdminList({ page: 1, size: 100 });
|
||||
userOptions.value = (us.list ?? []).map((u) => ({
|
||||
label: `${u.nickname || u.username}(${u.username})`,
|
||||
value: u.id,
|
||||
}));
|
||||
} catch {
|
||||
// 用户列表异常不阻塞
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 统计概览 ----------
|
||||
const stats = ref({ total: 0, success: 0, failed: 0, successRate: 0 });
|
||||
|
||||
// ---------- 列表 ----------
|
||||
/** 组装查询入参(列表与统计口径一致,均由同一接口返回) */
|
||||
function buildParams(page: number, size: number) {
|
||||
const sort = sortMap[sortKey.value] ?? {
|
||||
orderBy: 'createdAt',
|
||||
orderDir: 'desc',
|
||||
};
|
||||
return {
|
||||
page,
|
||||
size,
|
||||
keyword: query.keyword || undefined,
|
||||
group: query.group || undefined,
|
||||
noticeType: query.noticeType || undefined,
|
||||
eventType: query.eventType || undefined,
|
||||
channelCode: query.channelCode || undefined,
|
||||
userId: query.userId || undefined,
|
||||
status: query.status || undefined,
|
||||
dateFrom: dateRange.value?.[0]
|
||||
? `${dateRange.value[0]} 00:00:00`
|
||||
: undefined,
|
||||
dateTo: dateRange.value?.[1] ? `${dateRange.value[1]} 23:59:59` : undefined,
|
||||
orderBy: sort.orderBy,
|
||||
orderDir: sort.orderDir,
|
||||
};
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions<NoticeLogItem> = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'createdAt', title: '时间', width: 165 },
|
||||
{ field: 'eventType', title: '事件', width: 110 },
|
||||
{ field: 'channelCode', title: '渠道', width: 100 },
|
||||
{ field: 'target', title: '目标', width: 140, showOverflow: 'tooltip' },
|
||||
{ field: 'title', title: '标题', width: 180, showOverflow: 'tooltip' },
|
||||
{ field: 'body', title: '内容', minWidth: 200, showOverflow: 'tooltip' },
|
||||
{ type: 'checkbox', width: 46 }, // 多选列(配合批量删除)
|
||||
{ field: 'createdAt', title: '通知时间', width: 165 },
|
||||
{
|
||||
field: 'result',
|
||||
field: 'groupName',
|
||||
title: '分组',
|
||||
width: 110,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h('span', row.groupName || row.group || '-'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'typeName',
|
||||
title: '类型',
|
||||
width: 110,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h('span', row.typeName || row.noticeType || '-'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'eventName',
|
||||
title: '事件',
|
||||
width: 120,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h('span', row.eventName || row.eventType || '-'),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'channelName',
|
||||
title: '渠道',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{ theme: 'primary', variant: 'light', size: 'small' },
|
||||
row.channelName || row.channelCode || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'userName',
|
||||
title: '接收人',
|
||||
width: 120,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h('span', row.userName || (row.userId > 0 ? `#${row.userId}` : '-')),
|
||||
},
|
||||
},
|
||||
{ field: 'title', title: '标题', minWidth: 160, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'body',
|
||||
title: '详细内容',
|
||||
minWidth: 220,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'statusName',
|
||||
title: '结果',
|
||||
width: 90,
|
||||
slots: {
|
||||
@ -49,15 +190,64 @@ const gridOptions: VxeTableGridOptions = {
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.result === 1 ? 'success' : 'danger',
|
||||
theme: noticeStatusTheme(row.status),
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.result === 1 ? '成功' : '失败',
|
||||
row.statusName || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'error', title: '错误', width: 160, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'durationMs',
|
||||
title: '耗时(ms)',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: row.durationMs >= 3000 ? 'text-red-500' : 'text-gray-600',
|
||||
},
|
||||
String(row.durationMs ?? 0),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'error',
|
||||
title: '错误信息',
|
||||
minWidth: 160,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: row.error ? 'text-red-500' : 'text-gray-400' },
|
||||
row.error || '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: NoticeLogItem }) => {
|
||||
const actions: ActionItem[] = [
|
||||
{ text: '详情', onClick: () => openDetail(row) },
|
||||
];
|
||||
if (hasAccessByCodes(['notice:log:delete'])) {
|
||||
actions.push({
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete([row.id]),
|
||||
});
|
||||
}
|
||||
return h(VbenTableAction, { actions });
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 20 },
|
||||
proxyConfig: {
|
||||
@ -67,14 +257,16 @@ const gridOptions: VxeTableGridOptions = {
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getNoticeLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
eventType: query.eventType || undefined,
|
||||
channelCode: query.channelCode || undefined,
|
||||
result: query.result || undefined,
|
||||
...rangeToParam(),
|
||||
});
|
||||
const data = await getNoticeLogList(
|
||||
buildParams(page.currentPage, page.pageSize),
|
||||
);
|
||||
// 统计概览与列表同源,随筛选条件同步刷新
|
||||
stats.value = data.stats ?? {
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
@ -83,68 +275,349 @@ const gridOptions: VxeTableGridOptions = {
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
/** 触发查询(表格自动携带分页条件) */
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置筛选并回到第一页重新查询 */
|
||||
function reset() {
|
||||
query.keyword = '';
|
||||
query.group = '';
|
||||
query.noticeType = '';
|
||||
query.eventType = '';
|
||||
query.channelCode = '';
|
||||
query.result = 0;
|
||||
query.dateRange = [];
|
||||
query.userId = undefined;
|
||||
query.status = 0;
|
||||
sortKey.value = 'time_desc';
|
||||
dateRange.value = [];
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
// ---------- 详情 ----------
|
||||
const detail = ref<NoticeLogItem | null>(null);
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
title: '通知详情',
|
||||
footer: false,
|
||||
});
|
||||
|
||||
/** 关联规则展示:规则名(#ID);无名称时仅显示 #ID;都没有则为 - */
|
||||
function ruleText(d: NoticeLogItem) {
|
||||
if (d.ruleName) return `${d.ruleName}(#${d.ruleId})`;
|
||||
if (d.ruleId > 0) return `#${d.ruleId}`;
|
||||
return '-';
|
||||
}
|
||||
|
||||
const detailRows = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d) return [];
|
||||
return [
|
||||
{ label: '通知时间', value: d.createdAt },
|
||||
{ label: '批次号', value: d.batchId || '-' },
|
||||
{ label: '分组', value: d.groupName || d.group || '-' },
|
||||
{ label: '类型', value: d.typeName || d.noticeType || '-' },
|
||||
{ label: '事件', value: `${d.eventName || d.eventType}(${d.eventType})` },
|
||||
{ label: '渠道', value: d.channelName || d.channelCode },
|
||||
{
|
||||
label: '接收人',
|
||||
value: d.userName || (d.userId > 0 ? `#${d.userId}` : '-'),
|
||||
},
|
||||
{ label: '触发来源', value: d.source || '-' },
|
||||
{ label: '关联规则', value: ruleText(d) },
|
||||
{ label: '发送结果', value: d.statusName || '-' },
|
||||
{ label: '接口耗时', value: `${d.durationMs ?? 0} ms` },
|
||||
{ label: '重试次数', value: String(d.retryCount ?? 0) },
|
||||
{ label: '备注', value: d.remark || '-' },
|
||||
];
|
||||
});
|
||||
|
||||
/** 打开详情抽屉:先展示列表行数据,再拉取完整详情兜底 */
|
||||
async function openDetail(row: NoticeLogItem) {
|
||||
detail.value = row;
|
||||
detailDrawerApi.setState({ title: `通知详情 #${row.id}` });
|
||||
detailDrawerApi.open();
|
||||
try {
|
||||
const res = await getNoticeLogDetail({ id: row.id });
|
||||
if (res.item) detail.value = res.item;
|
||||
} catch {
|
||||
// 详情接口失败时沿用列表行数据展示
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制文本到剪贴板 */
|
||||
function copyText(text: string, label = '内容') {
|
||||
if (!text) {
|
||||
MessagePlugin.warning(`没有可复制的${label}`);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => MessagePlugin.success(`${label}已复制`));
|
||||
}
|
||||
|
||||
// ---------- 删除 / 清空 ----------
|
||||
/** 收集当前勾选的记录 ID */
|
||||
function getSelectedIds(): number[] {
|
||||
const records = (gridApi.grid?.getCheckboxRecords() ?? []) as NoticeLogItem[];
|
||||
return records.map((r) => Number(r.id));
|
||||
}
|
||||
|
||||
/** 删除指定记录(批量) */
|
||||
function onDelete(ids: number[]) {
|
||||
if (ids.length === 0) {
|
||||
MessagePlugin.warning('请先勾选要删除的记录');
|
||||
return;
|
||||
}
|
||||
const d = DialogPlugin.confirm({
|
||||
header: `确认删除选中的 ${ids.length} 条通知历史记录?`,
|
||||
body: '删除后不可恢复,仅删除记录本身,不影响已发送的通知。',
|
||||
theme: 'warning',
|
||||
onConfirm: async () => {
|
||||
const res = await deleteNoticeLog({ ids });
|
||||
MessagePlugin.success(`已删除 ${res.deleted} 条记录`);
|
||||
d.destroy();
|
||||
gridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 清空历史记录:可选保留最近 N 天 */
|
||||
function onClear() {
|
||||
const keepDays = ref(30);
|
||||
const d = DialogPlugin.confirm({
|
||||
header: '清空通知历史记录',
|
||||
body: () =>
|
||||
h('div', { class: 'flex flex-col gap-2' }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'text-sm text-gray-500' },
|
||||
'选择保留最近若干天的记录,更早的记录将被永久删除;填 0 表示全部清空。',
|
||||
),
|
||||
h('input', {
|
||||
class: 't-input__inner',
|
||||
defaultValue: '30',
|
||||
placeholder: '保留最近 N 天(0 = 全部清空)',
|
||||
style:
|
||||
'padding:6px 8px;border:1px solid #dcdcdc;border-radius:4px;width:100%',
|
||||
onInput: (e: Event) => {
|
||||
keepDays.value = Number((e.target as HTMLInputElement).value) || 0;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
theme: 'warning',
|
||||
onConfirm: async () => {
|
||||
const res = await clearNoticeLog({ keepDays: keepDays.value });
|
||||
MessagePlugin.success(`已清空 ${res.deleted} 条记录`);
|
||||
d.destroy();
|
||||
gridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(loadBase);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="wb-page-head">
|
||||
<h2 class="wb-page-title">通知日志</h2>
|
||||
<p class="wb-page-sub">每条通知的发送记录:目标、渠道、结果与错误信息</p>
|
||||
<h2 class="wb-page-title">通知历史</h2>
|
||||
<p class="wb-page-sub">
|
||||
记录每一次通知的分组、类型、渠道、接收人与详细内容,可按时间、渠道、结果等维度追溯排查
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-select
|
||||
v-model="query.eventType"
|
||||
clearable
|
||||
:options="[
|
||||
{ label: '任务完成', value: 'job_done' },
|
||||
{ label: '任务失败', value: 'job_fail' },
|
||||
{ label: '安全告警', value: 'security_alert' },
|
||||
{ label: '测试', value: 'test' },
|
||||
]"
|
||||
placeholder="事件类型"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.channelCode"
|
||||
clearable
|
||||
placeholder="渠道(bark/pushplus)"
|
||||
style="width: 150px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.result"
|
||||
clearable
|
||||
:options="[
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 2 },
|
||||
]"
|
||||
placeholder="结果"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-date-range-picker
|
||||
v-model="query.dateRange"
|
||||
clearable
|
||||
placeholder="时间范围"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
<!-- 统计概览(与列表筛选条件同源) -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<t-card :bordered="false">
|
||||
<div class="text-sm text-gray-500">通知总数</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.total }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="success">
|
||||
<div class="text-sm text-gray-500">发送成功</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.success }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="danger">
|
||||
<div class="text-sm text-gray-500">发送失败</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.failed }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="primary">
|
||||
<div class="text-sm text-gray-500">成功率</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.successRate }}%</div>
|
||||
</t-card>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<t-card :bordered="false" class="wb-filter-card">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<t-date-range-picker
|
||||
v-model="dateRange"
|
||||
value-type="YYYY-MM-DD"
|
||||
clearable
|
||||
separator="~"
|
||||
:placeholder="['通知开始日期', '通知结束日期']"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="标题 / 内容 / 目标 / 错误"
|
||||
style="width: 210px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.group"
|
||||
clearable
|
||||
:options="groupOptions"
|
||||
placeholder="分组"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.noticeType"
|
||||
clearable
|
||||
:options="typeOptions"
|
||||
placeholder="类型"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.eventType"
|
||||
clearable
|
||||
:options="eventOptions"
|
||||
placeholder="事件"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.channelCode"
|
||||
clearable
|
||||
:options="channelOptions"
|
||||
placeholder="渠道"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.userId"
|
||||
clearable
|
||||
filterable
|
||||
:options="userOptions"
|
||||
placeholder="接收人"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.status"
|
||||
:options="NOTICE_STATUS_OPTIONS"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="sortKey"
|
||||
:options="SORT_OPTIONS"
|
||||
style="width: 165px"
|
||||
/>
|
||||
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<div class="mb-2 flex items-center justify-end gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['notice:log:delete'])"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
@click="onDelete(getSelectedIds())"
|
||||
>
|
||||
批量删除
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['notice:log:clear'])"
|
||||
theme="warning"
|
||||
variant="outline"
|
||||
@click="onClear"
|
||||
>
|
||||
清空历史
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="wb-table-card">
|
||||
<Grid />
|
||||
</t-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<DetailDrawer class="w-160">
|
||||
<div v-if="detail" class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div
|
||||
v-for="item in detailRows"
|
||||
:key="item.label"
|
||||
class="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span class="w-24 shrink-0 text-gray-500">{{ item.label }}</span>
|
||||
<span class="break-all">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t-card :bordered="false" class="mt-2">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">通知标题</span>
|
||||
<t-button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="primary"
|
||||
@click="copyText(detail.title, '标题')"
|
||||
>
|
||||
复制
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="break-all text-sm">{{ detail.title || '-' }}</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">详细内容</span>
|
||||
<t-button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="primary"
|
||||
@click="copyText(detail.body, '内容')"
|
||||
>
|
||||
复制
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="whitespace-pre-wrap break-all text-sm">
|
||||
{{ detail.body || '-' }}
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card :bordered="false">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">发送目标</span>
|
||||
<t-button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="primary"
|
||||
@click="copyText(detail.target, '目标')"
|
||||
>
|
||||
复制
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="break-all font-mono text-sm">
|
||||
{{ detail.target || '-' }}
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card v-if="detail.error" :bordered="false" theme="danger">
|
||||
<template #header>
|
||||
<span class="font-medium">错误信息</span>
|
||||
</template>
|
||||
<div class="whitespace-pre-wrap break-all text-sm text-red-500">
|
||||
{{ detail.error }}
|
||||
</div>
|
||||
</t-card>
|
||||
</div>
|
||||
</DetailDrawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
579
apps/web-tdesign/src/views/wallpaper/library/index.vue
Normal file
579
apps/web-tdesign/src/views/wallpaper/library/index.vue
Normal file
@ -0,0 +1,579 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
// 壁纸图库管理:批量上传 + 列表筛选 + 元信息编辑 + 删除 + 全屏预览。
|
||||
//
|
||||
// 两处刻意不用 TDesign t-upload:
|
||||
// 1) 后端按文件逐条返回结果(成功/失败/秒传重复),需要一个能承载
|
||||
// 「逐条结果」的反馈界面,而不是统一的成功/失败提示;
|
||||
// 2) 上传是 multipart,需要精确控制 FormData 字段名(files)。
|
||||
// 因此用隐藏 file input + t-button 触发,交互与 t-upload 一致但完全可控。
|
||||
import type {
|
||||
WallpaperItem,
|
||||
WallpaperStat,
|
||||
WallpaperUploadResp,
|
||||
} from '#/api/wallpaper';
|
||||
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
DialogPlugin,
|
||||
Image,
|
||||
MessagePlugin,
|
||||
Progress,
|
||||
Tag,
|
||||
} from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteWallpaper,
|
||||
ENABLED_OPTIONS,
|
||||
formatBytes,
|
||||
getWallpaperList,
|
||||
getWallpaperStats,
|
||||
ORIENTATION_OPTIONS,
|
||||
orientationName,
|
||||
saveWallpaper,
|
||||
uploadWallpapers,
|
||||
} from '#/api/wallpaper';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
// ---------------- 统计概览 ----------------
|
||||
const stat = ref<null | WallpaperStat>(null);
|
||||
|
||||
async function loadStats() {
|
||||
if (!hasAccessByCodes(['wallpaper:library:stats'])) return;
|
||||
try {
|
||||
stat.value = await getWallpaperStats();
|
||||
} catch {
|
||||
// 概览属锦上添花:拿不到就不显示,不打断主流程
|
||||
stat.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const statCards = computed(() => {
|
||||
const s = stat.value;
|
||||
if (!s) return [];
|
||||
return [
|
||||
{ label: '总数', value: String(s.total) },
|
||||
{ label: '启用', value: String(s.enabled) },
|
||||
{ label: '停用', value: String(s.disabled) },
|
||||
{ label: '横版', value: String(s.landscape) },
|
||||
{ label: '竖版', value: String(s.portrait) },
|
||||
{ label: '占用', value: formatBytes(s.totalBytes) },
|
||||
];
|
||||
});
|
||||
|
||||
// ---------------- 列表与筛选 ----------------
|
||||
const query = reactive({
|
||||
query: '',
|
||||
orientation: 0,
|
||||
enabled: -1,
|
||||
});
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{
|
||||
field: 'thumbUrl',
|
||||
title: '预览图',
|
||||
width: 120,
|
||||
slots: {
|
||||
// 缩略图点击直接全屏查看大图(预览图,非原图)
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center justify-center',
|
||||
style: 'cursor: zoom-in',
|
||||
onClick: () => openPreview(row),
|
||||
},
|
||||
[
|
||||
h(Image, {
|
||||
src: row.thumbUrl,
|
||||
fit: 'cover',
|
||||
style: 'width: 88px; height: 56px; border-radius: 4px',
|
||||
alt: row.title,
|
||||
}),
|
||||
],
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{
|
||||
field: 'title',
|
||||
title: '标题',
|
||||
minWidth: 180,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'size',
|
||||
title: '尺寸',
|
||||
width: 120,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h(
|
||||
'span',
|
||||
row.width && row.height ? `${row.width}×${row.height}` : '-',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'orientation',
|
||||
title: '朝向',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h('span', orientationName(row.orientation)),
|
||||
},
|
||||
},
|
||||
{ field: 'category', title: '分类', width: 100 },
|
||||
{ field: 'tags', title: '标签', width: 150, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'filesize',
|
||||
title: '体积',
|
||||
width: 90,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h('span', formatBytes(row.filesize)),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '状态',
|
||||
width: 80,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.enabled === 1 ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.enabled === 1 ? '启用' : '停用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{ field: 'createdAt', title: '上传时间', width: 160 },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 190,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperItem }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
{ text: '全屏', onClick: () => openPreview(row) },
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['wallpaper:library:save'],
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
auth: ['wallpaper:library:delete'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 12 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getWallpaperList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
query: query.query || undefined,
|
||||
orientation: query.orientation || undefined,
|
||||
enabled: query.enabled < 0 ? undefined : query.enabled,
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function search() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(query, { query: '', orientation: 0, enabled: -1 });
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
// ---------------- 批量上传 ----------------
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploading = ref(false);
|
||||
const uploadPercent = ref(0);
|
||||
const uploadForm = reactive({ category: '', sort: 0, tags: '', title: '' });
|
||||
// 逐条结果(后端允许部分成功,故单独承载而不是一句成功/失败)
|
||||
const report = ref<null | WallpaperUploadResp>(null);
|
||||
const reportOpen = ref(false);
|
||||
|
||||
function pickFiles() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFilesPicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = [...(input.files ?? [])];
|
||||
// 立即清空,保证连续两次选同一批文件也能触发 change
|
||||
input.value = '';
|
||||
if (files.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
if (uploadForm.title) formData.append('title', uploadForm.title);
|
||||
if (uploadForm.tags) formData.append('tags', uploadForm.tags);
|
||||
if (uploadForm.category) formData.append('category', uploadForm.category);
|
||||
formData.append('sort', String(uploadForm.sort || 0));
|
||||
|
||||
uploading.value = true;
|
||||
uploadPercent.value = 0;
|
||||
try {
|
||||
const resp = await uploadWallpapers(formData, (percent) => {
|
||||
uploadPercent.value = percent;
|
||||
});
|
||||
report.value = resp;
|
||||
reportOpen.value = true;
|
||||
await Promise.all([gridApi.query(), loadStats()]);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传结束后清空仅对本次生效的字段,避免影响下一批 */
|
||||
function afterReportClose() {
|
||||
Object.assign(uploadForm, { category: '', sort: 0, tags: '', title: '' });
|
||||
}
|
||||
|
||||
// ---------------- 编辑 ----------------
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '编辑壁纸',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
enabled: 1,
|
||||
sort: 0,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
function openEdit(row: WallpaperItem) {
|
||||
Object.assign(form, {
|
||||
id: Number(row.id),
|
||||
title: row.title,
|
||||
tags: row.tags,
|
||||
category: row.category,
|
||||
enabled: row.enabled,
|
||||
sort: row.sort,
|
||||
remark: row.remark,
|
||||
});
|
||||
drawerApi.setState({ title: `编辑壁纸 #${row.id}` });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
drawerApi.lock(true);
|
||||
try {
|
||||
await saveWallpaper({
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
tags: form.tags,
|
||||
category: form.category,
|
||||
enabled: form.enabled,
|
||||
sort: form.sort,
|
||||
remark: form.remark,
|
||||
});
|
||||
MessagePlugin.success('已保存');
|
||||
drawerApi.close();
|
||||
await gridApi.query();
|
||||
} finally {
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(row: WallpaperItem) {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: '删除该壁纸?',
|
||||
body: `将同时删除原图、缩略图与预览图,不可恢复:${row.title || row.id}`,
|
||||
onConfirm: async () => {
|
||||
await deleteWallpaper({ id: Number(row.id) });
|
||||
MessagePlugin.success('已删除');
|
||||
dialog.destroy();
|
||||
await Promise.all([gridApi.query(), loadStats()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 全屏预览 ----------------
|
||||
// 用 Fullscreen API 让浮层真正全屏;浏览器拒绝时退化为普通遮罩(仍可看大图)。
|
||||
const previewOpen = ref(false);
|
||||
const previewRow = ref<null | WallpaperItem>(null);
|
||||
const previewRef = ref<HTMLElement | null>(null);
|
||||
|
||||
async function openPreview(row: WallpaperItem) {
|
||||
previewRow.value = row;
|
||||
previewOpen.value = true;
|
||||
await nextTick();
|
||||
try {
|
||||
await previewRef.value?.requestFullscreen?.();
|
||||
} catch {
|
||||
// 非用户手势或浏览器不允许时忽略,遮罩模式已可用
|
||||
}
|
||||
}
|
||||
|
||||
async function closePreview() {
|
||||
if (document.fullscreenElement) {
|
||||
try {
|
||||
await document.exitFullscreen();
|
||||
} catch {
|
||||
// 退出全屏失败不影响关闭遮罩
|
||||
}
|
||||
}
|
||||
previewOpen.value = false;
|
||||
previewRow.value = null;
|
||||
}
|
||||
|
||||
/** 用户按 Esc(或系统手势)退出全屏时,同步关闭遮罩 */
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && previewOpen.value) {
|
||||
previewOpen.value = false;
|
||||
previewRow.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && previewOpen.value) {
|
||||
void closePreview();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
void loadStats();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 概览:无 stats 权限或接口不可用时整块隐藏 -->
|
||||
<div v-if="statCards.length > 0" class="mb-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="card in statCards"
|
||||
:key="card.label"
|
||||
class="rounded border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span class="opacity-60">{{ card.label }}</span>
|
||||
<span class="ml-1 font-semibold">{{ card.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传区:标题/标签/分类为「本批生效」,上传后自动清空 -->
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<t-button
|
||||
v-if="hasAccessByCodes(['wallpaper:library:upload'])"
|
||||
theme="primary"
|
||||
:loading="uploading"
|
||||
@click="pickFiles"
|
||||
>
|
||||
批量上传
|
||||
</t-button>
|
||||
<t-input
|
||||
v-model="uploadForm.title"
|
||||
:disabled="uploading"
|
||||
clearable
|
||||
placeholder="标题(留空用文件名;多选时忽略)"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="uploadForm.tags"
|
||||
:disabled="uploading"
|
||||
clearable
|
||||
placeholder="标签,英文逗号分隔"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<t-input
|
||||
v-model="uploadForm.category"
|
||||
:disabled="uploading"
|
||||
clearable
|
||||
placeholder="分类"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<t-input-number
|
||||
v-model="uploadForm.sort"
|
||||
:disabled="uploading"
|
||||
:min="0"
|
||||
placeholder="排序"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<span v-if="uploading" class="flex items-center gap-2">
|
||||
<Progress
|
||||
theme="line"
|
||||
:percentage="uploadPercent"
|
||||
:label="`${uploadPercent}%`"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span class="text-xs opacity-60">上传中,请勿关闭页面</span>
|
||||
</span>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilesPicked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.query"
|
||||
clearable
|
||||
placeholder="标题/标签/分类关键字"
|
||||
style="width: 220px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.orientation"
|
||||
:options="ORIENTATION_OPTIONS"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.enabled"
|
||||
:options="ENABLED_OPTIONS"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<Grid />
|
||||
|
||||
<!-- 上传结果:逐条列出,允许部分成功 -->
|
||||
<t-dialog
|
||||
v-model:visible="reportOpen"
|
||||
header="上传结果"
|
||||
:footer="false"
|
||||
width="560px"
|
||||
@closed="afterReportClose"
|
||||
>
|
||||
<div v-if="report" class="mb-2 text-sm">
|
||||
成功 <b>{{ report.okCount }}</b> 张,失败
|
||||
<b>{{ report.failCount }}</b> 张
|
||||
</div>
|
||||
<div class="max-h-[50vh] overflow-auto">
|
||||
<div
|
||||
v-for="(item, index) in report?.list ?? []"
|
||||
:key="index"
|
||||
class="flex items-center gap-2 border-b py-1.5 text-sm last:border-b-0"
|
||||
>
|
||||
<t-tag
|
||||
:theme="
|
||||
item.ok ? (item.duplicated ? 'warning' : 'success') : 'danger'
|
||||
"
|
||||
variant="light"
|
||||
size="small"
|
||||
>
|
||||
{{ item.ok ? (item.duplicated ? '已存在' : '成功') : '失败' }}
|
||||
</t-tag>
|
||||
<span class="flex-1 truncate" :title="item.fileName">
|
||||
{{ item.fileName }}
|
||||
</span>
|
||||
<span class="opacity-60">{{ item.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 元信息编辑抽屉 -->
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="80px">
|
||||
<t-form-item label="标题">
|
||||
<t-input v-model="form.title" placeholder="留空则用文件名" />
|
||||
</t-form-item>
|
||||
<t-form-item label="标签">
|
||||
<t-input
|
||||
v-model="form.tags"
|
||||
placeholder="英文逗号分隔,如:山,雾,清晨"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="分类">
|
||||
<t-input v-model="form.category" placeholder="自由文本,如:风景" />
|
||||
</t-form-item>
|
||||
<t-form-item label="排序">
|
||||
<t-input-number v-model="form.sort" :min="0" style="width: 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="form.remark" :autosize="{ minRows: 2 }" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</Drawer>
|
||||
|
||||
<!-- 全屏预览:requestFullscreen 成功后本节点即全屏容器 -->
|
||||
<div
|
||||
v-if="previewOpen"
|
||||
ref="previewRef"
|
||||
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black"
|
||||
@click.self="closePreview"
|
||||
>
|
||||
<img
|
||||
v-if="previewRow"
|
||||
:src="previewRow.previewUrl"
|
||||
:alt="previewRow.title"
|
||||
class="max-h-[86vh] max-w-[94vw] object-contain"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm text-white/80">
|
||||
<span class="max-w-[70vw] truncate">
|
||||
{{ previewRow?.title || '未命名' }}
|
||||
</span>
|
||||
<span v-if="previewRow?.width">
|
||||
{{ previewRow?.width }}×{{ previewRow?.height }}
|
||||
</span>
|
||||
<span v-if="previewRow?.filesize">
|
||||
{{ formatBytes(previewRow?.filesize ?? 0) }}
|
||||
</span>
|
||||
<t-button size="small" variant="outline" @click="closePreview">
|
||||
关闭(Esc)
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
320
apps/web-tdesign/src/views/wallpaper/source/index.vue
Normal file
320
apps/web-tdesign/src/views/wallpaper/source/index.vue
Normal file
@ -0,0 +1,320 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
// 壁纸开源平台配置:开关、排序、API Key、默认搜索词、连通性测试。
|
||||
//
|
||||
// 关于「可用」的三态:启用开关(enabled)与凭据齐备(configured)是两回事,
|
||||
// 只有两者同时为真平台才真的能被前台取到图。列表把三态都显示出来,
|
||||
// 否则用户会遇到「开关开了但前台没有这个来源」却查不出原因的情况。
|
||||
import type { WallpaperSourceInfo } from '#/api/wallpaper';
|
||||
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
|
||||
|
||||
import { MessagePlugin, Switch, Tag } from 'tdesign-vue-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getWallpaperSourceList,
|
||||
PURITY_OPTIONS,
|
||||
saveWallpaperSource,
|
||||
testWallpaperSource,
|
||||
} from '#/api/wallpaper';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
/**
|
||||
* 凭据状态文案。
|
||||
* 免 Key 的平台(Bing / Picsum)不该显示「缺 Key」,否则用户会去到处找 Key。
|
||||
*/
|
||||
function credentialText(row: WallpaperSourceInfo): string {
|
||||
if (!NEEDS_KEY.has(row.code)) return '免配置';
|
||||
return row.configured ? '已配置' : '缺 Key';
|
||||
}
|
||||
|
||||
/** 需要 API Key 的平台(与后端 provider.RequiresKey() 对应,仅用于界面提示) */
|
||||
const NEEDS_KEY = new Set(['pexels', 'unsplash', 'wallhaven']);
|
||||
/** 需要分级/分类配置的平台 */
|
||||
const WALLHAVEN = 'wallhaven';
|
||||
|
||||
async function load() {
|
||||
const data = await getWallpaperSourceList();
|
||||
gridApi.setGridOptions({ data: data.list ?? [] });
|
||||
}
|
||||
|
||||
// ---------------- 开关:就地保存 ----------------
|
||||
// TDesign Switch 的 change 值类型是 string | number | boolean,统一收敛成 0/1。
|
||||
async function toggleEnabled(
|
||||
row: WallpaperSourceInfo,
|
||||
value: boolean | number | string,
|
||||
) {
|
||||
const next = Number(value) === 1 ? 1 : 0;
|
||||
if (!hasAccessByCodes(['wallpaper:source:save'])) {
|
||||
MessagePlugin.warning('没有保存平台配置的权限');
|
||||
return;
|
||||
}
|
||||
// 只提交开关与展示字段,config 整个不传 —— 后端会保留原有配置,
|
||||
// 免得「只想开个开关」却把 Key 与默认搜索词一起清掉。
|
||||
await saveWallpaperSource({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
enabled: next,
|
||||
sort: row.sort,
|
||||
remark: row.remark,
|
||||
});
|
||||
MessagePlugin.success(next === 1 ? '已启用' : '已停用');
|
||||
await load();
|
||||
}
|
||||
|
||||
// ---------------- 单条测试 ----------------
|
||||
const testing = ref('');
|
||||
|
||||
async function onTest(row: WallpaperSourceInfo) {
|
||||
testing.value = row.code;
|
||||
try {
|
||||
const data = await testWallpaperSource({ code: row.code });
|
||||
if (data.ok) {
|
||||
MessagePlugin.success(data.message || '连通正常');
|
||||
} else {
|
||||
MessagePlugin.error(data.message || '连通失败');
|
||||
}
|
||||
} finally {
|
||||
testing.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 配置抽屉 ----------------
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '平台配置',
|
||||
onConfirm: submit,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
enabled: 1,
|
||||
sort: 0,
|
||||
remark: '',
|
||||
apiKey: '',
|
||||
defaultQuery: '',
|
||||
purity: 'sfw',
|
||||
categories: '',
|
||||
baseUrl: '',
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
function openEdit(row: WallpaperSourceInfo) {
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
sort: row.sort,
|
||||
remark: row.remark,
|
||||
// 密钥明文永远不回传,这里只显示「已设置/未设置」并由用户决定是否重填
|
||||
apiKey: '',
|
||||
defaultQuery: row.config?.defaultQuery ?? '',
|
||||
purity: row.config?.purity || 'sfw',
|
||||
categories: row.config?.categories ?? '',
|
||||
baseUrl: row.config?.baseUrl ?? '',
|
||||
hasApiKey: row.hasApiKey,
|
||||
});
|
||||
drawerApi.setState({ title: `平台配置 · ${row.name}` });
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
drawerApi.lock(true);
|
||||
try {
|
||||
// config 以 JSON 字符串提交;apiKey 留空即表示「不修改已保存的 Key」。
|
||||
const config: Record<string, string> = {};
|
||||
if (form.apiKey) config.apiKey = form.apiKey;
|
||||
if (form.defaultQuery) config.defaultQuery = form.defaultQuery;
|
||||
if (form.baseUrl) config.baseUrl = form.baseUrl;
|
||||
if (form.code === WALLHAVEN) {
|
||||
config.purity = form.purity;
|
||||
if (form.categories) config.categories = form.categories;
|
||||
}
|
||||
|
||||
await saveWallpaperSource({
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
enabled: form.enabled,
|
||||
sort: form.sort,
|
||||
remark: form.remark,
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
MessagePlugin.success('已保存');
|
||||
drawerApi.close();
|
||||
await load();
|
||||
} finally {
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 表格 ----------------
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'name', title: '平台', width: 160 },
|
||||
{ field: 'code', title: '编码', width: 110 },
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperSourceInfo }) =>
|
||||
h(Switch, {
|
||||
value: row.enabled,
|
||||
customValue: [1, 0],
|
||||
size: 'small',
|
||||
// 无权限时禁用,避免点了没反应
|
||||
disabled: !hasAccessByCodes(['wallpaper:source:save']),
|
||||
onChange: (value: boolean | number | string) =>
|
||||
toggleEnabled(row, value),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'configured',
|
||||
title: '凭据',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperSourceInfo }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.configured ? 'success' : 'warning',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
credentialText(row),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'available',
|
||||
title: '前台可用',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperSourceInfo }) =>
|
||||
h(
|
||||
Tag,
|
||||
{
|
||||
theme: row.available ? 'success' : 'default',
|
||||
variant: 'light',
|
||||
size: 'small',
|
||||
},
|
||||
row.available ? '可用' : '不可用',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'hint',
|
||||
title: '说明',
|
||||
minWidth: 150,
|
||||
showOverflow: 'tooltip',
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperSourceInfo }) =>
|
||||
h('span', { class: 'opacity-70' }, row.hint || '-'),
|
||||
},
|
||||
},
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'remark', title: '备注', minWidth: 180, showOverflow: 'tooltip' },
|
||||
{
|
||||
field: 'op',
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: WallpaperSourceInfo }) =>
|
||||
h(VbenTableAction, {
|
||||
actions: [
|
||||
{
|
||||
text: '配置',
|
||||
onClick: () => openEdit(row),
|
||||
auth: ['wallpaper:source:save'],
|
||||
},
|
||||
{
|
||||
text: '测试',
|
||||
loading: testing.value === row.code,
|
||||
onClick: () => onTest(row),
|
||||
auth: ['wallpaper:source:test'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pagerConfig: { enabled: false }, // 平台数量固定,不分页
|
||||
proxyConfig: { enabled: false }, // 本地数据模式,由 load() 写入
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<t-button variant="outline" @click="load">刷新</t-button>
|
||||
<span class="text-xs opacity-60">
|
||||
提示:开启平台后需凭据齐备,前台才会出现该来源;「测试」会真实请求一次平台接口。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Grid />
|
||||
|
||||
<Drawer>
|
||||
<t-form label-align="right" label-width="100px">
|
||||
<t-form-item label="平台">
|
||||
<t-input v-model="form.name" />
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
<t-form-item label="排序">
|
||||
<t-input-number v-model="form.sort" :min="0" style="width: 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="API Key">
|
||||
<t-input
|
||||
v-model="form.apiKey"
|
||||
:placeholder="
|
||||
form.hasApiKey
|
||||
? '已设置,留空表示不修改'
|
||||
: '未设置,请粘贴平台申请的 Key'
|
||||
"
|
||||
type="password"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="默认搜索词">
|
||||
<t-input
|
||||
v-model="form.defaultQuery"
|
||||
placeholder="前台未传关键词时使用,可留空"
|
||||
/>
|
||||
</t-form-item>
|
||||
<template v-if="form.code === WALLHAVEN">
|
||||
<t-form-item label="分级">
|
||||
<t-select v-model="form.purity" :options="PURITY_OPTIONS" />
|
||||
</t-form-item>
|
||||
<t-form-item label="分类">
|
||||
<t-input
|
||||
v-model="form.categories"
|
||||
placeholder="general,anime,people 组合,可留空"
|
||||
/>
|
||||
</t-form-item>
|
||||
</template>
|
||||
<t-form-item label="接口地址">
|
||||
<t-input
|
||||
v-model="form.baseUrl"
|
||||
placeholder="留空用官方地址;可填自建反代以绕过 DNS 污染"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="form.remark" :autosize="{ minRows: 2 }" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
@ -13,20 +13,16 @@ export async function node(): Promise<Linter.Config[]> {
|
||||
rules: {
|
||||
'n/handle-callback-err': ['error', '^(err|error)$'],
|
||||
'n/no-deprecated-api': 'error',
|
||||
'n/no-extraneous-import': [
|
||||
'error',
|
||||
{
|
||||
allowModules: [
|
||||
'tsdown',
|
||||
'unplugin-vue',
|
||||
'@vben/vite-config',
|
||||
'vitest',
|
||||
'vite',
|
||||
'@vue/test-utils',
|
||||
'@playwright/test',
|
||||
],
|
||||
},
|
||||
],
|
||||
// ⚠️ 2026-09-15 关闭本规则(性能失控,已实测定位):
|
||||
// 本仓库是 pnpm hoisted 布局 + workspace 包名(@vben/*)+ `<pkg>/package.json` 里有 `imports` 别名,
|
||||
// n/no-extraneous-import 会为每个 import 做深层路径解析,代价近乎无界:
|
||||
// · 单个 10 行的 apps/web-tdesign/src/main.ts(仅 3 个 import)跑 300 秒仍未结束,eslint 进程满核空转;
|
||||
// · pre-commit 对 7 个文件执行时,eslint 单进程累计消耗 900+ 秒 CPU、卡死 15 分钟以上,commit 无法完成。
|
||||
// · 二分验证:仅关闭本规则后,同一文件 lint 由「>60s 不结束」降到 1.7s。
|
||||
// 未声明依赖的兜底保障:pnpm 严格依赖结构 + CI 的 install/build(缺依赖会直接失败),
|
||||
// 另由 oxlint(typescript 插件)承担其余类型相关静态检查。
|
||||
// 如需恢复,请先升级 eslint-plugin-n 并在本机复测单文件耗时,确认不再是数量级开销。
|
||||
'n/no-extraneous-import': 'off',
|
||||
// 'n/no-unpublished-import': 'off',
|
||||
'n/no-unsupported-features/es-syntax': [
|
||||
'error',
|
||||
|
||||
@ -61,8 +61,14 @@ pre-commit:
|
||||
glob: '*.{vue,css,less,scss}'
|
||||
run: pnpm exec stylelint --fix {staged_files}
|
||||
stage_fixed: true
|
||||
# 类型检查改为「增量」:全量 turbo run typecheck 要跑 38 个任务(含 vue-tsc)。
|
||||
# --filter=...[HEAD] 只校验「自 HEAD 起有改动的包 + 依赖它们的包」,实测 38 → 10 个任务。
|
||||
# --concurrency=4 限制并发,避免 dev server 在跑时瞬时打满内存/CPU。
|
||||
# 注意:2026-09-15「commit 卡死」的真凶不在这里,而是 eslint 规则 n/no-extraneous-import
|
||||
# (已在 eslint-config 中关闭,详见 agents.md 第 8 节现象 C),本项仅为缩时优化。
|
||||
# 应急跳过(例如纯文档/配置提交):LEFTHOOK=0 git commit ...(该次提交跳过全部钩子)。
|
||||
- name: checkType
|
||||
run: pnpm check:type
|
||||
run: pnpm exec turbo run typecheck --filter=...[HEAD] --concurrency=4
|
||||
|
||||
post-merge:
|
||||
commands:
|
||||
|
||||
@ -47,7 +47,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
|
||||
"companySiteLink": "https://www.vben.pro",
|
||||
"date": "2024",
|
||||
"enable": true,
|
||||
"icp": "闽ICP备19024351号",
|
||||
"icp": "黔ICP备2026003933号",
|
||||
"icpLink": "https://beian.miit.gov.cn/",
|
||||
"settingShow": true,
|
||||
},
|
||||
|
||||
@ -47,7 +47,7 @@ const defaultPreferences: Preferences = {
|
||||
companySiteLink: 'https://www.vben.pro',
|
||||
date: '2024',
|
||||
enable: true,
|
||||
icp: '闽ICP备19024351号',
|
||||
icp: '黔ICP备2026003933号',
|
||||
icpLink: 'https://beian.miit.gov.cn/',
|
||||
settingShow: true,
|
||||
},
|
||||
|
||||
@ -24,7 +24,7 @@ function definePreferencesExtension<
|
||||
|
||||
/** 应用级 ICP 备案配置,供各 app 的 preferences 覆盖使用 */
|
||||
const appCopyrightPreferences = {
|
||||
icp: '闽ICP备19024351号',
|
||||
icp: '黔ICP备2026003933号',
|
||||
icpLink: 'https://beian.miit.gov.cn/',
|
||||
} satisfies DeepPartial<Preferences>['copyright'];
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user