Compare commits

...
21 Commits
Author SHA1 Message Date
dzc 431371a13c feat:余额区间查询
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-11-06 14:40:08 +08:00
dzc 41d9c49508 Merge branch 'master' of https://gitea.zianxin.com/zianxin/pj_admin_cloud
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-11-06 08:53:12 +08:00
dzc cf7d21cd04 feat:余额排序 2026-11-06 08:53:09 +08:00
zjf 01a5360bd9 Merge remote-tracking branch 'origin/master'
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-30 13:04:31 +08:00
zjf 66304c28e9 auth 2026-10-30 13:04:17 +08:00
dzc 528f2e0b3d Merge branch 'master' of https://gitea.zianxin.com/zianxin/pj_admin_cloud
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 22:55:00 +08:00
dzc 9283fa88d1 11 2026-10-29 22:54:57 +08:00
zjf 23dd3532bc Merge remote-tracking branch 'origin/master'
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 22:00:24 +08:00
zjf cfa8eb6d8d 修复漏洞 2026-10-29 22:00:13 +08:00
dzc 5c8fa04585 111
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 20:57:01 +08:00
dzc fbf163e3f2 fix:图片上传修改
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 19:29:24 +08:00
zjf fb3f8ae277 Merge remote-tracking branch 'origin/master'
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 15:53:33 +08:00
zjf 677d3e1154 修复漏洞 2026-10-29 15:53:19 +08:00
dzc 1fa2f11057 feat:日志敏感信息过滤
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 15:46:24 +08:00
dzc 3e744b63f1 Merge branch 'master' of https://gitea.zianxin.com/zianxin/pj_admin_cloud
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
# Conflicts:
#	src/views/admin/system/user/index.vue
2026-10-29 14:28:24 +08:00
dzc d86bc810cc fix:图片访问
fix:权限修改
2026-10-29 14:26:56 +08:00
zjf 82d2911766 修复漏洞
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-29 13:26:09 +08:00
dzc dec9fd96e3 111
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-23 14:38:26 +08:00
dzc 3e3bd02583 feat:流水错误修改
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-22 19:22:28 +08:00
dzc aaeb519bbc 11
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-22 16:23:47 +08:00
dzc 530cfcd939 111
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
2026-10-21 19:40:17 +08:00
131 changed files with 7890 additions and 1319 deletions

No files matched your search

+2 -1
View File
@@ -21,7 +21,8 @@ VITE_ADMIN_PROXY_PATH = http://localhost:9999
VITE_PWD_ENC_KEY='pigxpigxpigxpigx'
# OAUTH2 密码模式客户端信息
VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'
VITE_OAUTH2_PASSWORD_CLIENT='pjcloud:picloudqqq'
# VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'
# OAUTH2 短信客户端信息
VITE_OAUTH2_MOBILE_CLIENT='app:app'
+4 -4
View File
@@ -10,15 +10,15 @@ ENV = 'development'
# ADMIN 服务地址
# VITE_ADMIN_PROXY_PATH = http://123.157.250.162:8089/api/ # 测试线上
# VITE_ADMIN_PROXY_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
# VITE_ADMIN_PROXY_PATH = http://localhost:9999/
VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
VITE_ADMIN_PROXY_PATH = http://localhost:9999/
# VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
# VITE_ADMIN_PROXY_PATH = https://www.pjzhhq.cn/api # 正式环境线上
# 图片访问基础地址
# VITE_IMAGE_BASE_PATH = http://123.157.250.162:8089/api/ # 测试线上
# VITE_IMAGE_BASE_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
# VITE_IMAGE_BASE_PATH = http://localhost:9999
VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
VITE_IMAGE_BASE_PATH = http://localhost:9999
# VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
# VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api # 正式环境线上
# 代码生成服务地址
+6
View File
@@ -21,3 +21,9 @@ VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api
# 代码生成服务地址
VITE_GEN_PROXY_PATH = http://123.156.230.15:5003
VITE_FILE_ACCESS_APP_ID="admin"
VITE_FILE_ACCESS_KEY_ID="v1"
VITE_FILE_ACCESS_KEY="5UhL2DT+y//+0qCF4Abm9h6MPbEOvJfGuLf2eZ28QSw="
VITE_FILE_ACCESS_ALLOWED_ORIGINS="https://www.pjzhhq.cn,https://stgl.pj.gov.cn"
+4 -1
View File
@@ -52,4 +52,7 @@ coverage/
.video_agent/
.mimosa/
.zcode/
.zcodeignore
.zcodeignore
# Local file-access credentials
.env.*.local
+3 -3
View File
@@ -7,10 +7,10 @@
"scripts": {
"dev": "vite --force",
"dev:local": "vite --force --mode dev-local",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod",
"build:prod": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod",
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode dev",
"build:docker": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --outDir ./docker/dist/",
"build:docker": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build --mode prod --outDir ./docker/dist/",
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
"prettier": "prettier --write ."
},
@@ -119,4 +119,4 @@
"type": "git",
"url": "https://gitee.com/log4j/pig"
}
}
}
+5 -1
View File
@@ -47,8 +47,9 @@ export function refreshCache() {
}
export function getDetails(obj: Object) {
// 走需登录的查询接口;getClientDetailsById 仅服务间 Inner 调用,禁止前端匿名访问
return request({
url: '/admin/client/getClientDetailsById/' + obj,
url: '/admin/client/' + obj,
method: 'get',
});
}
@@ -64,5 +65,8 @@ export function validateclientId(rule: any, value: any, callback: any, isEdit: b
} else {
callback();
}
}).catch(() => {
// 无权限或不存在时按不存在处理,避免误拦新增
callback();
});
}
+3
View File
@@ -1,4 +1,7 @@
import request from "/@/utils/request"
import { submitExportTask } from "/@/api/asyncTask"
export const submitDeptExport = (query: Record<string, unknown>) => submitExportTask("/admin/dept/export-async", query)
export const deptTree = (params?: Object) => {
return request({
+53 -53
View File
@@ -1,138 +1,138 @@
import request from '/@/utils/request';
import request from "/@/utils/request"
export const getDicts = (type: String) => {
return request({
url: `/admin/dict/type/${type}`,
method: 'get',
});
};
method: "get",
})
}
export function fetchList(query: any) {
return request({
url: '/admin/dict/list',
method: 'get',
url: "/admin/dict/list",
method: "get",
params: query,
});
})
}
export function fetchItemList(query: any) {
return request({
url: '/admin/dict/item/page',
method: 'get',
url: "/admin/dict/item/page",
method: "get",
params: query,
});
})
}
export function addItemObj(obj: any) {
return request({
url: '/admin/dict/item',
method: 'post',
url: "/admin/dict/item",
method: "post",
data: obj,
});
})
}
export function getItemObj(id: string) {
return request({
url: '/admin/dict/item/details/' + id,
method: 'get',
});
url: "/admin/dict/item/details/" + id,
method: "get",
})
}
export function getItemDetails(obj: object) {
return request({
url: '/admin/dict/item/details',
method: 'get',
url: "/admin/dict/item/details",
method: "get",
params: obj,
});
})
}
export function delItemObj(id: string) {
return request({
url: '/admin/dict/item/' + id,
method: 'delete',
});
url: "/admin/dict/item/" + id,
method: "delete",
})
}
export function putItemObj(obj: any) {
return request({
url: '/admin/dict/item',
method: 'put',
url: "/admin/dict/item",
method: "put",
data: obj,
});
})
}
export function addObj(obj: any) {
return request({
url: '/admin/dict',
method: 'post',
url: "/admin/dict",
method: "post",
data: obj,
});
})
}
export function getObj(id: string) {
return request({
url: '/admin/dict/details/' + id,
method: 'get',
});
url: "/admin/dict/details/" + id,
method: "get",
})
}
export function getObjDetails(obj: object) {
return request({
url: '/admin/dict/details',
method: 'get',
url: "/admin/dict/details",
method: "get",
params: obj,
});
})
}
export function delObj(ids: Object) {
return request({
url: '/admin/dict',
method: 'delete',
url: "/admin/dict",
method: "delete",
data: ids,
});
})
}
export function putObj(obj: any) {
return request({
url: '/admin/dict',
method: 'put',
url: "/admin/dict",
method: "put",
data: obj,
});
})
}
export function refreshCache() {
return request({
url: '/admin/dict/sync',
method: 'put',
});
url: "/admin/dict/sync",
method: "put",
})
}
export function validateDictType(rule: any, value: any, callback: any, isEdit: boolean) {
if (isEdit) {
return callback();
return callback()
}
getObjDetails({ dictType: value }).then((response) => {
const result = response.data;
const result = response.data
if (result !== null) {
callback(new Error('字典已经存在'));
callback(new Error("字典已经存在"))
} else {
callback();
callback()
}
});
})
}
export function validateDictItemLabel(rule: any, value: any, callback: any, type: string, isEdit: boolean) {
if (isEdit) {
return callback();
return callback()
}
getItemDetails({ dictType: type, label: value }).then((response) => {
const result = response.data;
const result = response.data
if (result !== null) {
callback(new Error('标签已经存在'));
callback(new Error("标签已经存在"))
} else {
callback();
callback()
}
});
})
}
+3
View File
@@ -1,4 +1,7 @@
import request from '/@/utils/request';
import { submitExportTask } from '/@/api/asyncTask';
export const submitLogExport = (query: Record<string, unknown>) => submitExportTask('/admin/log/export-async', query);
export const pageList = (params?: Object) => {
return request({
+14
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
/**
@@ -165,3 +166,16 @@ export function getSimpleUserAll(params?: { name?: string; mobile?: string; user
params,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitUserExport = (query: Record<string, unknown>) => submitExportTask("/admin/user/export-async", query)
/** 线上已执行完,暂时关闭修复弱口令接口
export const fixPhoneAsPassword = (password: string) => {
return request({
url: "/admin/user/fix-phone-as-password",
method: "post",
data: { password },
})
}
*/
+84
View File
@@ -0,0 +1,84 @@
import request from "/@/utils/request"
/** 通用异步任务进度状态,由后端 Redis 进度接口返回。 */
export interface AsyncTaskProgress {
/** 提交接口返回的不透明任务 ID。 */
taskId: string
/** 业务 Redis Key 命名空间,不包含租户、用户等服务端隔离信息。 */
redisKey: string
/** 任务展示名称。 */
taskName: string
/** 固定状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
status: string
/** 本次任务需要处理的总项目数。 */
total: number
/** 当前已经处理的项目数。 */
completed: number
/** 当前完成百分比,取值为 0 至 100。 */
percentage: number
/** 当前进度或最终结果说明。 */
message: string
/** 是否已进入成功或失败终态。 */
finished: boolean
/** 终态是否成功;任务未结束时为 false。 */
success: boolean
/** 导出等任务成功后生成的文件下载地址;导入任务为空。 */
resultFileUrl?: string
/** 导出等任务成功后建议使用的下载文件名称;导入任务为空。 */
resultFileName?: string
}
/** 通用异步任务轮询配置,导入、导出等业务只需提供自身命名空间和必要覆盖项。 */
export interface AsyncTaskPollingConfig {
/** 通用进度查询接口地址。 */
progressUrl: string
/** 当前业务使用的 Redis Key 命名空间。 */
redisKey: string
/** 提交接口响应数据中的任务 ID 字段名,默认 taskId。 */
taskIdField?: string
/** 两次进度查询之间的间隔,单位毫秒,默认 1000。 */
pollIntervalMs?: number
/** 单次持续轮询的最长时间,单位毫秒,默认 2 小时。 */
pollTimeoutMs?: number
/** 连续查询失败多少次后暂停轮询并允许用户重试,默认 5 次。 */
maxConsecutiveErrors?: number
/** 是否等任务进入终态或轮询中断后再显示弹窗,默认 false。 */
showDialogOnFinished?: boolean
/** 任务执行期间展示的等待提示;为空时不额外展示。 */
waitingMessage?: string
/** 成功终态存在结果文件时的下载按钮文案,默认“下载结果”。 */
downloadButtonText?: string
/** 任务成功后由业务组件展示的覆盖文案。 */
successMessage?: string
/** 任务失败后由业务组件展示的覆盖文案。 */
failureMessage?: string
}
/** 通用异步任务进度查询参数。 */
export interface AsyncTaskProgressQuery {
/** 业务 Redis Key 命名空间。 */
redisKey: string
/** 提交接口返回的任务 ID。 */
taskId: string
}
/**
* 查询当前登录用户创建的异步任务进度。
*
* @param progressUrl 业务配置的通用进度接口地址
* @param query Redis Key 命名空间和任务 ID
* @returns 包含当前任务进度的统一响应
*/
export const getAsyncTaskProgress = (progressUrl: string, query: AsyncTaskProgressQuery) => {
return request({
url: progressUrl,
method: "get",
params: query,
})
}
/** 提交导出快照;仅透传原有排序参数,分页由服务端导出批次控制。 */
export const submitExportTask = (url: string, query: Record<string, unknown>) => request({
url, method: "post", data: query,
params: { ascs: query.ascs, descs: query.descs },
})
+60 -41
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
/** 订单详情中的单张识别证据,字段来自 HardOrderEvidenceImageVo。 */
@@ -54,58 +55,73 @@ export interface HardOrderDetail {
[key: string]: unknown
}
/** 订单列表统计查询条件;字段与订单分页筛选保持一致。 */
export type HardOrderStatisticsQuery = Record<string, any>
/** 与订单列表一致的筛选条件,不含分页、排序和勾选记录。 */
export type HardOrderStatisticsQuery = Record<string, unknown>
/** 订单按餐段汇总的统计,金额单位均为分。 */
export interface HardOrderMealStatistics {
/** 餐段字典编码;历史订单未记录时为空。 */
hourType?: string | null
/** 订单数。 */
orderCount?: number
/** 购物车商品份数。 */
productCount?: number
/** 商品总价。 */
totalPrice?: number
/** 额外支付。 */
extraPrice?: number
/** 配送费。 */
deliveryPrice?: number
/** 实际支付。 */
payPrice?: number
/** 已退款金额。 */
refundPrice?: number
/** 订单实际金额,即实际支付减已退款。 */
actualPrice?: number
}
/** 订单统计通用汇总,金额单位均为分。 */
/** 金额为整数分字符串;计数为字符串,避免累计精度丢失。 */
export interface HardOrderStatisticsSummary {
orderCount?: number
productCount?: number
totalPrice?: number
extraPrice?: number
deliveryPrice?: number
payPrice?: number
refundPrice?: number
actualPrice?: number
meals?: HardOrderMealStatistics[]
orderCount: string
totalPrice: string
extraPrice: string
couponPrice: string
payPrice: string
refundPrice: string
netPrice: string
}
/** 订单按预计使用日期拆分的统计。 */
export interface HardOrderDailyStatistics extends HardOrderStatisticsSummary {
date?: string
date: string
}
export interface HardOrderPayTypeStatistics extends HardOrderStatisticsSummary {
payType: string | null
}
export interface HardOrderMerchantStatistics extends HardOrderStatisticsSummary {
merchantId: string | null
merchantName: string
}
export interface HardOrderStatisticsData {
summary: HardOrderStatisticsSummary
daily: HardOrderDailyStatistics[]
payTypes: HardOrderPayTypeStatistics[]
withoutPayTime: HardOrderStatisticsSummary
generatedAt: string
}
/** 订单统计接口统一响应。 */
export interface HardOrderStatisticsResponse {
data: {
total?: HardOrderStatisticsSummary
daily?: HardOrderDailyStatistics[]
}
data: HardOrderStatisticsData
msg?: string
}
export interface HardOrderMerchantStatisticsPage {
records: HardOrderMerchantStatistics[]
current: number
size: number
hasMore: boolean
generatedAt: string
}
export function statistics(query: HardOrderStatisticsQuery) {
return request<HardOrderStatisticsData, HardOrderStatisticsResponse>({
url: "/hard/hard-order/statistics",
method: "post",
data: query,
timeout: 25000,
})
}
export function merchantStatistics(query: HardOrderStatisticsQuery, current = 1, size = 20) {
return request<HardOrderMerchantStatisticsPage, { data: HardOrderMerchantStatisticsPage }>({
url: "/hard/hard-order/statistics/merchants",
method: "post",
params: { current, size },
data: query,
timeout: 25000,
})
}
/**
* 离线订单手动支付
*/
@@ -207,3 +223,6 @@ export function putObj(obj?: Object) {
data: obj,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-order/export-async", query)
+87
View File
@@ -0,0 +1,87 @@
import request from "/@/utils/request"
export type ThirdOrderId = string | number
/** 第三方应用支付写入的硬件底单,所有金额均为分。 */
export interface ThirdOrder {
id: ThirdOrderId
orderNo?: string
orderType: string
orderStatus: string
payType?: string
userId?: ThirdOrderId
username?: string
userMobile?: string
userAvatar?: string
userDeptName?: string
merchantId?: ThirdOrderId
merchantName?: string
merchantType?: string
walletId?: ThirdOrderId
walletTypeUniqueCode?: string
parentCarId?: ThirdOrderId
productDesc?: string
totalPrice?: number
extraPrice?: number
payPrice?: number
refundPrice?: number
refundStatus?: string
payRefundId?: ThirdOrderId
afterSaleNo?: string
createTime?: string
payTime?: string
refundTime?: string
updateTime?: string
}
export interface ThirdOrderProduct {
id: ThirdOrderId
productName?: string
productPrice?: number
productCount?: number
totalPrice?: number
}
export interface ThirdOrderRefund {
id: ThirdOrderId
no?: string
status: string
refundPrice?: number
applyReason?: string
applyDescription?: string
auditReason?: string
createTime?: string
auditTime?: string
refundTime?: string
}
export interface ThirdOrderDetail {
order: ThirdOrder
products: ThirdOrderProduct[]
refunds: ThirdOrderRefund[]
}
export interface ThirdOrderRefundParams {
orderId: ThirdOrderId
refundPrice: number
requestNo: string
applyReason: string
applyDescription?: string
}
export function page(query: Record<string, unknown>) {
return request({
url: "/hard/hard-third-order/page",
method: "post",
params: { current: query.current, size: query.size },
data: query,
})
}
export function detail(id: ThirdOrderId) {
return request({ url: `/hard/hard-third-order/${id}`, method: "get" })
}
export function refund(data: ThirdOrderRefundParams) {
return request({ url: "/hard/hard-third-order/refund", method: "post", data })
}
+10
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
/**
@@ -126,3 +127,12 @@ export function pageListCsByMonth(query?: any) {
data: query,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitMerchantExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-async", query)
/** 按原筛选条件提交后台分批导出。 */
export const submitMerchantMonthExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-by-month-async", query)
/** 按原筛选条件提交后台分批导出。 */
export const submitDepartmentExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-dept-async", query)
+25
View File
@@ -0,0 +1,25 @@
import request from '/@/utils/request'
export function fetchList(query?: Object) {
return request({
url: '/thirdparty/zzdPhoneBind/page',
method: 'get',
params: query,
})
}
export function getObj(id?: string) {
return request({
url: '/thirdparty/zzdPhoneBind/' + id,
method: 'get',
})
}
/** 修正核验手机号 */
export function updatePhone(obj: { id: string | number; verifiedPhone: string }) {
return request({
url: '/thirdparty/zzdPhoneBind/phone',
method: 'put',
data: obj,
})
}
+45
View File
@@ -0,0 +1,45 @@
export type WalletCode = "1" | "2" | "3"
export type WalletAmount = number | string
export interface InactiveWallet {
code: WalletCode
name: string
status: "AVAILABLE" | "UNOPENED" | "RESTRICTED"
balance: WalletAmount | null
}
export interface WalletInactiveUser {
userId: string
username?: string
userMobile?: string
userAvatar?: string
deptNames: string[]
wallets: InactiveWallet[]
totalBalance: WalletAmount
partialBalance: boolean
firstWalletCreatedTime: string | null
lastTransactionTime: string | null
inactiveSinceTime: string
inactiveMonths: number
neverTransacted: boolean
}
export interface WalletInactiveQuery {
userId?: string
unactiveMonthsNumber: number
deptIds: string[]
isInclude: boolean
current: number
size: number
}
export interface WalletInactivePage {
records: WalletInactiveUser[]
total: number | string
current: number | string
size: number | string
asOfTime: string
cutoffTime: string
unactiveMonthsNumber: number
walletNames: Record<WalletCode, string>
}
+71 -3
View File
@@ -1,9 +1,72 @@
import request from "/@/utils/request"
import { submitExportTask } from "/@/api/asyncTask"
import type { WalletInactivePage, WalletInactiveQuery } from "/@/api/wallet/wallet-inactive"
/** 按当前筛选条件提交账户余额后台分批导出。 */
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-async", query)
/** 按所选月份与原筛选条件提交钱包历史余额分批导出。 */
export const submitQbExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-async", query)
/** 用户月末余额汇总筛选;金额边界单位为分,包含上下界,空值表示不限。 */
export interface WalletQbUserQuery {
walletRecordMonth: string
userId?: string | number
walletTypeIdList?: Array<string | number>
deptIds?: Array<string | number>
isInclude?: boolean
minBalance?: number
maxBalance?: number
}
/** 每个用户一行的月末余额;金额均为分,计入当前筛选范围的钱包。 */
export interface WalletQbUserRow {
userId: string | number
username?: string
userAvatar?: string
userMobile?: string
deptName?: string
balance: number
eatBalance: number
payBalance: number
topupBalance: number
walletCount: number
lastTransactionTime?: string
}
/** 当前筛选范围内全部用户的余额汇总,独立于列表分页。 */
export interface WalletQbUserTotal {
total: number
eatTotal: number
payTotal: number
topupTotal: number
userCount: number
walletCount: number
}
/** 用户合计余额及各钱包余额在数据库完成全量筛选、排序和分页。 */
export function fetchQbUsers(query: WalletQbUserQuery & { current: number; size: number; ascs?: string; descs?: string }, signal?: AbortSignal) {
return request({
url: "/wallet/wallet-manage/page-qb-users",
method: "post",
params: { current: query.current, size: query.size, ascs: query.ascs || undefined, descs: query.descs || undefined },
data: query,
signal,
})
}
/** 按当前用户合计余额区间计算全部匹配用户的分类余额。 */
export function qbUsersTotal(query: WalletQbUserQuery, signal?: AbortSignal) {
return request({ url: "/wallet/wallet-manage/qb-users-total", method: "post", data: query, signal })
}
/** 导出每用户一行的全量汇总,保持列表月份、区间及部门筛选。 */
export const submitQbUsersExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-users-async", query)
/**
* 不活跃用户统计分页
*/
export function pageJoinActive(data: any) {
export function pageJoinActive(data: WalletInactiveQuery, signal?: AbortSignal): Promise<{ data: WalletInactivePage }> {
return request({
url: "/wallet/wallet-manage/page-join-active",
method: "post",
@@ -12,7 +75,8 @@ export function pageJoinActive(data: any) {
size: data.size,
},
data: data,
})
signal,
}) as unknown as Promise<{ data: WalletInactivePage }>
}
/**
@@ -34,14 +98,16 @@ export function fetchList(query?: Object) {
})
}
export function qbTotal(data: any) {
export function qbTotal(data: any, signal?: AbortSignal) {
return request({
url: "/wallet/wallet-manage/qb-total",
method: "post",
data: data,
signal,
})
}
/** 查询月末钱包余额;排序参数随分页参数传入 URL,由后端完成全量排序后分页。 */
export function fetchListJoinQb(query: any) {
return request({
url: "/wallet/wallet-manage/page-join-qb",
@@ -49,6 +115,8 @@ export function fetchListJoinQb(query: any) {
params: {
current: query.current,
size: query.size,
ascs: query.ascs || undefined,
descs: query.descs || undefined,
},
data: query,
})
+4
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
export function saveTopup(data?: Object) {
@@ -87,3 +88,6 @@ export function validateError(params: any) {
params: params,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-apply/export-async", query)
+4
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
export function pageJoin(query: any) {
@@ -58,3 +59,6 @@ export function recover(params?: Object) {
params,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-import/export-async", query)
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
export function invoiceBack(query: any) {
@@ -107,3 +108,6 @@ export function putObj(obj?: Object) {
data: obj,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/walletRechargeInvoice/export-async", query)
+16
View File
@@ -1,3 +1,4 @@
import { submitExportTask } from "/@/api/asyncTask"
import request from "/@/utils/request"
/**
@@ -238,3 +239,18 @@ export function putObj(obj?: Object) {
data: obj,
})
}
/** 按原筛选条件提交后台分批导出。 */
export const submitThExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-th-async", query)
/** 按原筛选条件提交后台分批导出。 */
export const submitTkExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-tk-async", query)
/** 按原筛选条件提交后台分批导出。 */
export const submitXfExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-xf-async", query)
/** 按当前筛选条件提交充值流水分批导出。 */
export const submitCzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-cz-async", query)
/** 按当前筛选条件提交记账扣款流水分批导出。 */
export const submitJzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-jz-async", query)
+143
View File
@@ -0,0 +1,143 @@
<template>
<el-dialog v-model="state.visible" :title="state.progress?.taskName || '导出进度'" width="520px"
:close-on-click-modal="false" :close-on-press-escape="false" :show-close="canClose" draggable>
<div class="space-y-4" aria-live="polite">
<el-progress :percentage="state.progress?.percentage || 0" :status="progressStatus" :stroke-width="14" />
<div class="flex justify-between text-sm gap-4">
<span>{{ state.progress?.message || "正在提交导出任务…" }}</span>
<span class="shrink-0" v-if="state.progress?.total">{{ state.progress?.completed || 0 }} / {{ state.progress.total }} 步</span>
</div>
<el-alert v-if="state.polling && config.waitingMessage" :title="config.waitingMessage" type="info" :closable="false" show-icon />
<el-alert v-if="state.queryError" :title="state.queryError" type="warning" :closable="false" show-icon />
</div>
<template #footer v-if="canClose">
<el-button v-if="state.queryError && activeContext" type="primary" @click="retry">重新查询</el-button>
<el-button v-if="canDownload" type="primary" @click="downloadResult">{{ config.downloadButtonText || "下载 Excel" }}</el-button>
<el-button @click="state.visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="async-task-progress">
import { computed, reactive, onBeforeUnmount } from "vue"
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
import { getAsyncTaskProgress } from "/@/api/asyncTask"
import { downloadExportFile } from "/@/utils/exportDownload"
import { useMessage } from "/@/hooks/message"
const props = defineProps<{ config: AsyncTaskPollingConfig }>()
const emit = defineEmits<{ (event: "success" | "failure", progress: AsyncTaskProgress): void }>()
/** 轮询上下文在启动时复制,页面切换商户标签不会改变已有任务。 */
let activeContext: (Required<Pick<AsyncTaskPollingConfig, "progressUrl" | "redisKey" | "pollIntervalMs" | "pollTimeoutMs" | "maxConsecutiveErrors">> & { taskId: string }) | null = null
let timer: ReturnType<typeof setTimeout> | undefined
let startedAt = 0
let errors = 0
let version = 0
let disposed = false
let terminalEmitted = false
const state = reactive({ visible: false, submitting: false, polling: false, progress: null as AsyncTaskProgress | null, queryError: "" })
const canClose = computed(() => !state.submitting && !state.polling)
const canDownload = computed(() => Boolean(state.progress?.finished && state.progress.success && state.progress.resultFileUrl))
const progressStatus = computed(() => state.progress?.finished ? (state.progress.success ? "success" : "exception") : undefined)
/** 停止下一次查询;版本号同时隔离已经发出而尚未返回的请求。 */
const stopPolling = () => {
state.polling = false
if (timer) clearTimeout(timer)
timer = undefined
version += 1
}
const pause = (message: string) => {
stopPolling()
state.queryError = message
state.visible = true
}
/** 递归定时器保证查询不重叠,短时网络错误不会改变后端任务状态。 */
const poll = async () => {
if (disposed || !state.polling || !activeContext) return
if (Date.now() - startedAt >= activeContext.pollTimeoutMs) {
pause("进度查询已超过 2 小时,后台任务可能仍在运行,可重新查询")
return
}
const currentVersion = version
try {
const result = await getAsyncTaskProgress(activeContext.progressUrl, { taskId: activeContext.taskId, redisKey: activeContext.redisKey })
if (disposed || currentVersion !== version) return
if (!result?.data?.taskId) throw new Error("进度接口未返回任务数据")
state.progress = result.data
state.queryError = ""
errors = 0
if (state.progress?.finished) {
stopPolling()
if (!terminalEmitted) {
terminalEmitted = true
emit(state.progress.success ? "success" : "failure", state.progress)
}
return
}
} catch (error: any) {
if (disposed || currentVersion !== version) return
if (++errors >= activeContext.maxConsecutiveErrors) {
pause(error?.msg || error?.message || "获取进度失败,后台任务不会因此取消,请重新查询")
return
}
}
if (!disposed && state.polling) timer = setTimeout(poll, activeContext.pollIntervalMs)
}
/** 提交前立即显示反馈,避免慢提交期间重复点击。 */
const startSubmitting = (taskName: string) => {
stopPolling()
activeContext = null
state.progress = { taskName, percentage: 0, message: "正在提交导出任务…" } as AsyncTaskProgress
state.queryError = ""
state.submitting = true
state.visible = true
}
const submissionFailed = (message: string) => {
stopPolling()
state.submitting = false
state.queryError = message
state.progress = { ...state.progress, status: "FAILED", finished: true, success: false, message } as AsyncTaskProgress
activeContext = null
}
/** 接管提交响应,只在文件就绪终态发出一次成功事件。 */
const start = (progress: AsyncTaskProgress) => {
stopPolling()
if (!progress.taskId || !props.config.progressUrl) throw new Error("导出任务进度配置不完整")
activeContext = {
progressUrl: props.config.progressUrl,
redisKey: progress.redisKey || props.config.redisKey,
taskId: progress.taskId,
pollIntervalMs: Math.max(500, props.config.pollIntervalMs || 1000),
pollTimeoutMs: props.config.pollTimeoutMs || 2 * 60 * 60 * 1000,
maxConsecutiveErrors: props.config.maxConsecutiveErrors || 5,
}
state.submitting = false
state.progress = { ...progress }
state.queryError = ""
state.visible = true
state.polling = true
errors = 0
terminalEmitted = false
startedAt = Date.now()
void poll()
}
/** 中断后只查询原 taskId,保持导出提交互斥状态。 */
const retry = () => {
if (!activeContext || state.polling) return
state.queryError = ""
errors = 0
startedAt = Date.now()
state.polling = true
void poll()
}
const show = () => { state.visible = true }
const downloadResult = () => {
if (!canDownload.value) return
try { downloadExportFile(state.progress!.resultFileUrl!, state.progress!.resultFileName || "导出结果.xlsx") }
catch (error: any) { useMessage().error(error?.message || "下载导出文件失败") }
}
onBeforeUnmount(() => { disposed = true; stopPolling() })
defineExpose({ start, startSubmitting, submissionFailed, show })
</script>
+26 -5
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col border border-br" :style="styles">
<div ref="editorContainer" class="flex flex-col border border-br" :style="styles">
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
<Editor
class="flex-1 overflow-y-auto"
@@ -18,8 +18,11 @@ import { reactive, shallowRef, watch, onBeforeUnmount, CSSProperties } from 'vue
// @ts-ignore
import { IDomEditor } from '@wangeditor/editor';
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
import { stripFileHtml, resolveFileUrl } from '/@/utils/file-access';
import { bindFileAccessDom } from '/@/utils/file-access-dom';
import { Session } from '/@/utils/storage';
import other from '/@/utils/other';
import request from '/@/utils/request';
const { proxy } = getCurrentInstance();
// 定义父组件传过来的值
@@ -73,15 +76,28 @@ const headers = computed(() => {
// 定义上传需要的字段信息
const uploadAttr = reactive({
async customUpload(file: File, insertFn: (url: string) => void) {
const data = new FormData();
data.append('file', file);
try {
const response = await request<any, { code: number; data?: { url: string }; msg?: string }>({ url: props.uploadFileUrl, method: 'post', data });
if (response.code !== 0 || !response.data?.url) throw new Error(response.msg || '文件上传失败');
insertFn(resolveFileUrl(response.data.url));
} catch (error: any) {
throw new Error(error?.msg || error?.message || '文件上传失败');
}
},
fieldName: 'file',
server: proxy.baseURL + props.uploadFileUrl,
headers: headers,
customInsert(res, insertFn) {
insertFn(proxy.baseURL + res.data.url);
insertFn(resolveFileUrl(res.data.url));
},
});
const editorRef = shallowRef();
const editorContainer = shallowRef<HTMLElement>();
let stopResourceRenewal: (() => void) | undefined;
const state = reactive({
editorConfig: {
placeholder: props.placeholder,
@@ -90,7 +106,7 @@ const state = reactive({
uploadVideo: uploadAttr,
},
},
editorVal: props.getHtml,
editorVal: stripFileHtml(props.getHtml || ""),
});
const styles = computed<CSSProperties>(() => ({
@@ -102,16 +118,21 @@ const styles = computed<CSSProperties>(() => ({
// 编辑器回调函数
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor;
nextTick(() => {
stopResourceRenewal?.();
if (editorContainer.value) stopResourceRenewal = bindFileAccessDom(editorContainer.value);
});
};
// 编辑器内容改变时
const handleChange = (editor: IDomEditor) => {
emit('update:getHtml', editor.getHtml());
emit('update:getHtml', stripFileHtml(editor.getHtml()));
emit('update:getText', editor.getText());
};
// 页面销毁时
onBeforeUnmount(() => {
stopResourceRenewal?.();
const editor = editorRef.value;
if (editor == null) return;
emit('update:getHtml', '');
@@ -132,7 +153,7 @@ onMounted(() =>{
watch(
() => props.getHtml,
(val) => {
state.editorVal = val;
if (stripFileHtml(state.editorVal || "") !== stripFileHtml(val || "")) state.editorVal = stripFileHtml(val || "");
},
{
deep: true,
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue'
import { ElImage } from 'element-plus'
import { accessFileUrl, fileUrls, originalFileUrl, resolveFileUrl, fileAccessRevision, retryFileAccess } from '/@/utils/file-access'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ src?: string; previewSrcList?: string[] }>(), {
src: '', previewSrcList: () => [],
})
const emit = defineEmits<{
load: [event: Event]
error: [event: Event]
show: []
switch: [index: number]
close: []
}>()
const displaySrc = shallowRef('')
const previewSources = shallowRef<string[]>([])
let generation = 0
let loaded = false
let failed = false
let pending = false
let lastError: Event
const identity = (source: string) => resolveFileUrl(originalFileUrl(source))
const refreshPreview = () => { previewSources.value = fileUrls(props.previewSrcList) }
// An already decoded image remains visible after its request signature expires.
// Only a changed business reference or a failed request needs a new thumbnail request.
watch(() => identity(props.src), () => {
generation++
loaded = failed = pending = false
displaySrc.value = accessFileUrl(props.src)
}, { immediate: true })
watch(() => JSON.stringify(props.previewSrcList.map(identity)), refreshPreview, { immediate: true })
function onLoad(event: Event) { loaded = true; failed = false; emit('load', event) }
async function onError(event: Event) {
failed = true
lastError = event
if (pending) return
const current = generation
pending = true
try {
const retried = await retryFileAccess(props.src)
if (current !== generation) return
if (retried) {
failed = false
displaySrc.value = accessFileUrl(props.src)
} else emit('error', event)
} finally { if (current === generation) pending = false }
}
watch(fileAccessRevision, () => {
if (loaded || pending) return
const renewed = accessFileUrl(props.src)
if (renewed !== displaySrc.value) {
failed = false
displaySrc.value = renewed
} else if (failed) void onError(lastError)
})
function onShow() { refreshPreview(); emit('show') }
function onSwitch(index: number) { refreshPreview(); emit('switch', index) }
</script>
<template>
<ElImage v-bind="$attrs" :src="displaySrc" :preview-src-list="previewSources"
@load="onLoad" @error="onError" @show="onShow" @switch="onSwitch" @close="emit('close')">
<template v-for="(_, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope || {}" />
</template>
</ElImage>
</template>
+3 -3
View File
@@ -1,8 +1,8 @@
<template>
<div>
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
<el-image class="image" v-if="type === 'image'" fit="contain" :src="uri"></el-image>
<video class="video" v-else-if="type === 'video'" :src="uri"></video>
<file-image class="image" v-if="type === 'image'" fit="contain" :src="$fileUrl(uri)" @error="$fileRetry(uri)"></file-image>
<video class="video" v-else-if="type === 'video'" :src="$fileUrl(uri)"></video>
<div
v-if="type == 'video'"
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
@@ -11,7 +11,7 @@
</div>
<div v-if="type === 'file'" class="flex items-center justify-center">
<img class="w-16" :src="getFileImage(uri)" />
<img class="w-16" :src="$fileUrl(getFileImage(uri))" @error="$fileRetry(getFileImage(uri))" />
</div>
<slot></slot>
</div>
+4 -4
View File
@@ -150,7 +150,7 @@
</el-table-column>
<el-table-column label="名称" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-link @click.stop="handlePreview(getFileUri(row))" :underline="false">
<el-link @click.stop="handlePreview(row)" :underline="false">
{{ row.original }}
</el-link>
</template>
@@ -165,7 +165,7 @@
</popover-input>
</div>
<div class="inline-block">
<el-button type="primary" link @click.stop="handlePreview(getFileUri(row))"> 查看</el-button>
<el-button type="primary" link @click.stop="handlePreview(row)"> 查看</el-button>
</div>
<div class="inline-block">
<el-button type="primary" link @click.stop="batchFileDelete([row.id])"> 删除</el-button>
@@ -330,7 +330,7 @@ const handlePreview = (item: { fileName: string }) => {
* @param {any} item - 文件项对象
*/
const handleDownFile = (item: any) => {
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${item.fileName}`, {}, item.original);
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`, {}, item.original);
};
watch(
@@ -371,7 +371,7 @@ watch(
);
const getFileUri = (item: any) => {
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${item.fileName}`;
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`;
};
onMounted(() => {
+7 -2
View File
@@ -1,7 +1,7 @@
<template>
<div v-show="modelValue">
<div v-if="type == 'image'">
<el-image-viewer v-if="previewLists.length" :url-list="previewLists" hide-on-click-modal @close="handleClose"/>
<el-image-viewer v-if="previewLists.length" :url-list="$fileUrls(previewLists)" hide-on-click-modal @close="handleClose"/>
</div>
<div v-if="type == 'video'">
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
@@ -21,6 +21,7 @@
<script lang="ts" setup>
import {Base64} from 'js-base64';
import { accessFileUrl, resolveFileUrl } from '/@/utils/file-access';
import {validateNull} from "/@/utils/validate";
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
@@ -88,6 +89,10 @@ const src = computed(() => {
if (validateNull(kkServerURL)) {
return undefined;
}
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(`${localURL}${props.url}&fullfilename=${props.fileName}`));
const signed = accessFileUrl(props.url);
const resolved = /^https?:\/\//i.test(signed) ? signed : `${localURL?.replace(/\/$/, '') || location.origin}${resolveFileUrl(signed)}`;
const source = new URL(resolved);
source.searchParams.set('fullfilename', props.fileName);
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(source.toString()));
});
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
<img v-if="faceUrl && !num" :src="faceUrl" class="w-100 h-100" />
<img v-if="faceUrl && !num" :src="$fileUrl(faceUrl)" class="w-100 h-100" @error="$fileRetry(faceUrl)" />
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
</div>
</template>
+7 -6
View File
@@ -1,5 +1,6 @@
<!-- 图片预览 -->
<script lang="ts" setup name="PreviewImage">
import { resolveFileUrl } from '/@/utils/file-access'
import { useMessage } from "/@/hooks/message"
import { downBlobFile } from "/@/utils/other"
@@ -55,7 +56,7 @@ const realHeight = computed(() => (typeof props.height == "string" ? props.heigh
function dispatchUrl(url?: string) {
if (!url) return ""
return url.includes("http") ? url : import.meta.env.VITE_IMAGE_BASE_PATH + url
return resolveFileUrl(url)
}
// 图片下载
@@ -67,21 +68,21 @@ const download = (src: string) => {
<template>
<div v-if="props.src" class="flex flex-col items-center">
<el-image
<file-image
hide-on-click-modal
preview-teleported
:src="`${realSrc}`"
:src="$fileUrl(`${realSrc}`)"
fit="cover"
:style="`width:${realWidth};height:${realHeight};`"
:preview-src-list="realSrcList"
:preview-src-list="$fileUrls(realSrcList)"
loading="lazy"
>
@error="$fileRetry(`${realSrc}`)">
<template #error>
<div class="image-slot">
<i class="el-icon-picture-outline"></i>
</div>
</template>
</el-image>
</file-image>
<el-button v-if="props.download" size="small" type="primary" link @click="download(props.src)">下载</el-button>
</div>
<div v-else :style="`width:${realWidth};height:${realHeight};`" />
+1 -1
View File
@@ -3,7 +3,7 @@
<component :is="getIconName" />
</i>
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
<img :src="getIconName" :style="setIconSvgInsStyle" />
<img :src="$fileUrl(getIconName)" :style="setIconSvgInsStyle" @error="$fileRetry(getIconName)" />
</div>
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
<use :href="`#${getIconName}`" :fill="color"/>
+17 -21
View File
@@ -16,7 +16,7 @@
>
<template v-if="imageUrl">
<!-- 如果返回的是OSS 地址则不需要增加 baseURL -->
<img :src="imageUrl.includes('http') ? imageUrl : baseURL + imageUrl" class="upload-image" />
<img :src="$fileUrl(imageUrl)" class="upload-image" @error="$fileRetry(imageUrl)" />
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="editImg" v-if="!self_disabled">
<el-icon :size="props.iconSize"><Edit /></el-icon>
@@ -48,7 +48,7 @@
:teleported="true"
v-if="imgViewVisible"
@close="imgViewVisible = false"
:url-list="[imageUrl.includes('http') ? imageUrl : baseURL + imageUrl]"
:url-list="$fileUrls([imageUrl])"
/>
</div>
</template>
@@ -66,7 +66,7 @@ interface UploadFileProps {
drag?: boolean // 是否支持拖拽上传 ==> 非必传(默认为 true)
disabled?: boolean // 是否禁用上传组件 ==> 非必传(默认为 false)
fileSize?: number // 图片大小限制 ==> 非必传(默认为 5M)
fileType?: File.ImageMimeType[] // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
fileType?: string[] // 图片类型限制 ==> 非必传(默认 JPEG、PNG、GIF、WebP)
height?: string // 组件高度 ==> 非必传(默认为 150px)
width?: string // 组件宽度 ==> 非必传(默认为 150px)
borderRadius?: string // 组件边框圆角 ==> 非必传(默认为 8px)
@@ -82,7 +82,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
drag: true,
disabled: false,
fileSize: 5,
fileType: () => ["image/jpeg", "image/png", "image/gif"],
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
height: "150px",
width: "150px",
borderRadius: "8px",
@@ -116,21 +116,17 @@ const handleHttpUpload = async (options: UploadRequestOptions) => {
let formData = new FormData()
formData.append("file", options.file)
formData.append("dir", props.dir)
try {
const { data } = await request({
url: props.uploadFileUrl,
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
emit("update:imageUrl", data.url)
// 调用 el-form 内部的校验方法(可自动校验)
formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
} catch (error) {
options.onError(error as any)
}
const { data } = await request({
url: props.uploadFileUrl,
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
emit("update:imageUrl", data.url)
// 调用 el-form 内部的校验方法(可自动校验)
formItemContext?.prop && formContext?.validateField([formItemContext.prop as string])
}
/**
@@ -186,10 +182,10 @@ const uploadSuccess = () => {
/**
* @description 图片上传错误
* */
const uploadError = () => {
const uploadError = (error: any) => {
ElNotification({
title: "温馨提示",
message: "图片上传失败,请您重新上传!",
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
type: "error",
})
}
+30 -31
View File
@@ -1,5 +1,5 @@
<template>
<div class="upload-box">
<div ref="imageContainer" class="upload-box">
<el-upload
v-model:file-list="fileList"
list-type="picture-card"
@@ -19,12 +19,14 @@
</el-upload>
<el-dialog v-model="dialogVisible">
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { fileUrl, originalFileUrl, resolveFileUrl } from '/@/utils/file-access'
import { bindFileAccessDom } from '/@/utils/file-access-dom'
import { ref, watch } from "vue"
import { Plus } from "@element-plus/icons-vue"
import { ElUpload, ElDialog, UploadRequestOptions, ElNotification, UploadFile, UploadProps, formContextKey } from "element-plus"
@@ -40,16 +42,20 @@ interface UploadFileProps {
}
const props = withDefaults(defineProps<UploadFileProps>(), {
disabled: false,
fileType: () => ["image/jpeg", "image/png", "image/gif"],
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
fileSize: 5,
height: "150px",
width: "150px",
borderRadius: "8px",
})
const imageContainer = ref<HTMLElement>()
let stopResourceRenewal: (() => void) | undefined
onMounted(() => { if (imageContainer.value) stopResourceRenewal = bindFileAccessDom(imageContainer.value) })
onBeforeUnmount(() => stopResourceRenewal?.())
const imageUrls = defineModel("modelValue", { default: "" })
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
const dialogVisible = ref(false)
const fileList = ref<any>([])
const dialogImageUrl = ref("")
@@ -60,20 +66,16 @@ const formContext = inject(formContextKey, void 0)
const handleHttpUpload = async (options: UploadRequestOptions) => {
let formData = new FormData()
formData.append("file", options.file)
try {
const res = await request({
url: "/admin/sys-file/upload",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
if (res.code == 0) {
return res
}
} catch (error) {
options.onError(error as any)
const res = await request({
url: "/admin/sys-file/upload",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
if (res.code == 0) {
return res
}
}
@@ -105,19 +107,18 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
* @description 图片上传成功
* */
const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) => {
console.log("success", uploadFiles)
const urls = uploadFiles
.map((item: any) => {
// 处理新上传和已有的图片URL
return item.response ? item.response.data.url : item.fileUrl
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
})
.filter(Boolean)
.join(",")
imageUrls.value = urls
console.log("fileList.value", fileList.value)
console.log("urls", urls)
ElNotification({
title: "温馨提示",
@@ -136,7 +137,7 @@ const handlePictureCardPreview = (uploadFile: any) => {
const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
const urls = uploadFiles
.map((item) => {
return item.response ? item.response.data.url : item.fileUrl
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
})
.filter(Boolean)
.join(",")
@@ -146,10 +147,10 @@ const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
/**
* @description 图片上传错误
* */
const uploadError = () => {
const uploadError = (error: any) => {
ElNotification({
title: "温馨提示",
message: "图片上传失败,请您重新上传!",
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
type: "error",
})
}
@@ -171,16 +172,15 @@ watch(
fileList.value = newValue
.split(",")
.map((item: string) => {
const url = item.startsWith("http") ? item : imageBaseUrl + item
const url = resolveFileUrl(item)
return {
name: item.split("/").pop() || "",
url: url,
url: fileUrl(url),
fileUrl: item,
}
})
.filter((item) => item.url)
console.log("fileList.value", fileList.value)
} else {
fileList.value = newValue
.split("?")
@@ -189,16 +189,15 @@ watch(
item.endsWith(".jpg") || item.endsWith(".png") || item.endsWith(".gif") || item.endsWith(".jpeg") || item.endsWith(".webp"),
)
.map((item: string) => {
const url = item.startsWith("http") ? item : imageBaseUrl + item
const url = resolveFileUrl(item)
return {
name: item.split("/").pop() || "",
url: url,
url: fileUrl(url),
fileUrl: item,
}
})
.filter((item) => item.url)
console.log("fileList.value", fileList.value)
}
},
{ immediate: true },
+21 -5
View File
@@ -2,6 +2,7 @@
<template>
<div class="w-full upload-file">
<el-upload ref="fileUpload" v-if="props.type === 'default'"
:http-request="handleHttpUpload"
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
:file-list="fileList" :headers="headers" :limit="limit" :on-error="handleUploadError"
:on-remove="handleRemove" :on-preview="handlePreview" :on-exceed="handleExceed" :data="formData"
@@ -24,6 +25,7 @@
</template>
</el-upload>
<el-upload ref="fileUpload" v-if="props.type === 'simple'"
:http-request="handleHttpUpload"
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
:file-list="fileList" :headers="headers" :limit="limit" :auto-upload="autoUpload"
:on-error="handleUploadError" :on-remove="handleRemove" :on-exceed="handleExceed" :data="formData"
@@ -38,6 +40,8 @@ import { useMessage } from '/@/hooks/message';
import { Session } from '/@/utils/storage';
import other from '/@/utils/other';
import { useI18n } from 'vue-i18n';
import request from '/@/utils/request';
import type { UploadRequestOptions } from 'element-plus';
const props = defineProps({
modelValue: [String, Array],
@@ -53,7 +57,7 @@ const props = defineProps({
},
fileType: {
type: Array,
default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'apk'],
default: () => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg', 'pdf', 'apk', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'mp4', 'mov', 'webm', 'avi', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'amr', 'flac'],
},
// 是否显示提示
isShowTip: {
@@ -93,6 +97,16 @@ const uploadList = ref<{ name: string; url: string; fileUrl: string; fileSize: n
const fileUpload = ref();
const { t } = useI18n();
// Axios applies the gateway prefix, architecture adaptation and fresh signature once.
const handleHttpUpload = (options: UploadRequestOptions) => {
const data = new FormData();
for (const [name, value] of Object.entries(options.data || {})) {
if (value != null) data.append(name, value instanceof Blob ? value : String(value));
}
data.append(options.filename || 'file', options.file);
return request({ url: props.uploadFileUrl, method: 'post', data, timeout: 0 });
};
// 请求头处理
const headers = computed(() => {
return {
@@ -111,8 +125,8 @@ const handleBeforeUpload = (file: File) => {
// 校检文件类型
if (props.fileType.length) {
const fileName = file.name.split('.');
const fileExt = fileName[fileName.length - 1];
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
const fileExt = fileName[fileName.length - 1].toLowerCase();
const isTypeOk = props.fileType.some(value => String(value).toLowerCase() === fileExt);
if (!isTypeOk) {
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
return false;
@@ -193,8 +207,10 @@ const listToString = (list: { url: string }[], separator = ','): string => {
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
};
const handleUploadError = () => {
useMessage().error('上传文件失败');
const handleUploadError = (error: any) => {
number.value = Math.max(0, number.value - 1);
useMessage().error(error?.msg || error?.message || '上传文件失败');
uploadedSuccessfully();
};
/**
+14 -18
View File
@@ -10,7 +10,7 @@
</el-upload>
<el-dialog v-model="dialogVisible">
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
</el-dialog>
</div>
</template>
@@ -31,7 +31,7 @@ interface UploadFileProps {
}
const props = withDefaults(defineProps<UploadFileProps>(), {
disabled: false,
fileType: () => ["image/jpeg", "image/png", "image/gif"],
fileType: () => ["image/jpeg", "image/png", "image/gif", "image/webp"],
fileSize: 5,
height: "150px",
width: "150px",
@@ -51,20 +51,16 @@ const formContext = inject(formContextKey, void 0)
const handleHttpUpload = async (options: UploadRequestOptions) => {
let formData = new FormData()
formData.append("file", options.file)
try {
const res = await request({
url: "/admin/sys-file/qn/upload",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
if (res.code == 0) {
return res
}
} catch (error) {
options.onError(error as any)
const res = await request({
url: "/admin/sys-file/qn/upload",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: formData,
})
if (res.code == 0) {
return res
}
}
@@ -137,10 +133,10 @@ const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
/**
* @description 图片上传错误
* */
const uploadError = () => {
const uploadError = (error: any) => {
ElNotification({
title: "温馨提示",
message: "图片上传失败,请您重新上传!",
message: error?.msg || error?.message || "图片上传失败,请您重新上传!",
type: "error",
})
}
+83 -6
View File
@@ -1,6 +1,16 @@
<template>
<div class="flex min-w-0 items-center gap-3 text-left">
<el-avatar :size="props.avatarSize" :src="props.user.avatar || undefined" :alt="displayName" class="user-info-cell__avatar shrink-0">
<div v-if="props.variant === 'compact'" class="user-info-cell--compact">
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(compactAvatar) || undefined" @error="$fileRetry(compactAvatar)"
aria-hidden="true" class="user-info-cell__compact-avatar">
<el-icon><User /></el-icon>
</el-avatar>
<div class="user-info-cell__compact-text">
<strong :title="compactName">{{ compactName }}</strong>
<span :title="compactSecondary">{{ compactSecondary }}</span>
</div>
</div>
<div v-else class="flex min-w-0 items-center gap-3 text-left">
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(props.user.avatar) || undefined" @error="$fileRetry(props.user.avatar)" :alt="displayName" class="user-info-cell__avatar shrink-0">
{{ avatarFallback }}
</el-avatar>
<div class="min-w-0 flex-1">
@@ -37,13 +47,16 @@
</template>
<script setup lang="ts" name="UserInfoCell">
import { computed } from "vue"
import { User } from "@element-plus/icons-vue"
/** 可复用用户信息单元格需要的最小用户摘要。 */
interface UserInfoCellUser {
/** 用户头像地址;为空时组件展示姓名或账号首字。 */
/** 用户头像地址;为空或加载失败时,详细模式显示姓名或账号首字,紧凑模式显示用户图标。 */
avatar?: string | null
/** 用户姓名;为空时显示明确的缺失占位。 */
name?: string | null
/** 用户登录账号;为空时显示未分配。 */
/** 详细模式表示登录账号;紧凑模式兼容钱包接口的用户姓名。 */
username?: string | null
/** 用户绑定手机号;为空时显示未绑定。 */
phone?: string | null
@@ -51,13 +64,23 @@ interface UserInfoCellUser {
gender?: string | number | null
/** 用户所属部门名称;为空时显示未分配。 */
deptName?: string | null
/** 规范用户摘要的主键;紧凑模式缺手机号时用于 ID 提示,保留字符串大整数。 */
id?: string | number | null
/** 钱包接口的用户主键;与钱包或订单行的 id 同时存在时优先用于身份提示。 */
userId?: string | number | null
/** 钱包接口的用户头像地址,紧凑模式下作为 avatar 的兼容字段。 */
userAvatar?: string | null
/** 钱包接口的用户手机号,紧凑模式下作为 phone 的兼容字段。 */
userMobile?: string | null
}
/** 用户信息单元格的展示配置。 */
interface UserInfoCellProps {
/** 后端接口或上层列表传入的用户摘要。 */
user: UserInfoCellUser
/** 头像直径,单位为像素。 */
/** 展示密度:details 保留完整资料,compact 仅显示头像、姓名和手机号或用户 ID。 */
variant?: "details" | "compact"
/** 头像直径,单位为像素;未传时详细模式为 38,紧凑模式为 32。 */
avatarSize?: number
/** 是否展示性别文本。 */
showGender?: boolean
@@ -71,7 +94,7 @@ interface UserInfoCellProps {
/** 当前组件的输入与默认展示策略。 */
const props = withDefaults(defineProps<UserInfoCellProps>(), {
avatarSize: 38,
variant: "details",
showGender: true,
showAccount: true,
showPhone: true,
@@ -90,6 +113,23 @@ const formatText = (value: string | null | undefined, emptyText: string) => {
return text || emptyText
}
/** 头像尺寸集中由展示模式决定,已有页面的显式尺寸优先。 */
const resolvedAvatarSize = computed(() => props.avatarSize ?? (props.variant === "compact" ? 32 : 38))
/** 紧凑模式兼容规范摘要与钱包列表字段,姓名缺失时展示明确占位。 */
const compactName = computed(() => formatText(props.user.name?.trim() || props.user.username, "用户信息不可用"))
/** 紧凑头像仍由统一文件访问能力解析,不在单元格中增加用户资料请求。 */
const compactAvatar = computed(() => props.user.avatar?.trim() || props.user.userAvatar?.trim() || undefined)
/** 紧凑次行优先展示手机号;缺失时以用户 ID 帮助识别,零值主键也保留。 */
const compactSecondary = computed(() => {
const phone = props.user.phone?.trim() || props.user.userMobile?.trim()
if (phone) return phone
const id = props.user.userId ?? props.user.id
return id !== undefined && id !== null && String(id).trim() ? `ID ${id}` : "用户信息不可用"
})
/** 用户姓名展示文本。 */
const displayName = computed(() => formatText(props.user.name, "未填写姓名"))
@@ -126,6 +166,43 @@ const departmentTitle = computed(() => formatText(props.user.deptName, "未分
</script>
<style scoped lang="scss">
.user-info-cell--compact {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
text-align: left;
}
.user-info-cell__compact-avatar {
flex-shrink: 0;
font-size: 18px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
}
.user-info-cell__compact-text {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.user-info-cell__compact-text strong {
overflow: hidden;
font-size: 13px;
font-weight: 500;
line-height: 18px;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.user-info-cell__compact-text span {
overflow: hidden;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
.user-info-cell__avatar {
font-weight: 600;
color: var(--el-color-primary);
@@ -14,12 +14,12 @@
<i class="iconfont icon-refresh"></i>
</div>
<img
:src="'data:image/png;base64,' + pointBackImgBase"
:src="$fileUrl('data:image/png;base64,' + pointBackImgBase)"
ref="canvas"
alt=""
style="width: 100%; height: 100%; display: block"
@click="bindingClick ? canvasClick($event) : undefined"
/>
@error="$fileRetry('data:image/png;base64,' + pointBackImgBase)" />
<div
v-for="(tempPoint, index) in tempPoints"
@@ -2,7 +2,7 @@
<div style="position: relative">
<div v-if="type === '2'" :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" class="verify-img-out">
<div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel modern-shadow">
<img :src="'data:image/png;base64,' + backImgBase" alt="" style="width: 100%; height: 100%; display: block" />
<img :src="$fileUrl('data:image/png;base64,' + backImgBase)" alt="" style="width: 100%; height: 100%; display: block" @error="$fileRetry('data:image/png;base64,' + backImgBase)" />
<div v-show="showRefresh" class="verify-refresh" @click="refresh"><i class="iconfont icon-refresh"></i></div>
<transition name="tips">
<span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">{{ tipWords }}</span>
@@ -46,10 +46,10 @@
class="verify-sub-block"
>
<img
:src="'data:image/png;base64,' + blockBackImgBase"
:src="$fileUrl('data:image/png;base64,' + blockBackImgBase)"
alt=""
style="width: 100%; height: 100%; display: block; -webkit-user-drag: none"
/>
@error="$fileRetry('data:image/png;base64,' + blockBackImgBase)" />
</div>
</div>
</div>
+3 -1
View File
@@ -3,7 +3,9 @@
<video-play
ref="playerRef"
v-bind="options"
:src="src"
:src="$fileUrl(src)"
:poster="$fileUrl(poster)"
@error="$fileRetry(src)"
@play="onPlay"
@pause="onPause"
@timeupdate="onTimeupdate"
@@ -3,7 +3,7 @@
<div v-if="objData.type === 'image'">
<div class="waterfall" v-loading="state.loading">
<div class="waterfall-item" v-for="item in state.dataList" :key="item.mediaId">
<img class="material-img" :src="item.url" />
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
<p class="item-name">{{ item.name }}</p>
<el-row class="ope-row">
<el-button type="success" @click="selectMaterial(item)"
+15 -9
View File
@@ -39,7 +39,7 @@
</div>
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
<div v-if="item.repType === 'image'">
<a target="_blank" :href="item.repUrl"><img :src="item.repUrl" style="width: 100px" /></a>
<a target="_blank" :href="$fileUrl(item.repUrl)"><img :src="$fileUrl(item.repUrl)" style="width: 100px" @error="$fileRetry(item.repUrl)" /></a>
</div>
<div v-if="item.repType === 'voice'">
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
@@ -54,7 +54,7 @@
<el-link
type="primary"
target="_blank"
:href="
:href="$fileUrl(
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
item.repLocationY +
'&pointy=' +
@@ -62,23 +62,29 @@
'&name=' +
item.repContent +
'&ref=joolun'
"
)"
>
<img
:src="
:src="$fileUrl(
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
item.repLocationX +
',' +
item.repLocationY +
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
"
/>
)"
@error="$fileRetry(
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
item.repLocationX +
',' +
item.repLocationY +
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
)" />
<p />
<i class="el-icon-map-location"></i>{{ item.repContent }}
</el-link>
</div>
<div v-if="item.repType === 'link'" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
@@ -87,9 +93,9 @@
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
</div>
<div v-if="item.repType === 'music'">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
<div class="avue-card__avatar"><img :src="item.repThumbUrl" alt="" /></div>
<div class="avue-card__avatar"><img :src="$fileUrl(item.repThumbUrl)" alt="" @error="$fileRetry(item.repThumbUrl)" /></div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
+4 -4
View File
@@ -1,22 +1,22 @@
<template>
<div class="news-home">
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
<a v-if="index === 0" target="_blank" :href="news.url">
<a v-if="index === 0" target="_blank" :href="$fileUrl(news.url)">
<div class="news-main">
<div class="news-content">
<img class="material-img" :src="news.thumbUrl" style="width: 280; height: 120" />
<img class="material-img" :src="$fileUrl(news.thumbUrl)" style="width: 280; height: 120" @error="$fileRetry(news.thumbUrl)" />
<div class="news-content-title">
<span>{{ news.title }}</span>
</div>
</div>
</div>
</a>
<a v-if="index > 0" target="_blank" :href="news.url">
<a v-if="index > 0" target="_blank" :href="$fileUrl(news.url)">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ news.title }}</div>
<div class="news-content-item-img">
<img class="material-img" :src="news.thumbUrl" height="100%" />
<img class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
</div>
</div>
</div>
+3 -3
View File
@@ -9,7 +9,7 @@
<template #label><i class="el-icon-picture"></i> 图片</template>
<el-row>
<div v-if="objData.repUrl" class="select-item">
<img class="material-img" :src="objData.repUrl" />
<img class="material-img" :src="$fileUrl(objData.repUrl)" @error="$fileRetry(objData.repUrl)" />
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
@@ -36,7 +36,7 @@
<div v-if="objData.repName" class="select-item">
<p class="item-name">{{ objData.repName }}</p>
<div class="item-infos">
<img :src="WxVoice" style="width: 100px" @click="loadVideo(item)" />
<img :src="$fileUrl(WxVoice)" style="width: 100px" @click="loadVideo(item)" @error="$fileRetry(WxVoice)" />
</div>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
@@ -65,7 +65,7 @@
</el-row>
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<a v-if="objData.repUrl" target="_blank" :href="objData.repUrl"> <SvgIcon name="local-wx-video" :size="45" /> </a
<a v-if="objData.repUrl" target="_blank" :href="$fileUrl(objData.repUrl)"> <SvgIcon name="local-wx-video" :size="45" /> </a
></el-col>
<el-col :span="12" class="col-add">
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
+2
View File
@@ -14,6 +14,7 @@ import Tip from "/@/components/Tip/index.vue"
import TagList from "/@/components/TagList/index.vue"
import SvgIcon from "/@/components/SvgIcon/index.vue"
import PreviewImage from "/@/components/PreviewImage/index.vue"
import FileImage from "/@/components/FileImage/index.vue"
import PreviewJson from "/@/components/PreviewJson/index.vue"
import PreviewText from "/@/components/PreviewText/index.vue"
import PreviewXml from "/@/components/PreviewXml/index.vue"
@@ -86,6 +87,7 @@ export default {
app.component("TagList", TagList)
app.component("SvgIcon", SvgIcon)
app.component("PreviewImage", PreviewImage)
app.component("FileImage", FileImage)
app.component("PreviewJson", PreviewJson)
app.component("PreviewText", PreviewText)
app.component("PreviewXml", PreviewXml)
+65
View File
@@ -0,0 +1,65 @@
import { ref, toRaw, unref } from "vue"
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useMessage } from "/@/hooks/message"
import { downloadExportFile } from "/@/utils/exportDownload"
/** 页面只声明业务接口、名称及命名空间,任务状态由公共组件管理。 */
interface BatchExportOptions {
progressUrl: string
redisKey: string
taskName: string
fileName: string
submitTask: (query: Record<string, unknown>) => Promise<any>
}
/** 固定筛选快照,保留排序;当前列表页码和页大小不限制全量导出。 */
export const snapshotExportQuery = (query: Record<string, unknown>) => {
const snapshot = JSON.parse(JSON.stringify(toRaw(unref(query))))
delete snapshot.current
delete snapshot.size
return snapshot as Record<string, unknown>
}
/** 复用任务提交互斥、进度跟踪及下载。查询中断后继续跟踪原任务,不重新提交。 */
export const useBatchExport = (options: BatchExportOptions) => {
const exportProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
const exportSubmitting = ref(false)
const exportTaskConfig: AsyncTaskPollingConfig = {
progressUrl: options.progressUrl,
redisKey: options.redisKey,
waitingMessage: "正在后台分批生成文件,进度达到 100% 表示文件已就绪",
downloadButtonText: "重新下载 Excel",
}
const submitExport = async (query: Record<string, unknown>) => {
if (exportSubmitting.value) {
exportProgressRef.value?.show()
return
}
exportSubmitting.value = true
exportProgressRef.value?.startSubmitting(options.taskName)
try {
const response = await options.submitTask(snapshotExportQuery(query))
if (!response?.data?.taskId) throw new Error("导出任务响应缺少任务 ID")
exportProgressRef.value?.start(response.data)
} catch (error: any) {
exportSubmitting.value = false
const message = error?.msg || error?.message || "导出任务提交失败,请重试"
exportProgressRef.value?.submissionFailed(message)
useMessage().error(message)
}
}
const handleExportSuccess = (progress: AsyncTaskProgress) => {
exportSubmitting.value = false
try {
downloadExportFile(progress.resultFileUrl || "", progress.resultFileName || options.fileName || "导出结果.xlsx")
} catch (error: any) {
useMessage().error(error?.message || "下载导出文件失败")
}
}
const handleExportFailure = (progress: AsyncTaskProgress) => {
exportSubmitting.value = false
useMessage().error(progress.message || "导出任务失败")
}
return { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure }
}
+172
View File
@@ -0,0 +1,172 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from "vue"
import { pageJoinActive } from "/@/api/wallet/wallet-manage"
import type { WalletInactivePage, WalletInactiveQuery, WalletInactiveUser } from "/@/api/wallet/wallet-inactive"
/** 本页独立的查询生命周期;取消请求和序号校验同时防止旧响应覆盖。 */
export function useWalletInactive() {
const form = reactive({
userId: undefined as string | undefined,
unactiveMonthsNumber: 3 as number | undefined,
deptIds: [] as string[],
isInclude: true,
})
const pagination = reactive({ current: 1, size: 20, total: 0 })
const rows = shallowRef<WalletInactiveUser[]>([])
const metadata = shallowRef<WalletInactivePage>()
const loading = ref(false)
const loaded = ref(false)
const error = ref("")
const monthsError = ref("")
let sequence = 0
let controller: AbortController | undefined
let pendingKey = ""
let pending: Promise<void> | undefined
let departmentTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
const summary = computed(() => ({
neverUsed: rows.value.filter((row) => row.neverTransacted).length,
balance: rows.value.reduce((total, row) => total + Number(row.totalBalance), 0),
partialBalance: rows.value.some((row) => row.partialBalance),
}))
const stopPending = () => {
sequence++
controller?.abort()
controller = undefined
pending = undefined
pendingKey = ""
clearTimeout(departmentTimer)
departmentTimer = undefined
}
const clearResult = () => {
rows.value = []
// 保留最后一次分页总数,避免 Element Plus 在请求中把 total=0 解释为跳回第一页。
// 概览由 loaded/loading 控制,请求中和失败时不会展示旧人数。
metadata.value = undefined
loaded.value = false
error.value = ""
}
const query = (): Promise<void> => {
if (disposed) return Promise.resolve()
clearTimeout(departmentTimer)
departmentTimer = undefined
const months = form.unactiveMonthsNumber
if (!Number.isInteger(months) || months! < 1 || months! > 120) {
stopPending()
clearResult()
loading.value = false
monthsError.value = "请输入1至120的整数月数"
return Promise.resolve()
}
monthsError.value = ""
const snapshot: WalletInactiveQuery = {
userId: form.userId ? String(form.userId) : undefined,
unactiveMonthsNumber: months!,
deptIds: [...new Set(form.deptIds.map(String))].sort(),
isInclude: form.isInclude,
current: pagination.current,
size: pagination.size,
}
const key = JSON.stringify(snapshot)
if (pending && key === pendingKey) return pending
stopPending()
const requestSequence = sequence
const requestController = new AbortController()
controller = requestController
pendingKey = key
clearResult()
loading.value = true
pending = (async () => {
try {
const response = await pageJoinActive(snapshot, requestController.signal)
if (disposed || requestSequence !== sequence) return
const page = response.data
if (
!page ||
!Array.isArray(page.records) ||
typeof page.asOfTime !== "string" ||
typeof page.cutoffTime !== "string" ||
!page.walletNames ||
!page.records.every((row) => typeof row.userId === "string" && Array.isArray(row.wallets))
) {
throw new Error("统计结果不完整,请刷新页面后重试")
}
rows.value = page.records
pagination.current = Number(page.current)
pagination.size = Number(page.size)
pagination.total = Number(page.total)
metadata.value = page
loaded.value = true
} catch (cause: any) {
if (disposed || requestSequence !== sequence || requestController.signal.aborted) return
error.value = cause?.msg || cause?.message || "统计查询失败,请重试"
} finally {
if (requestSequence === sequence) {
loading.value = false
pending = undefined
pendingKey = ""
controller = undefined
}
}
})()
return pending
}
const search = () => {
pagination.current = 1
return query()
}
const reset = () => {
Object.assign(form, { userId: undefined, unactiveMonthsNumber: 3, deptIds: [], isInclude: true })
monthsError.value = ""
return search()
}
const setMonths = (months: number) => {
form.unactiveMonthsNumber = months
return search()
}
const changePage = (current: number) => {
if (!loaded.value || current === pagination.current) return
pagination.current = current
return query()
}
const changeSize = (size: number) => {
if (size === pagination.size) return
pagination.size = size
return search()
}
const changeDepartments = (ids: Array<string | number>, include: boolean) => {
form.deptIds = ids.map(String)
form.isInclude = include
pagination.current = 1
stopPending()
clearResult()
loading.value = true
departmentTimer = setTimeout(() => {
void query()
}, 250)
}
onMounted(query)
onBeforeUnmount(() => {
disposed = true
stopPending()
})
return {
form,
pagination,
rows,
metadata,
loading,
loaded,
error,
monthsError,
summary,
search,
refresh: query,
reset,
setMonths,
changePage,
changeSize,
changeDepartments,
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
</div>
</div>
<div :class="themeConfig.columnsAsideLayout" v-else>
<a :href="v.meta.isLink" target="_blank">
<a :href="$fileUrl(v.meta.isLink)" target="_blank">
<SvgIcon :name="v.meta.icon" />
<div class="columns-vertical-title font12">
{{
+1 -1
View File
@@ -28,7 +28,7 @@
<div v-show="state.isShowLoockLogin" class="layout-lock-screen-login">
<div class="layout-lock-screen-login-box">
<div class="layout-lock-screen-login-box-img">
<img :src="baseURL + formData.avatar" />
<img :src="$fileUrl(formData.avatar)" @error="$fileRetry(formData.avatar)" />
</div>
<div class="layout-lock-screen-login-box-name">{{ formData.username }}</div>
<div class="layout-lock-screen-login-box-value">
+1 -1
View File
@@ -3,7 +3,7 @@
<span :style="{color:setFontColor}">{{ themeConfig.globalTitle }}</span>
</div>
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
<img :src="logoMini" class="layout-logo-size-img"/>
<img :src="$fileUrl(logoMini)" class="layout-logo-size-img" @error="$fileRetry(logoMini)" />
</div>
</template>
+1 -1
View File
@@ -63,7 +63,7 @@
</div>
<el-dropdown :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
<span class="layout-navbars-breadcrumb-user-link">
<img :src="baseURL + userInfos.user.avatar" class="layout-navbars-breadcrumb-user-link-photo mr5" />
<img :src="$fileUrl(userInfos.user.avatar)" class="layout-navbars-breadcrumb-user-link-photo mr5" @error="$fileRetry(userInfos.user.avatar)" />
{{ userInfos.user.username }}
<el-icon class="el-icon--right">
<ele-ArrowDown />
+3
View File
@@ -1,3 +1,5 @@
import { installFileAccess } from './utils/file-access'
import { downBlobFile } from './utils/other'
import { createApp } from "vue"
import pinia from "/@/stores/index"
import App from "./App.vue"
@@ -23,6 +25,7 @@ import "/@/theme/tailwind.css"
import "/@/theme/index.scss"
const app = createApp(App)
installFileAccess(app, downBlobFile)
// 全局自定方法挂载
properties(app)
+11
View File
@@ -0,0 +1,11 @@
import 'vue'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$fileUrl: (source?: string | null) => string
$fileUrls: (sources: any) => string[]
$fileHtml: (html: string) => string
$fileNodes: (nodes: any) => any
$fileRetry: (source?: string | null) => Promise<boolean>
}
}
export {}
+16
View File
@@ -0,0 +1,16 @@
import other from "/@/utils/other"
/** 文件中心结果由浏览器原生下载,避免 Axios Blob 将整个大文件读进 JS 内存。 */
export const downloadExportFile = (fileUrl: string, fileName: string) => {
if (!fileUrl) throw new Error("导出完成,但未返回文件下载地址")
const url = /^https?:\/\//i.test(fileUrl)
? fileUrl
: `${String(import.meta.env.VITE_API_URL || "").replace(/\/$/, "")}${other.adaptationUrl(fileUrl)}`
const link = document.createElement("a")
link.href = url
link.style.display = "none"
link.download = (fileName || "导出结果.xlsx").replace(/[<>:"/\\|?*]/g, "_")
document.body.appendChild(link)
link.click()
link.remove()
}
+163
View File
@@ -0,0 +1,163 @@
import * as CryptoJS from 'crypto-js'
export const FILE_ACCESS_FIELDS = ['appId', 'kid', 'ts', 'sign']
const FILE_PATHS = ['/sys-file/oss/file', '/admin/sys-file/oss/file', '/api/admin/sys-file/oss/file']
export interface FileAccessOptions {
appId: string; kid: string; key: string; baseUrl: () => string
allowedOrigins?: string[]; now?: () => number
}
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
const encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
const origin = (s: string) => {
const match = /^(https?):\/\/([^/?#]+)/i.exec(s)
if (!match || match[2].includes('@')) return ''
return (match[1] + '://' + match[2]).toLowerCase().replace(match[1].toLowerCase() === 'https' ? /:443$/ : /:80$/, '')
}
export function fileAccessCanonical(file: string, app: string, kid: string, ts: number): string {
const name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(file)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return ['v1', 'oss-file-read', app, kid, String(ts), name].join('\n')
}
export function fileAccessSignature(file: string, app: string, kid: string, ts: number, key: string): string {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
}
export function fileUploadCanonical(target: string, app: string, kid: string, ts: number, nonce: string): string {
if (!['upload', 'inner-upload', 'qn-upload'].includes(target)) throw new Error('Invalid file upload target')
return ['v1', 'oss-file-upload', 'POST', target, app, kid, String(ts), nonce].join('\n')
}
export function fileUploadSignature(target: string, app: string, kid: string, ts: number, nonce: string, key: string): string {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file upload key')
return CryptoJS.HmacSHA256(fileUploadCanonical(target, app, kid, ts, nonce), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
}
export function createFileAccess(options: FileAccessOptions) {
const now = options.now || Date.now
let offset = 0, ttl = 1800
const cache = new Map<string, { url: string; ts: number }>()
let uploadSequence = 0
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
function resolve(source?: string | null): string {
const raw = typeof source === 'string' ? source.trim() : ''
if (!raw || /^(?:data:|blob:|file:|wxfile:|content:|\/static\/|static\/|\/assets\/|\/src\/|@\/|#)/i.test(raw)) return raw
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw
if (raw.startsWith('//')) return (base().startsWith('http:') ? 'http:' : 'https:') + raw
const root = base()
if (/^[^/?#:\\]+\.[a-z0-9]{1,10}$/i.test(raw)) return root + '/admin/sys-file/oss/file?fileName=' + encode(raw)
const path = raw.startsWith('/') ? raw : '/' + raw
if (/^(?:\/api)?\/admin\/sys-file\/oss\/(?:file|access-info)(?:[?#]|$)/.test(path)) return root + path.replace(/^\/api\//, '/')
const prefix = root.replace(/^https?:\/\/[^/?#]+/i, '')
if ((prefix && path.startsWith(prefix + '/')) || path.startsWith('/api/')) return (origin(root) || '') + path
if (path.startsWith('/sys-file/oss/')) return root + '/admin' + path
if (raw.startsWith('?')) return root + '/admin/sys-file/oss/file' + raw
return root + path
}
function parse(source?: string | null) {
const resolved = resolve(source)
const ownOrigin = origin(resolved)
if (ownOrigin && !allowed().includes(ownOrigin)) return null
if (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !ownOrigin) return null
const relative = ownOrigin ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved
const path = relative.split(/[?#]/)[0]
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
if (!FILE_PATHS.includes(path) && path !== prefix + '/admin/sys-file/oss/file') return null
const hashAt = resolved.indexOf('#'), head = hashAt < 0 ? resolved : resolved.slice(0, hashAt)
const hash = hashAt < 0 ? '' : resolved.slice(hashAt)
const question = head.indexOf('?')
const endpoint = question < 0 ? head : head.slice(0, question)
const pairs = (question < 0 ? '' : head.slice(question + 1)).split('&').filter(Boolean).map(pair => {
const at = pair.indexOf('='); return [decode(at < 0 ? pair : pair.slice(0, at)), decode(at < 0 ? '' : pair.slice(at + 1))]
})
const names = pairs.filter(([key]) => key === 'fileName')
if (names.length !== 1 || !names[0][1] || /[\r\n]/.test(names[0][1])) throw new Error('Invalid file-center URL')
const extras = pairs.filter(([key]) => key !== 'fileName' && !FILE_ACCESS_FIELDS.includes(key))
const unsigned = endpoint + '?' + [...extras, ['fileName', names[0][1]]].map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + hash
return { endpoint, file: names[0][1], extras, hash, unsigned }
}
function url(source?: string | null, force = false): string {
const parsed = parse(source)
if (!parsed) return resolve(source)
const seconds = Math.floor((now() + offset) / 1000), cached = cache.get(parsed.unsigned)
if (!force && cached && seconds >= cached.ts - 300 && seconds < cached.ts + ttl - Math.min(300, ttl / 6)) return cached.url
if (!options.appId || !options.kid) throw new Error('Missing file access configuration')
const sign = fileAccessSignature(parsed.file, options.appId, options.kid, seconds, options.key)
const pairs = [...parsed.extras, ['appId', options.appId], ['kid', options.kid], ['ts', String(seconds)], ['sign', sign], ['fileName', parsed.file]]
const result = parsed.endpoint + '?' + pairs.map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + parsed.hash
if (cache.size >= 2048) cache.delete(cache.keys().next().value as string)
cache.set(parsed.unsigned, { url: result, ts: seconds })
return result
}
function strip(source?: string | null): string {
if (!source) return source || ''
const parsed = parse(source)
if (!parsed) return source
// Preserve the original origin/path form in persisted values.
const hashAt = source.indexOf('#'), hash = hashAt < 0 ? '' : source.slice(hashAt)
const head = hashAt < 0 ? source : source.slice(0, hashAt), at = head.indexOf('?')
if (at < 0) return source
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
}
function uploadTarget(source: string): string | null {
const resolved = resolve(source), host = origin(resolved)
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
.some(start => path === start + suffix)) return target
}
return null
}
function uploadHeaders(source: string, fixedNonce?: string): Record<string, string> {
const target = uploadTarget(source)
if (!target) return {}
const seconds = Math.floor((now() + offset) / 1000)
// The nonce is a unique request identifier, not a secret; HMAC provides authorization.
const nonce = fixedNonce || Math.floor(now()).toString(36) + '-' + (++uploadSequence).toString(36) + '-'
+ Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')).join('')
return {
'X-File-App-Id': options.appId, 'X-File-Key-Id': options.kid, 'X-File-Timestamp': String(seconds), 'X-File-Nonce': nonce,
'X-File-Signature': fileUploadSignature(target, options.appId, options.kid, seconds, nonce, options.key),
}
}
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
if (!Number.isFinite(serverTime) || serverTime <= 0) return
offset = serverTime + roundTripMs / 2 - now()
if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds
// url() already checks cached timestamps against the corrected clock. Keep
// signatures that are still usable so a small clock correction cannot reload every image.
}
function invalidate(source?: string | null) {
const parsed = parse(source)
if (parsed) cache.delete(parsed.unsigned)
}
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
}
const unescapeAttribute = (s: string) => s.replace(/&amp;/gi, '&').replace(/&quot;/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
const escapeAttribute = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
/** Rewrite only resource attributes; no DOM/window dependency (also used by mini programs). */
export function rewriteFileHtml(html: string, transform: (url: string) => string): string {
if (typeof html !== 'string' || !html) return html
return html.replace(/<[a-z][^>]*>/gi, tag => tag.replace(/\b(src|href|poster|srcset)\s*=\s*(["'])([\s\S]*?)\2/gi, (attribute, name, quote, raw) => {
const value = unescapeAttribute(raw)
const next = name.toLowerCase() === 'srcset' && !value.startsWith('data:')
? value.split(',').map(item => item.trim().replace(/^\S+/, transform)).join(', ')
: transform(value)
return next === value ? attribute : name + '=' + quote + escapeAttribute(next) + quote
}).replace(/url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi, (all, quote, value) => {
const next = transform(unescapeAttribute(value)); return next === value ? all : 'url(' + quote + escapeAttribute(next) + quote + ')'
}))
}
export function rewriteFileNodes(nodes: any, transform: (url: string) => string): any {
if (typeof nodes === 'string') return rewriteFileHtml(nodes, transform)
if (Array.isArray(nodes)) return nodes.map(node => rewriteFileNodes(node, transform))
if (!nodes || typeof nodes !== 'object') return nodes
const copy = { ...nodes }
if (copy.attrs) {
copy.attrs = { ...copy.attrs }
for (const key of ['src', 'href', 'poster']) if (typeof copy.attrs[key] === 'string') copy.attrs[key] = transform(copy.attrs[key])
}
if (copy.children) copy.children = rewriteFileNodes(copy.children, transform)
return copy
}
+39
View File
@@ -0,0 +1,39 @@
import { watch } from 'vue';
import { accessFileUrl, fileAccessRevision, retryFileAccess } from './file-access';
/** Renew editor resources without replacing the document or changing its undo history. */
export function bindFileAccessDom(root: HTMLElement) {
let scheduled = false;
let stopped = false;
const refresh = () => {
if (stopped) return;
for (const node of root.querySelectorAll<HTMLElement>('[src], [href], [poster]')) {
for (const attr of ['src', 'href', 'poster']) {
const source = node.getAttribute(attr);
if (!source) continue;
const target = accessFileUrl(source);
if (target !== source) node.setAttribute(attr, target);
}
}
};
const schedule = () => {
if (scheduled || stopped) return;
scheduled = true;
queueMicrotask(() => { scheduled = false; refresh(); });
};
const observer = new MutationObserver(schedule);
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'href', 'poster'] });
const unwatch = watch(fileAccessRevision, schedule, { flush: 'post' });
const onError = (event: Event) => {
const node = event.target;
if (node instanceof HTMLElement) void retryFileAccess(node.getAttribute('src')).then(schedule);
};
root.addEventListener('error', onError, true);
refresh();
return () => {
stopped = true;
observer.disconnect();
unwatch();
root.removeEventListener('error', onError, true);
};
}
+109
View File
@@ -0,0 +1,109 @@
import { ref } from 'vue'
import { createFileAccess, rewriteFileHtml, rewriteFileNodes } from './file-access-core'
export const fileAccessRevision = ref(0)
const client = createFileAccess({
appId: import.meta.env.VITE_FILE_ACCESS_APP_ID,
kid: import.meta.env.VITE_FILE_ACCESS_KEY_ID,
key: import.meta.env.VITE_FILE_ACCESS_KEY,
baseUrl: () => import.meta.env.VITE_IMAGE_BASE_PATH || import.meta.env.VITE_API_URL || '/api',
allowedOrigins: [import.meta.env.VITE_IMAGE_BASE_PATH, typeof location === 'undefined' ? '' : location.origin, ...(import.meta.env.VITE_FILE_ACCESS_ALLOWED_ORIGINS || '').split(',')],
})
let timer: ReturnType<typeof setInterval> | undefined
let pending: Promise<void> | undefined
let lastSync = 0
const retried = new Map<string, number>()
const retrying = new Map<string, Promise<boolean>>()
export function fileUrl(source?: string | null): string {
void fileAccessRevision.value
try { return client.url(source) } catch { return '' }
}
export const accessFileUrl = (source?: string | null) => isProtectedFile(source) ? fileUrl(source) : (source || '')
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
export const originalFileUrl = (source?: string | null) => client.strip(source)
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
export const isProtectedFile = (source?: string | null) => { try { return client.protected(source) } catch { return false } }
export async function refreshFileAccess(force = false): Promise<void> {
if (pending) return pending
if (!force && Date.now() - lastSync < 60000) return
pending = (async () => {
const started = Date.now()
try {
const result: any = await readAccessInfo()
const info = result?.data || result
if (info?.serverTime) client.synchronize(Number(info.serverTime), Number(info.ttlSeconds), Date.now() - started)
lastSync = Date.now()
} catch { /* Retain the last clock offset and signed access; never fall back to a bare URL. */ }
finally { fileAccessRevision.value++; pending = undefined }
})()
return pending
}
export function startFileAccess() {
if (!timer) timer = setInterval(() => { fileAccessRevision.value++ }, 60000)
void refreshFileAccess()
}
export function stopFileAccess() { if (timer) clearInterval(timer); timer = undefined }
export async function retryFileAccess(source?: string | null): Promise<boolean> {
if (!isProtectedFile(source)) return false
const raw = originalFileUrl(source), previous = retried.get(raw) || 0
const active = retrying.get(raw)
if (active) return active
if (Date.now() - previous < 30 * 60 * 1000) return false
if (retried.size >= 2048) retried.clear()
retried.set(raw, Date.now())
const task = (async () => {
const previousUrl = fileUrl(source)
await refreshFileAccess(true)
client.invalidate(source)
if (fileUrl(source) === previousUrl) {
await new Promise(resolve => setTimeout(resolve, 1100))
client.invalidate(source)
}
fileAccessRevision.value++
return true
})()
retrying.set(raw, task)
try { return await task } finally { retrying.delete(raw) }
}
type FileDownloader = (url: string, query: Record<string, never>, name: string) => unknown
/** Keep owned file links inside the application's fetch/blob download flow. */
export function handleFileLinkClick(event: MouseEvent, download: FileDownloader): boolean {
const anchor = (event.target as Element | null)?.closest?.('a[href]')
const href = anchor?.getAttribute('href')
if (event.defaultPrevented || !href || !isProtectedFile(href)) return false
event.preventDefault()
event.stopImmediatePropagation()
const name = /[?&]fileName=([^&#]*)/.exec(resolveFileUrl(href))
const filename = anchor?.getAttribute('download') || (name ? decodeURIComponent(name[1].replace(/\+/g, ' ')) : '文件')
void download(originalFileUrl(href), {}, filename)
return true
}
export function installFileAccess(app: any, download?: FileDownloader) {
Object.assign(app.config.globalProperties, {
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
})
startFileAccess()
if (typeof window !== 'undefined') {
if (download) document.addEventListener('click', event => { handleFileLinkClick(event, download) }, true)
window.addEventListener('online', () => { retried.clear(); void refreshFileAccess(true) })
document.addEventListener('visibilitychange', () => {
if (document.hidden) stopFileAccess()
else { retried.clear(); startFileAccess() }
})
}
}
async function readAccessInfo() {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(client.resolve('/admin/sys-file/oss/access-info'), { credentials: 'omit', cache: 'no-store', signal: controller.signal })
if (!response.ok) throw new Error('File clock unavailable')
return await response.json()
} finally { clearTimeout(timeout) }
}
+8 -2
View File
@@ -1,3 +1,4 @@
import { accessFileUrl, refreshFileAccess, isProtectedFile } from './file-access'
import { nextTick } from "vue"
import router from "/@/router/index"
import pinia from "/@/stores/index"
@@ -255,9 +256,10 @@ export function base64Encrypt(src: string) {
* @param fileName 文件名称
* @returns {*}
*/
export function downBlobFile(url: any, query: any, fileName: string) {
export function downBlobFile(url: any, query: any, fileName: string, retried = false) {
return request({
url: url,
url: accessFileUrl(url),
...(isProtectedFile(url) ? { baseURL: '' } : {}),
method: "get",
responseType: "blob",
params: query,
@@ -266,6 +268,10 @@ export function downBlobFile(url: any, query: any, fileName: string) {
handleBlobFile(response, fileName)
})
.catch(async (error) => {
if (!retried && isProtectedFile(url)) {
await refreshFileAccess(true)
return downBlobFile(url, query, fileName, true)
}
let msg = "下载失败"
try {
if (error instanceof Blob) {
+4
View File
@@ -3,6 +3,7 @@ import { Session } from "/@/utils/storage"
import { useMessageBox } from "/@/hooks/message"
import qs from "qs"
import other from "./other"
import { fileUploadHeaders } from './file-access'
/**
* 创建并配置一个 Axios 实例对象
@@ -48,6 +49,7 @@ service.interceptors.request.use(
// 自动适配单体和微服务架构不同的URL
config.url = other.adaptationUrl(config.url)
if (config.method?.toLowerCase() === 'post') Object.assign(config.headers, fileUploadHeaders(axios.getUri(config)))
// 处理完毕,返回config对象
return config
@@ -82,6 +84,8 @@ const handleResponse = (response: AxiosResponse<any>) => {
* 添加 Axios 的响应拦截器,用于全局响应结果处理
*/
service.interceptors.response.use(handleResponse, (error) => {
// 超时和网络中断没有 response,保留原错误供页面展示和重试。
if (!error.response) return Promise.reject(error)
const status = Number(error.response.status) || 200
if (status === 423) {
return Promise.reject({ msg: '"演示环境,仅供预览"' })
+22 -14
View File
@@ -7,31 +7,33 @@
<div class="layout-padding-auto layout-padding-view">
<el-row>
<div class="mb8" style="width: 100%">
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add" type="primary">
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add"
type="primary">
{{ $t("common.addBtn") }}
</el-button>
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left" type="primary">
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left"
type="primary">
{{ $t("common.refreshCacheBtn") }}
</el-button>
</div>
</el-row>
<el-scrollbar>
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick" placeholder="请输入字典项或名称">
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick"
placeholder="请输入字典项或名称">
<template #default="{ data }">
<span class="custom-tree-node">
<span class="label">{{ data.description }}</span>
<span class="code">{{ data.dictType }}</span>
<span class="do">
<el-button-group>
<el-button icon="el-icon-edit" size="small" @click.stop="dicDialogRef.openDialog(data.id)"></el-button>
<el-tooltip :content="$t('sysdict.deleteDisabledTip')" :disabled="data.systemFlag === '0'" placement="top">
<el-button icon="el-icon-edit" size="small"
@click.stop="dicDialogRef.openDialog(data.id)"></el-button>
<el-tooltip :content="$t('sysdict.deleteDisabledTip')"
:disabled="data.systemFlag === '0'" placement="top">
<span style="margin-left: 12px">
<el-button
:disabled="data.systemFlag !== '0'"
icon="el-icon-delete"
size="small"
@click.stop="handleDelete([data.id])"
></el-button>
<el-button :disabled="data.systemFlag !== '0'"
icon="el-icon-delete" size="small"
@click.stop="handleDelete([data.id])"></el-button>
</span>
</el-tooltip>
</el-button-group>
@@ -41,9 +43,10 @@
</query-tree>
</el-scrollbar>
<el-footer style="height: 40px; line-height: 40px">
<el-button type="primary" size="small" icon="Download" style="width: 100%" @click="exportExcel">{{
$t("common.exportBtn")
}}</el-button>
<el-button type="primary" size="small" icon="Download" style="width: 100%"
@click="exportExcel">{{
$t("common.exportBtn")
}}</el-button>
</el-footer>
</div>
</pane>
@@ -130,6 +133,7 @@ const handleDelete = async (ids: string[]) => {
flex: 1;
height: 100%;
}
.custom-tree-node {
display: flex;
flex: 1;
@@ -139,16 +143,20 @@ const handleDelete = async (ids: string[]) => {
padding-right: 24px;
height: 100%;
}
.custom-tree-node .code {
font-size: 12px;
color: #999;
}
.custom-tree-node .do {
display: none;
}
.custom-tree-node:hover .code {
display: none;
}
.custom-tree-node:hover .do {
display: inline-block;
}
+46 -3
View File
@@ -29,6 +29,7 @@
v-model="state.queryForm.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<div class="log-export-time-tip">导出需选择完整时间范围,最多支持一个自然月</div>
</el-form-item>
</SearchPanel>
</el-row>
@@ -109,15 +110,23 @@
<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"></pagination>
<log-detail ref="LogDetailRef"></log-detail>
</div>
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
@failure="handleExportFailure" />
</div>
</template>
<script lang="ts" setup>
import { BasicTableProps, useTable } from "/@/hooks/table"
import { delObj, pageList } from "/@/api/admin/log"
import { delObj, pageList, submitLogExport } from "/@/api/admin/log"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useBatchExport } from "/@/hooks/batchExport"
import { useI18n } from "vue-i18n"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useDict } from "/@/hooks/dict"
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
dayjs.extend(customParseFormat)
const LogDetail = defineAsyncComponent(() => import("./detail.vue"))
const LogLineChart = defineAsyncComponent(() => import("./line-chart.vue"))
@@ -152,7 +161,6 @@ const state: BasicTableProps = reactive<BasicTableProps>({
// table hook
const {
downBlobFile,
getDataList,
currentChangeHandle: baseCurrentChangeHandle,
sortChangeHandle,
@@ -175,8 +183,36 @@ const resetQuery = () => {
}
// 导出excel
const { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:log",
taskName: "操作日志", fileName: "操作日志.xlsx", submitTask: submitLogExport,
})
const exportExcel = () => {
downBlobFile("/admin/log/export", state.queryForm, "操作日志.xlsx")
if (exportSubmitting.value) {
exportProgressRef.value?.show()
return
}
const range = state.queryForm.createTime
let error = ""
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
error = "导出前请选择完整的日志创建时间范围"
} else {
const start = dayjs(range[0], "YYYY-MM-DD HH:mm:ss", true)
const end = dayjs(range[1], "YYYY-MM-DD HH:mm:ss", true)
if (!start.isValid() || !end.isValid()) error = "请选择有效的日志创建时间范围"
else if (start.isAfter(end)) error = "日志创建时间的开始时间不能晚于结束时间"
else if (end.isAfter(start.add(1, "month"))) error = "日志导出时间范围不能超过一个月"
}
if (error) {
showSearch.value = true
useMessage().error(error)
return
}
return submitExport({
...state.queryForm,
createTime: range,
})
}
// 多选事件
@@ -214,6 +250,13 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.log-export-time-tip {
width: 100%;
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-secondary);
}
pre code.hljs {
width: 65%;
}
+1 -1
View File
@@ -15,7 +15,7 @@
匹配结果
<tip content="可点击敏感词加入白名单"/>
</template>
<div v-html="matchResult" @click="handleChildClick"/>
<div v-html="$fileHtml(matchResult)" @click="handleChildClick"/>
</el-form-item>
</el-col>
</el-row>
+11 -2
View File
@@ -58,11 +58,15 @@
@refreshDataList="getDataList"
/>
</div>
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig"
@success="handleExportSuccess" @failure="handleExportFailure" />
</div>
</template>
<script lang="ts" name="systemDept" setup>
import { downBlobFile } from "/@/utils/other"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { submitDeptExport } from "/@/api/admin/dept"
import { useBatchExport } from "/@/hooks/batchExport"
import { useI18n } from "vue-i18n"
import { useUserInfo } from "/@/stores/userInfo"
@@ -120,8 +124,13 @@ const handleExpand = () => {
/**
* 导出Excel
*/
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:dept",
taskName: "部门管理", fileName: "部门管理.xlsx", submitTask: submitDeptExport,
})
const exportExcel = () => {
downBlobFile("/admin/dept/export", state.queryForm, "部门管理.xlsx")
return submitExport(state.queryForm)
}
const getDataList = () => {
+27 -5
View File
@@ -12,7 +12,7 @@
</el-col>
<el-col :span="12" class="mb20">
<el-form-item :label="$t('sysuser.password')" prop="password">
<el-input clearable placeholder="请输入密码" type="password"
<el-input clearable placeholder="字母+数字,至少8位" type="password"
v-model="dataForm.password"></el-input>
</el-form-item>
</el-col>
@@ -170,11 +170,33 @@ const dataRules = ref({
},
],
password: [
{ required: true, message: "密码不能为空", trigger: "blur" },
{
min: 6,
max: 20,
message: "用户密码长度必须介于 5 和 20 之间",
validator: (_rule: any, value: any, callback: any) => {
// 编辑时占位 ****** 不校验;新增或修改了明文密码则校验
if (dataForm.userId && (!value || String(value).includes("******"))) {
return callback()
}
if (!value) {
return callback(new Error("密码不能为空"))
}
const pwd = String(value)
if (pwd.length < 8) {
return callback(new Error("密码长度不能少于8位"))
}
if (pwd.length > 20) {
return callback(new Error("密码长度不能超过20位"))
}
if (!/[A-Za-z]/.test(pwd) || !/[0-9]/.test(pwd)) {
return callback(new Error("密码必须同时包含字母和数字"))
}
if (dataForm.phone && pwd === String(dataForm.phone).trim()) {
return callback(new Error("密码不能与手机号相同"))
}
if (dataForm.username && pwd.toLowerCase() === String(dataForm.username).trim().toLowerCase()) {
return callback(new Error("密码不能与用户名相同"))
}
callback()
},
trigger: "blur",
},
],
+42 -8
View File
@@ -75,6 +75,19 @@
>
审批
</el-button>
<!-- 线上已执行完,暂时注释入口
<el-button
plain
v-auth="'sys_user_edit'"
class="ml10"
icon="Key"
type="warning"
:loading="fixWeakPwdLoading"
@click="openFixWeakPwdDialog"
>
修复弱口令
</el-button>
-->
<div class="flex justify-between items-center">
<el-tabs class="flex-1" v-model="activeName" @tab-click="handleClickTabItem">
<el-tab-pane v-for="v in tabItems" :key="v.id" :label="v.label" :name="v.value" />
@@ -224,10 +237,26 @@
<!-- 批量审批对话框 -->
<batch-deal ref="batchDealRef" @task-submit-event="taskSubmitEvent" />
<!-- 线上已执行完,暂时注释修复弱口令入口
<el-dialog v-model="fixWeakPwdVisible" title="修复弱口令(密码=手机号)" width="480px" destroy-on-close>
...
</el-dialog>
<el-dialog v-model="fixWeakPwdResultVisible" title="弱口令修复结果" width="640px" destroy-on-close>
...
</el-dialog>
<async-task-progress-component ref="fixWeakPwdProgressRef" :config="fixWeakPwdTaskConfig" @success="handleFixWeakPwdSuccess" @failure="handleFixWeakPwdFailure" />
-->
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
</div>
</template>
<script lang="ts" name="systemUser" setup>
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useBatchExport } from "/@/hooks/batchExport"
import { submitUserExport as submitBatchExport } from "/@/api/admin/user"
import { delObj, pageList, putObj } from "/@/api/admin/user"
import { putGlobalFace } from "/@/api/admin/userFace"
import { BasicTableProps, useTable } from "/@/hooks/table"
@@ -274,7 +303,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
},
pageList: pageList,
})
const { getDataList, currentChangeHandle, sizeChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
const { user_staffing_type } = useDict("user_staffing_type")
@@ -325,14 +354,17 @@ const taskSubmitEvent = () => {
}
// 导出excel
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:user",
taskName: "用户管理", fileName: "用户管理.xlsx", submitTask: submitBatchExport,
})
const exportExcel = () => {
const _data = { ...state.queryForm }
if (_data.role && !Array.isArray(_data.role)) {
_data.role = [_data.role]
} else {
_data.role = []
}
postDownBlobFile("/admin/user/export", Object.assign(_data, { ids: selectObjs.value }), "用户管理.xlsx")
const query = { ...state.queryForm }
// 保留原导出角色参数的转换规则及勾选元数据。
query.role = query.role && !Array.isArray(query.role) ? [query.role] : []
return submitExport({ ...query, ids: [...selectObjs.value] })
}
// 是否可以多选
@@ -394,4 +426,6 @@ const changePassword = async (row: any) => {
useMessage().success(t("common.optSuccessText"))
getDataList()
}
// 线上已执行完,暂时注释修复弱口令入口相关逻辑(按钮/弹窗/异步进度)
</script>
@@ -1,5 +1,5 @@
<template>
<el-image :style="styles" v-bind="props" :src="src.includes('http') ? src : baseURL + src">
<file-image :style="styles" v-bind="props" :src="$fileUrl(src)" @error="$fileRetry(src)">
<template #placeholder>
<div class="image-slot"></div>
</template>
@@ -8,7 +8,7 @@
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</file-image>
</template>
<script lang="ts" setup>
@@ -3,7 +3,7 @@
<div class="flex items-center news-title mx-[10px] my-[15px] text-[17px] font-medium">最新资讯</div>
<div v-for="item in newsList" :key="item.id" class="news-card flex bg-white px-[10px] py-[16px] text-[#333] border-[#f2f2f2] border-b">
<div class="mr-[10px]" v-if="item.image">
<img :src="item.image.includes('http') ? item.image : baseURL + item.image" class="w-[120px] h-[90px]" />
<img :src="$fileUrl(item.image)" class="w-[120px] h-[90px]" @error="$fileRetry(item.image)" />
</div>
<div class="flex flex-col justify-between flex-1">
<div class="text-[15px] font-medium line-clamp-2">{{ item.title }}</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="pages-preview mx-[30px]">
<div class="flex tabbar dark:bg-gray-700">
<div class="flex flex-col items-center justify-center flex-1 tabbar-item" v-for="(item, index) in tabbar.list" :key="index">
<img class="w-[22px] h-[22px]" :src="item.unselected.includes('http') ? item.unselected: baseURL + item.unselected" alt="" />
<img class="w-[22px] h-[22px]" :src="$fileUrl(item.unselected)" alt="" @error="$fileRetry(item.unselected)" />
<div class="leading-3 text-[12px] mt-[4px] dark:text-gray-300">{{ item.name }}</div>
</div>
</div>
+2 -2
View File
@@ -75,10 +75,10 @@
</div>
<div v-if="scope.row.repType === 'text'">{{ scope.row.repContent }}</div>
<div v-if="scope.row.repType === 'image'">
<a target="_blank" :href="scope.row.repUrl"><img :src="scope.row.repUrl" style="width: 100px" /></a>
<a target="_blank" :href="$fileUrl(scope.row.repUrl)"><img :src="$fileUrl(scope.row.repUrl)" style="width: 100px" @error="$fileRetry(scope.row.repUrl)" /></a>
</div>
<div v-if="['video', 'voice', 'link', 'shortvideo'].includes(scope.row.repType)">
<el-tag>链接</el-tag>:<a :href="scope.row.repUrl" target="_blank">{{ scope.row.repName }}</a>
<el-tag>链接</el-tag>:<a :href="$fileUrl(scope.row.repUrl)" target="_blank">{{ scope.row.repName }}</a>
</div>
</template>
</el-table-column>
@@ -13,7 +13,7 @@
<div v-for="(news, index) in articlesAdd" :key="news.id">
<div v-if="index == 0" class="news-main father" :class="{ activeAddNews: isActiveAddNews === index }" @click="activeNews(index)">
<div class="news-content">
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" />
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" @error="$fileRetry(news.thumbUrl)" />
<div class="news-content-title">{{ news.title }}</div>
</div>
<div v-if="articlesAdd.length > 1" class="child">
@@ -25,7 +25,7 @@
<div class="news-content-item">
<div class="news-content-item-title">{{ news.title }}</div>
<div class="news-content-item-img">
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" height="100%" />
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
</div>
</div>
<div class="child">
@@ -52,9 +52,9 @@
<img
v-if="articlesAdd[isActiveAddNews].thumbUrl"
class="material-img"
:src="articlesAdd[isActiveAddNews].thumbUrl"
:src="$fileUrl(articlesAdd[isActiveAddNews].thumbUrl)"
:class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
/>
@error="$fileRetry(articlesAdd[isActiveAddNews].thumbUrl)" />
<i v-else class="el-icon-plus avatar-uploader-icon" :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"></i>
<div class="thumb-but">
<wx-file-upload :uploadData="uploadData" @success="handleImageChange"></wx-file-upload>
+2 -2
View File
@@ -22,8 +22,8 @@
</div>
<div v-loading="state.loading" class="waterfall">
<div v-for="item in state.dataList" :key="item.id" class="waterfall-item">
<a target="_blank" :href="item.url">
<img class="material-img" :src="item.url" />
<a target="_blank" :href="$fileUrl(item.url)">
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
<div class="item-name">{{ item.name }}</div>
</a>
<el-row class="ope-row">
@@ -42,7 +42,7 @@
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
@@ -56,7 +56,7 @@
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
@@ -70,7 +70,7 @@
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
+1 -1
View File
@@ -29,7 +29,7 @@
>
<CircleClose />
</el-icon>
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
+3 -3
View File
@@ -41,7 +41,7 @@
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
@@ -55,7 +55,7 @@
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
@@ -69,7 +69,7 @@
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
<div class="food_list">
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
<div class="w-full flex justify-around items-center text-black mt-2">
<span>{{ item.name }}</span>
<span class="mx-1">|</span>
+2 -2
View File
@@ -41,8 +41,8 @@
<div class="w-full flex flex-wrap items-center" v-for="(hourType, index) in foodData" :key="index">
<div v-for="dish in hourType" :key="dish.id" class="p-2" :style="cardStyle">
<div class="relative overflow-hidden rounded-tl rounded-tr" :style="foodCardStyle">
<img :src="imageBaseUrl + dish.picUrl" :alt="dish.name"
class="h-full w-full object-cover" />
<img :src="$fileUrl(imageBaseUrl + dish.picUrl)" :alt="dish.name"
class="h-full w-full object-cover" @error="$fileRetry(imageBaseUrl + dish.picUrl)" />
<div class="ribbon">午餐</div>
<div class="absolute left-1 top-1">
<star-medal
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div>
<div v-if="form.perm === 'R'" v-html="form.props.value" />
<div v-if="form.perm === 'R'" v-html="$fileHtml(form.props.value)" />
<editor v-else v-model:get-html="form.props.value" :placeholder="form.placeholder" />
</div>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div v-if="form.perm === 'R'">
<img :src="form.props.value" />
<img :src="$fileUrl(form.props.value)" @error="$fileRetry(form.props.value)" />
</div>
<div v-else>
<sign
@@ -734,10 +734,10 @@
class="flex min-h-[180px] gap-4 rounded-[6px] border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-blank)] p-3">
<div
class="flex h-[156px] w-40 flex-none items-center justify-center overflow-hidden rounded bg-[var(--el-fill-color-light)]">
<el-image
<file-image
v-if="evidence.status === 'AVAILABLE' && evidence.imageUrl && !failedEvidenceImages.has(evidence.id)"
class="h-full w-full" :src="evidenceImageUrl(evidence.imageUrl)"
:preview-src-list="[evidenceImageUrl(evidence.imageUrl)]" fit="contain"
class="h-full w-full" :src="$fileUrl(evidenceImageUrl(evidence.imageUrl))"
:preview-src-list="$fileUrls([evidenceImageUrl(evidence.imageUrl)])" fit="contain"
preview-teleported loading="lazy"
@error="markEvidenceImageFailed(evidence.id)" />
<span v-else-if="evidence.status === 'PENDING'"
+30 -21
View File
@@ -16,9 +16,13 @@
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList"
placeholder="请选择钱包类型" />
</el-form-item>
<el-form-item style="width: 400px" label="时间" prop="createTimeList">
<el-form-item style="width: 400px" label="交易时间" prop="createTimeList">
<template #label>
<statistics-table-header label="交易时间" :tip="transactionTimeTip" />
</template>
<el-date-picker v-model="state.queryForm.createTimeList"
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange" />
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange"
start-placeholder="开始时间(含)" end-placeholder="结束时间(不含)" />
</el-form-item>
<el-form-item>
<el-button icon="Search" type="primary" @click="getDataList">{{ $t("common.queryBtn")
@@ -197,13 +201,18 @@
</template>
</DraggableDepTree>
</div>
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
</div>
</template>
<script setup lang="ts" name="hard-dept-total">
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useBatchExport } from "/@/hooks/batchExport"
import { submitDepartmentExport as submitBatchExport } from "/@/api/hard/hard-total"
import { BasicTableProps, useTable } from "/@/hooks/table"
import { displayPrice } from "/@/utils/formatMoney"
import { useDict } from "/@/hooks/dict"
import * as hardTotalApi from "/@/api/hard/hard-total"
import dayjs from "dayjs"
@@ -215,8 +224,7 @@ const selectObjs = ref([]) as any
const multiple = ref(true)
const draggableDepTreeRef = ref() // 树组件ref引用
/** 部门列说明每行数据的统计范围。 */
const departmentHeaderTip = "每行显示一个部门,后面的金额和次数都是该部门在查询时间内的数据。"
const transactionTimeTip = "按钱包流水的交易时间统计,包含开始时间,不包含结束时间。例如,统计 9 月可选择 9 月 1 日 00:00:00 到 10 月 1 日 00:00:00。"
/** 批量充值金额说明批量入账金额的计算方式。 */
const batchRechargeAmountHeaderTip = "通过批量充值给该部门人员增加的金额合计。"
/** 批量充值次数说明按人员入账记录计次。 */
@@ -237,18 +245,15 @@ const singleDeductionCountHeaderTip = "单次扣减产生的扣款记录数,
const returnCanteenAmountHeaderTip = "批量退还余额时,退回该部门人员各自所属食堂的金额合计。"
/** 退还食堂次数说明按人员退还记录计次。 */
const returnCanteenCountHeaderTip = "批量退还余额产生的记录数;同一次操作涉及多人时,每个人的退还记录各算 1 次。"
/** 流水金额说明支付订单原始金额的统计范围。 */
const payAmountHeaderTip = "查询时间内创建且已支付的订单金额合计;退款金额另行显示,不会从这里扣除。"
/** 流水次数说明已支付订单的计次方式。 */
const payCountHeaderTip = "查询时间内创建且已支付的订单数量,同一笔订单算 1 次。"
/** 退款金额说明按退款完成时间统计。 */
const refundAmountHeaderTip = "查询时间内已经完成退款的金额合计,按退款完成时间统计。"
/** 退款次数说明按已完成退款记录计次。 */
const refundCountHeaderTip = "查询时间内已经完成的退款记录数;一笔订单有多次退款时,每次完成的退款各算 1 次。"
/** 支付和退款都按钱包流水的交易时间统计。 */
const payAmountHeaderTip = "所选交易时间内的钱包支付流水金额合计;退款金额单独显示,不会从这里扣除。"
const payCountHeaderTip = "所选交易时间内的钱包支付流水记录数,每条流水算 1 次;同一订单使用多个钱包支付时分别计次。"
const refundAmountHeaderTip = "所选交易时间内的钱包退款流水金额合计,包含非订单退款。"
const refundCountHeaderTip = "所选交易时间内的钱包退款流水记录数,每条流水算 1 次,包含非订单退款。"
/** 消费净金额说明支付与退款的相减关系。 */
const netAmountHeaderTip = "流水金额减去退款金额。订单按创建时间统计,退款按完成时间统计;只选到退款的时间段时,结果可能为负数。"
/** 消费净次数说明支付订单与退款记录的相减关系。 */
const netCountHeaderTip = "流水次数减去退款次数。退款次数按退款记录计算,一笔订单多次退款会分别扣减。"
const netAmountHeaderTip = "流水金额减去退款金额。支付和退款分别按各自钱包流水的交易时间统计,结果可能为负数。"
/** 消费净次数说明支付流水与退款流水的相减关系。 */
const netCountHeaderTip = "钱包支付流水次数减去钱包退款流水次数,每条流水分别计次,结果可能为负数。"
const defaultCreateTimeList = [
dayjs().subtract(3, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
@@ -264,14 +269,18 @@ const state: BasicTableProps = reactive<BasicTableProps>({
})
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
const { getDataList, sortChangeHandle, tableStyle } = useTable(state)
// 导出excel
const exportExcel = () => {
postDownBlobFile("/hard/hard-total/export-dept", Object.assign(state.queryForm), "部门交易记录导出.xlsx")
}
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:department",
taskName: "部门交易记录导出", fileName: "部门交易记录导出.xlsx", submitTask: submitBatchExport,
})
const cellClassName = ({ row, column, rowIndex }: any) => {
const exportExcel = () => submitExport({ ...state.queryForm })
const cellClassName = ({ column }: any) => {
if (column.property) {
if (column.property.includes("Count")) {
return 'success-cell'
@@ -0,0 +1,342 @@
<template>
<el-drawer :model-value="modelValue" title="订单统计" direction="rtl" size="min(1120px, 100vw)"
class="order-statistics-drawer" append-to-body :close-on-click-modal="false"
@opened="resizeCharts" @update:model-value="changeVisible">
<div class="statistics-heading">
<div>
<div>当前筛选条件的全部订单</div>
<div class="statistics-muted">{{ rangeDescription }}</div>
</div>
<el-button icon="Refresh" :loading="loading" @click="refresh">重新统计</el-button>
</div>
<el-tabs v-model="activeTab" @tab-change="changeTab">
<el-tab-pane label="概览" name="overview">
<div v-loading="overviewLoading" class="statistics-content">
<el-result v-if="overviewError" icon="error" title="统计失败" :sub-title="overviewError">
<template #extra><el-button type="primary" @click="loadOverview">重试</el-button></template>
</el-result>
<template v-else-if="overview">
<div class="statistics-updated">统计生成时间:{{ overview.generatedAt }}</div>
<div class="statistics-cards">
<div class="statistics-card">
<div class="statistics-muted">订单数</div>
<div class="statistics-value">{{ formatStatisticsCount(overview.summary.orderCount) }}<small>笔</small></div>
</div>
<div v-for="metric in moneyMetrics" :key="metric.key" class="statistics-card">
<div class="statistics-muted">{{ metric.label }}</div>
<div class="statistics-value">{{ formatStatisticsMoney(overview.summary[metric.key]) }}<small>元</small></div>
</div>
</div>
<p class="statistics-muted statistics-note">按订单原始金额汇总,不额外排除订单状态;净额=实际支付-已退款金额。</p>
<el-empty v-if="overview.summary.orderCount === '0'" description="当前条件下暂无订单" />
<template v-else>
<el-alert v-if="overview.withoutPayTime.orderCount !== '0'" type="info" :closable="false" show-icon
:title="`无支付时间的订单共 ${formatStatisticsCount(overview.withoutPayTime.orderCount)} 笔,实际支付金额 ${formatStatisticsMoney(overview.withoutPayTime.payPrice)} 元;已计入总汇总,未归入按日趋势。`" />
<el-card shadow="never" class="statistics-section">
<template #header>按日支付趋势</template>
<p v-if="overview.daily.length === 1" class="statistics-muted statistics-chart-note">仅 {{ overview.daily[0].date }} 有支付时间记录,以下展示当日金额与订单数。</p>
<v-chart v-if="overview.daily.length && activeTab === 'overview'" ref="trendChart" class="statistics-chart"
:style="{ height: overview.daily.length === 1 ? '240px' : '320px' }" :option="trendOption" autoresize />
<el-empty v-else-if="!overview.daily.length" description="当前订单均无支付时间" />
</el-card>
<el-card shadow="never" class="statistics-section">
<template #header>支付方式占比<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
<div class="statistics-payment">
<v-chart v-if="hasPositivePayment && activeTab === 'overview'" ref="paymentChart" class="statistics-chart" :option="paymentOption" autoresize />
<el-empty v-else-if="!hasPositivePayment" description="暂无正向支付金额" />
<el-table :data="overview.payTypes" border max-height="320">
<el-table-column label="支付方式" min-width="150">
<template #default="{ row }">{{ payTypeName(row.payType) }}</template>
</el-table-column>
<el-table-column label="订单数" min-width="100" align="right">
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
</el-table-column>
<el-table-column label="实际支付(元)" min-width="160" align="right">
<template #default="{ row }">{{ formatStatisticsMoney(row.payPrice) }}</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-collapse class="statistics-section">
<el-collapse-item title="每日金额明细(元)" name="daily">
<el-table :data="overview.daily" border max-height="360">
<el-table-column prop="date" label="支付日期" width="120" fixed />
<el-table-column label="订单数" width="100" align="right">
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
</el-table-column>
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="metric.label" min-width="140" align="right">
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</template>
</div>
</el-tab-pane>
<el-tab-pane label="商户消费" name="merchants" :disabled="overviewLoading">
<div v-loading="merchantLoading" class="statistics-content">
<el-result v-if="merchantError" icon="error" title="商户统计失败" :sub-title="merchantError">
<template #extra><el-button type="primary" @click="loadMerchants(requestedPage)">重试</el-button></template>
</el-result>
<template v-else-if="merchantPage">
<div class="statistics-updated">统计生成时间:{{ merchantPage.generatedAt }}</div>
<el-empty v-if="!merchantPage.records.length && merchantPage.current === 1" description="当前条件下暂无商户消费" />
<template v-else>
<el-card v-if="ranking.length" shadow="never" class="statistics-section">
<template #header>商户消费前十名<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
<p class="statistics-muted statistics-chart-note">展示 {{ ranking.length }} 个商户<span v-if="ranking.every(row => Number(row.payPrice) === 0)">,实际支付金额均为 0 元</span></p>
<v-chart v-if="activeTab === 'merchants'" ref="merchantChart" class="statistics-chart"
:style="{ height: `${Math.max(120, ranking.length * 34 + 58)}px` }" :option="merchantOption" autoresize />
</el-card>
<el-table :data="merchantPage.records" border max-height="440">
<el-table-column label="商户" min-width="210" fixed show-overflow-tooltip>
<template #default="{ row }">
<div>{{ row.merchantName }}</div>
<small v-if="row.merchantId" class="statistics-muted">{{ row.merchantId }}</small>
</template>
</el-table-column>
<el-table-column label="订单数" min-width="100" align="right">
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
</el-table-column>
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="`${metric.label}(元)`" min-width="155" align="right">
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
</el-table-column>
</el-table>
<div class="statistics-pagination">
<el-button :disabled="merchantLoading || merchantPage.current <= 1" @click="loadMerchants(merchantPage.current - 1)">上一页</el-button>
<span>第 {{ merchantPage.current }} 页 · 每页 {{ merchantPage.size }} 条</span>
<el-button :disabled="merchantLoading || !merchantPage.hasMore" @click="loadMerchants(merchantPage.current + 1)">下一页</el-button>
</div>
</template>
</template>
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
</template>
<script setup lang="ts" name="hardOrderStatisticsDrawer">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import VChart from "vue-echarts"
import { use } from "echarts/core"
import { BarChart, LineChart, PieChart } from "echarts/charts"
import { AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components"
import { CanvasRenderer } from "echarts/renderers"
import type { EChartsOption } from "echarts"
import { merchantStatistics, statistics } from "/@/api/hard/hard-order"
import type { HardOrderMerchantStatistics, HardOrderMerchantStatisticsPage, HardOrderStatisticsData, HardOrderStatisticsQuery, HardOrderStatisticsSummary } from "/@/api/hard/hard-order"
import { useDict } from "/@/hooks/dict"
import { escapeChartText, formatStatisticsCount, formatStatisticsMoney } from "./utils"
use([BarChart, LineChart, PieChart, AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
const props = defineProps<{ modelValue: boolean; query: HardOrderStatisticsQuery }>()
const emit = defineEmits<{ (event: "update:modelValue", value: boolean): void }>()
const { hard_pay_type } = useDict("hard_pay_type")
const activeTab = ref("overview")
const overview = ref<HardOrderStatisticsData>()
const merchantPage = ref<HardOrderMerchantStatisticsPage>()
const ranking = ref<HardOrderMerchantStatistics[]>([])
const overviewLoading = ref(false)
const merchantLoading = ref(false)
const overviewError = ref("")
const merchantError = ref("")
const requestedPage = ref(1)
const trendChart = ref<InstanceType<typeof VChart>>()
const paymentChart = ref<InstanceType<typeof VChart>>()
const merchantChart = ref<InstanceType<typeof VChart>>()
const loading = computed(() => overviewLoading.value || merchantLoading.value)
let alive = true
let overviewRequest = 0
let merchantRequest = 0
type MoneyKey = Exclude<keyof HardOrderStatisticsSummary, "orderCount">
const moneyMetrics: { key: MoneyKey; label: string }[] = [
{ key: "totalPrice", label: "商品总价" }, { key: "extraPrice", label: "额外支付" },
{ key: "couponPrice", label: "优惠金额" }, { key: "payPrice", label: "实际支付" },
{ key: "refundPrice", label: "已退款金额" }, { key: "netPrice", label: "净额" },
]
const rangeDescription = computed(() => [
["createTimeList", "创建时间"], ["payTimeList", "支付时间"],
].flatMap(([key, label]) => {
const value = props.query[key]
return Array.isArray(value) && value.length === 2 ? [`${label}:${value[0]} 至 ${value[1]}`] : []
}).join(";"))
function payTypeName(code: string | null) {
return hard_pay_type.value.find((item: { value: string; label: string }) => String(item.value) === code)?.label
|| (code ? `未知方式(${code})` : "未记录支付方式")
}
function errorMessage(error: unknown) {
const detail = error as { code?: string; msg?: string; message?: string }
if (detail?.code === "ECONNABORTED" || detail?.code === "ETIMEDOUT") return "统计查询超时,请缩小查询范围后重试"
return detail?.msg || detail?.message || "统计查询失败,请重试或缩小查询范围"
}
async function loadOverview() {
if (overviewLoading.value) return
const request = ++overviewRequest
overviewLoading.value = true
overviewError.value = ""
try {
const response = await statistics(props.query)
if (alive && request === overviewRequest) overview.value = response.data
} catch (error) {
if (alive && request === overviewRequest) overviewError.value = errorMessage(error)
} finally {
if (alive && request === overviewRequest) overviewLoading.value = false
}
}
async function loadMerchants(current = 1) {
if (merchantLoading.value) return
const request = ++merchantRequest
requestedPage.value = current
merchantLoading.value = true
merchantError.value = ""
try {
const response = await merchantStatistics(props.query, current)
if (alive && request === merchantRequest) {
merchantPage.value = response.data
if (current === 1) ranking.value = response.data.records.slice(0, 10)
}
} catch (error) {
if (alive && request === merchantRequest) merchantError.value = errorMessage(error)
} finally {
if (alive && request === merchantRequest) merchantLoading.value = false
}
}
function changeTab(name: string | number) {
if (name === "merchants" && !merchantPage.value && !merchantError.value) void loadMerchants()
}
function refresh() {
if (loading.value) return
if (activeTab.value === "overview") void loadOverview()
else void loadMerchants(1)
}
function changeVisible(value: boolean) {
if (!value) {
alive = false
overviewRequest++
merchantRequest++
}
emit("update:modelValue", value)
}
async function resizeCharts() {
await nextTick()
if (!alive || !props.modelValue) return
const charts = activeTab.value === "overview" ? [trendChart.value, paymentChart.value] : [merchantChart.value]
charts.forEach(chart => chart?.resize())
}
const trendOption = computed<EChartsOption>(() => {
const rows = overview.value?.daily ?? []
const singleDay = rows.length === 1
const hasZoom = rows.length > 14
return {
aria: { enabled: true },
color: ["#409eff", "#67c23a"],
legend: { data: ["实际支付", "订单数"] },
grid: { left: 12, right: 12, top: 48, bottom: hasZoom ? 52 : 12, containLabel: true },
tooltip: {
trigger: "axis",
formatter: (params: any) => {
const row = rows[params[0]?.dataIndex]
return row ? `${escapeChartText(row.date)}<br/>实际支付:${formatStatisticsMoney(row.payPrice)} 元<br/>订单数:${formatStatisticsCount(row.orderCount)} 笔` : ""
},
},
xAxis: singleDay ? { type: "category", data: rows.map(row => row.date) }
: { type: "time", minInterval: 24 * 60 * 60 * 1000, axisLabel: { formatter: "{MM}-{dd}", hideOverlap: true } },
yAxis: [{ type: "value", name: "金额(元)" }, { type: "value", name: "订单数", minInterval: 1 }],
dataZoom: hasZoom ? [{ type: "inside" }, { type: "slider", height: 18, bottom: 8 }] : [],
series: [
{
name: "实际支付", type: singleDay ? "bar" : "line", barMaxWidth: 36,
data: rows.map(row => singleDay ? Number(row.payPrice) / 100 : [row.date, Number(row.payPrice) / 100]),
showSymbol: rows.length < 32, symbolSize: 7,
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsMoney(rows[0]?.payPrice)} 元` },
},
{
name: "订单数", type: singleDay ? "bar" : "line", yAxisIndex: 1, barMaxWidth: 36,
lineStyle: { type: "dashed" }, data: rows.map(row => singleDay ? Number(row.orderCount) : [row.date, Number(row.orderCount)]),
showSymbol: rows.length < 32, symbolSize: 7,
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsCount(rows[0]?.orderCount ?? '0')} 笔` },
},
],
}
})
const hasPositivePayment = computed(() => overview.value?.payTypes.some(row => Number(row.payPrice) > 0))
const paymentOption = computed<EChartsOption>(() => ({
aria: { enabled: true },
tooltip: {
trigger: "item",
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元<br/>订单数:${formatStatisticsCount(params.data.orderCount)} 笔<br/>占比:${params.percent}%`,
},
legend: { type: "scroll", bottom: 0 },
series: [{
type: "pie", radius: ["40%", "65%"], center: ["50%", "44%"],
label: { formatter: "{b}\n{d}%" },
data: (overview.value?.payTypes ?? []).filter(row => Number(row.payPrice) > 0).map(row => ({
name: payTypeName(row.payType), value: Number(row.payPrice) / 100, rawAmount: row.payPrice, orderCount: row.orderCount,
})),
}],
}))
const merchantOption = computed<EChartsOption>(() => ({
aria: { enabled: true },
grid: { left: 12, right: 32, top: 12, bottom: 12, containLabel: true },
tooltip: {
trigger: "item",
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元`,
},
color: ["#409eff"],
// 商户名称可能相同,以位置作为类目并在标签中显示名称,避免同名商户合并。
xAxis: { type: "value", name: "元" },
yAxis: { type: "category", inverse: true, data: ranking.value.map((_, index) => String(index)), axisLabel: {
width: 120, overflow: "truncate", formatter: (value: string) => ranking.value[Number(value)]?.merchantName ?? "",
} },
series: [{ type: "bar", barMaxWidth: 26, data: ranking.value.map((row, index) => ({
name: row.merchantName, value: [Number(row.payPrice) / 100, index], rawAmount: row.payPrice,
label: Number(row.payPrice) === 0 ? { show: true, position: "right", formatter: "0.00" } : { show: false },
})), encode: { x: 0, y: 1 } }],
}))
// 只在当前标签可见时挂载图表;抽屉动画结束、数据更新与切换标签后重新测量容器。
watch([activeTab, overview, merchantPage], resizeCharts, { flush: "post" })
onMounted(() => void loadOverview())
onBeforeUnmount(() => { alive = false; overviewRequest++; merchantRequest++ })
</script>
<style scoped lang="scss">
:global(.order-statistics-drawer .el-drawer__header) { margin-bottom: 0; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
:global(.order-statistics-drawer .el-drawer__body) { min-width: 0; overflow: auto; }
.statistics-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.statistics-muted { color: var(--el-text-color-secondary); font-size: 13px; }
.statistics-heading .statistics-muted { margin-top: 6px; line-height: 1.6; }
.statistics-content { min-height: 220px; }
.statistics-updated { margin-bottom: 14px; text-align: right; color: var(--el-text-color-secondary); font-size: 12px; }
.statistics-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.statistics-card { padding: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-blank); }
.statistics-value { margin-top: 10px; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.statistics-value small { margin-left: 6px; font-size: 12px; font-weight: normal; color: var(--el-text-color-secondary); }
.statistics-note { margin: 12px 0 18px; }
.statistics-section { margin-top: 16px; }
.statistics-caption { margin-left: 12px; }
.statistics-chart { width: 100%; height: 320px; }
.statistics-chart-note { margin: 0 0 12px; }
.statistics-payment { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 20px; }
.statistics-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 16px; margin-top: 16px; }
@media (max-width: 900px) {
.statistics-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.statistics-payment { grid-template-columns: minmax(0, 1fr); }
.statistics-heading { align-items: flex-start; }
}
</style>
@@ -0,0 +1,42 @@
import dayjs from "dayjs"
import type { HardOrderStatisticsQuery } from "/@/api/hard/hard-order"
const ignoredFields = new Set(["current", "size", "descs", "ascs", "ids", "selectObjs", "tenantId", "mchTypeCodes"])
/** 在点击时复制表单,避免分页、导出勾选或后续表单编辑污染统计。 */
export function snapshotStatisticsQuery(form: HardOrderStatisticsQuery): HardOrderStatisticsQuery {
const query = JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(form).filter(([key]) => !ignoredFields.has(key)))))
for (const [key, label] of [["createTimeList", "创建时间"], ["payTimeList", "支付时间"]]) {
const range = query[key]
if (range == null || (Array.isArray(range) && range.length === 0)) continue
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
throw new Error(`${label}需填写完整`)
}
const start = dayjs(range[0])
const end = dayjs(range[1])
if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
throw new Error(`${label}开始时间不能晚于结束时间`)
}
}
return query
}
/** 直接移动整数分的小数点;卡片、表格和提示均不先转 Number。 */
export function formatStatisticsMoney(value: string | number | null | undefined): string {
const raw = String(value ?? "0")
if (!/^-?\d+$/.test(raw)) return "0.00"
const negative = raw.startsWith("-")
const digits = raw.replace(/^-/, "").replace(/^0+(?=\d)/, "").padStart(3, "0")
const yuan = digits.slice(0, -2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
return `${negative && /[1-9]/.test(digits) ? "-" : ""}${yuan}.${digits.slice(-2)}`
}
export function formatStatisticsCount(value: string | number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
export function escapeChartText(value: unknown): string {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[character]!))
}
+44 -5
View File
@@ -34,6 +34,13 @@
<dict-select v-model="state.queryForm.orderPayTypeCodes" dict-code="hard_pay_type"
multiple />
</el-form-item>
<el-form-item style="width: 250px" label="是否有额外支付" prop="hasExtraPrice">
<el-select v-model="state.queryForm.hasExtraPrice" placeholder="请选择" clearable
@clear="state.queryForm.hasExtraPrice = null">
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item style="width: 250px" label="订单状态" prop="orderStatus">
<dict-select v-model="state.queryForm.orderStatus" dict-code="hard_order_status" />
</el-form-item>
@@ -67,7 +74,12 @@
</el-tabs>
<right-toolbar v-model:showSearch="showSearch" :export="'sys_audit_export'"
@exportExcel="exportExcel" class="ml10" style="float: right; margin-right: 20px"
@queryTable="getDataList" />
@queryTable="getDataList">
<el-tooltip content="统计" placement="top">
<el-button icon="DataLine" circle aria-label="统计" v-auth="'hard_hardwareOrder_view'"
:disabled="statisticsVisible" @click="openStatistics" />
</el-tooltip>
</right-toolbar>
</div>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
@@ -271,10 +283,18 @@
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<admin-refund-dialog ref="adminRefundDailogRef" @refresh="getDataList(false)" />
<shop-car ref="shopCarDialogRef" />
<order-statistics-drawer v-if="statisticsVisible" v-model="statisticsVisible" :query="statisticsQuery" />
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
@failure="handleExportFailure" />
</div>
</template>
<script setup lang="ts" name="systemHardwareOrder">
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useBatchExport } from "/@/hooks/batchExport"
import { submitExport as submitBatchExport } from "/@/api/hard/hard-order"
import { BasicTableProps, useTable } from "/@/hooks/table"
import * as hardOrder from "/@/api/hard/hard-order"
import * as hardInvoice from "/@/api/hard/hard-invoice"
@@ -285,6 +305,7 @@ import { displayPrice } from "/@/utils/formatMoney"
import { ElLoading, TableColumnCtx } from "element-plus"
import { VNode } from "vue"
import dayjs from "dayjs"
import { snapshotStatisticsQuery } from "./components/statistics-drawer/utils"
const { push } = useRouter()
@@ -293,6 +314,7 @@ const FormDialog = defineAsyncComponent(() => import("./form.vue"))
const AdminRefundDialog = defineAsyncComponent(() => import("./components/admin-refund-dialog/index.vue"))
const ShopCar = defineAsyncComponent(() => import("../components/shop-car.vue"))
const TransactionList = defineAsyncComponent(() => import("/@/views/wallet/components/transaction-list.vue"))
const OrderStatisticsDrawer = defineAsyncComponent(() => import("./components/statistics-drawer/index.vue"))
// 定义查询字典
const { hard_pay_type, hard_order_type, hard_order_status, hard_type, hard_refund_status, daily_menu, wallet_type } = useDict(
"hard_pay_type",
@@ -315,6 +337,18 @@ const draggableDepTreeRef = ref()
// 搜索变量
const queryRef = ref()
const showSearch = ref(true)
const statisticsVisible = ref(false)
const statisticsQuery = ref<hardOrder.HardOrderStatisticsQuery>({})
const openStatistics = () => {
if (statisticsVisible.value) return
try {
statisticsQuery.value = snapshotStatisticsQuery(state.queryForm)
statisticsVisible.value = true
} catch (error) {
useMessage().warning((error as Error).message)
}
}
// 多选变量
const selectObjs = ref([]) as any
@@ -328,12 +362,13 @@ const defaultCreateTimeList = [
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
createTimeList: defaultCreateTimeList,
hasExtraPrice: null,
},
pageList: hardOrder.pageJoin,
})
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
onMounted(async () => {
const { data } = await getDicts("hard_order_type")
@@ -386,9 +421,13 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
}
// 导出excel
const exportExcel = () => {
downBlobFile("/hard/hard-order/export", Object.assign(state.queryForm, { ids: selectObjs }), "订单记录.xlsx")
}
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:order",
taskName: "订单记录", fileName: "订单记录.xlsx", submitTask: submitBatchExport,
})
const exportExcel = () => submitExport({ ...state.queryForm, ids: [...selectObjs.value] })
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
@@ -5,7 +5,11 @@
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle">
<el-table-column type="selection" width="80" align="center" />
<el-table-column type="index" label="#" width="50" />
<el-table-column label="商户" width="200" align="center" prop="merchantName" />
<el-table-column label="商户" width="200" align="center" prop="merchantName">
<template #default="{ row }">
{{ row.merchantName || (row.merchantId == null ? "未归属商户" : row.merchantId) }}
</template>
</el-table-column>
<el-table-column label="流水金额" min-width="120" align="center" prop="totalPayPrice">
<template #header>
<statistics-table-header label="流水金额" :tip="payAmountHeaderTip" />
@@ -76,15 +80,23 @@
</el-table-column>
<el-table-column label="操作" fixed="right" align="center" width="220">
<template #default="{ row }">
<el-tooltip class="box-item" effect="dark" content="只有合作商家能结账" placement="top">
<el-button icon="Tickets" text :disabled="row.merchantType != 2" type="primary"
@click="doCheckout(row)">
商户结账
</el-button>
<el-tooltip class="box-item" effect="dark"
:content="row.merchantId == null ? '未归属商户的流水仅参与统计,不能结账' : '只有合作商家能结账'" placement="top">
<span>
<el-button icon="Tickets" text :disabled="row.merchantId == null || row.merchantType != 2" type="primary"
@click="doCheckout(row)">
商户结账
</el-button>
</span>
</el-tooltip>
<el-tooltip effect="dark" content="未归属商户的流水计入汇总导出,不能按商户单独导出"
:disabled="row.merchantId != null" placement="top">
<span>
<el-button icon="Tickets" type="primary" text :disabled="row.merchantId == null" @click="exportMchExcel(row)">
导出商户流水
</el-button>
</span>
</el-tooltip>
<el-button icon="Tickets" type="primary" text @click="exportMchExcel(row)">
导出商户流水
</el-button>
</template>
</el-table-column>
</el-table>
@@ -99,18 +111,15 @@ import * as hardTotalApi from "/@/api/hard/hard-total"
const { push } = useRouter()
/** 流水金额说明已支付订单原始金额的统计方式。 */
const payAmountHeaderTip = "所选时间内完成支付的订单金额合计;退款金额另行显示,不会从这里扣除。"
/** 流水次数说明已支付订单的计次方式。 */
const payCountHeaderTip = "所选时间内完成支付的订单数量,同一笔订单算 1 次。"
/** 退款金额说明按退款完成时间统计。 */
const refundAmountHeaderTip = "所选时间内已经完成退款的金额合计,按退款完成时间统计。"
/** 退款次数说明按已完成退款记录计次。 */
const refundCountHeaderTip = "所选时间内已经完成的退款记录数;一笔订单有多次退款时,每次完成的退款各算 1 次。"
/** 支付和退款都按钱包流水的交易时间统计。 */
const payAmountHeaderTip = "所选交易时间内的钱包支付流水金额合计;退款金额单独显示,不会从这里扣除。"
const payCountHeaderTip = "所选交易时间内的钱包支付流水记录数,每条流水算 1 次;同一订单使用多个钱包支付时分别计次。"
const refundAmountHeaderTip = "所选交易时间内的钱包退款流水金额合计,包含非订单退款;未归属商户的流水也计入合计。"
const refundCountHeaderTip = "所选交易时间内的钱包退款流水记录数,每条流水算 1 次,包含非订单退款。"
/** 消费净金额说明支付与退款的相减关系。 */
const netAmountHeaderTip = "流水金额减去退款金额。付款和退款分别按完成时间统计;只选到退款的时间段时,结果可能为负数。"
/** 消费净次数说明支付订单与退款记录的相减关系。 */
const netCountHeaderTip = "流水次数减去退款次数。退款次数按退款记录计算,一笔订单多次退款会分别扣减。"
const netAmountHeaderTip = "流水金额减去退款金额。支付和退款分别按各自钱包流水的交易时间统计,结果可能为负数。"
/** 消费净次数说明支付流水与退款流水的相减关系。 */
const netCountHeaderTip = "钱包支付流水次数减去钱包退款流水次数,每条流水分别计次,结果可能为负数。"
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
@@ -120,17 +129,19 @@ const state: BasicTableProps = reactive<BasicTableProps>({
})
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
const { getDataList, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
// 多选变量
const selectObjs = ref([]) as any
const multiple = ref(true)
const doCheckout = (row: any) => {
if (row.merchantId == null || row.merchantType != 2) return
push({ path: "/mch/mch-checkout/index", query: { mchId: row.merchantId } })
}
const exportMchExcel = (row: any) => {
if (row.merchantId == null) return
postDownBlobFile("/hard/hard-total/export-mch-cs", { ...state.queryForm, merchantId: row.merchantId }, "商户流水.xlsx")
}
@@ -149,7 +160,7 @@ const getSummaries = (param: any) => {
if (totalColumns.includes(i.property)) {
const total = data
.filter((v: any) => v[i.property] != null)
.map((v: any) => v[i.property])
.map((v: any) => Number(v[i.property]))
.reduce((pren: number, next: number) => pren + next, 0)
if (i.property.includes("Price")) {
totalRow[index] = displayPrice(total)
@@ -176,4 +187,4 @@ defineExpose({
doSearch,
doCancel
})
</script>
</script>
@@ -7,7 +7,7 @@
<el-table-column type="index" label="#" width="50" />
<el-table-column label="商户" width="200" align="center" prop="merchantName">
<template #default="{ row }">
{{ row.merchantName || row.merchantId }}
{{ row.merchantName || (row.merchantId == null ? "未归属商户" : row.merchantId) }}
</template>
</el-table-column>
<el-table-column label="月份" width="100" align="center" prop="day" />
@@ -90,18 +90,15 @@ import { displayPrice } from "/@/utils/formatMoney"
import * as hardTotalApi from "/@/api/hard/hard-total"
/** 流水金额说明已支付订单原始金额的统计方式。 */
const payAmountHeaderTip = "所选月份内完成支付的订单金额合计;退款金额另行显示,不会从这里扣除。"
/** 流水次数说明已支付订单的计次方式。 */
const payCountHeaderTip = "所选月份内完成支付的订单数量,同一笔订单算 1 次。"
/** 退款金额说明按退款完成时间统计。 */
const refundAmountHeaderTip = "所选月份内已经完成退款的金额合计,按退款完成时间归属月份。"
/** 退款次数说明按已完成退款记录计次。 */
const refundCountHeaderTip = "所选月份内已经完成的退款记录数;一笔订单有多次退款时,每次完成的退款各算 1 次。"
/** 支付和退款都按钱包流水的交易时间归属月份。 */
const payAmountHeaderTip = "所选月份内的钱包支付流水金额合计;退款金额单独显示,不会从这里扣除。"
const payCountHeaderTip = "所选月份内的钱包支付流水记录数,每条流水算 1 次;同一订单使用多个钱包支付时分别计次。"
const refundAmountHeaderTip = "所选月份内的钱包退款流水金额合计,包含非订单退款;未归属商户的流水也计入合计。"
const refundCountHeaderTip = "所选月份内的钱包退款流水记录数,每条流水算 1 次,包含非订单退款。"
/** 消费净金额说明支付与退款的相减关系。 */
const netAmountHeaderTip = "流水金额减去退款金额。付款按支付月份、退款按退款完成月份统计;只选到退款的月份时,结果可能为负数。"
/** 消费净次数说明支付订单与退款记录的相减关系。 */
const netCountHeaderTip = "流水次数减去退款次数。退款次数按退款记录计算,一笔订单多次退款会分别扣减。"
const netAmountHeaderTip = "流水金额减去退款金额。支付和退款分别按各自钱包流水的交易时间归属月份,结果可能为负数。"
/** 消费净次数说明支付流水与退款流水的相减关系。 */
const netCountHeaderTip = "钱包支付流水次数减去钱包退款流水次数,每条流水分别计次,结果可能为负数。"
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
@@ -114,7 +111,7 @@ const selectObjs = ref([]) as any
const multiple = ref(true)
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
@@ -126,12 +123,12 @@ const getSummaries = (param: any) => {
const { columns, data } = param
const totalColumns = ["totalPayPrice", "totalPayCount", "totalRefundPrice", "totalRefundCount", "totalProfitPrice", "totalProfitCount", "mchPlatformRankePrice"]
const totalRow = Array.from({ length: columns.length })
totalRow[0] = "合计"
totalRow[0] = "本页合计"
columns.forEach((i: any, index: number) => {
if (totalColumns.includes(i.property)) {
const total = data
.filter((v: any) => v[i.property] != null)
.map((v: any) => v[i.property])
.map((v: any) => Number(v[i.property]))
.reduce((pren: number, next: number) => pren + next, 0)
if (i.property.includes("Price")) {
totalRow[index] = displayPrice(total)
@@ -158,4 +155,4 @@ defineExpose({
doSearch,
doCancel
})
</script>
</script>
+37 -32
View File
@@ -26,12 +26,15 @@
<el-form-item style="width: 250px" label="商户" prop="merchantId">
<select-mch v-model="state.queryForm.merchantId" :apiParams="{ parentIsNull: false }" />
</el-form-item>
<el-form-item v-if="activeTab === 'MCH'" style="width: 450px" label="创建时间(天)"
<el-form-item v-if="activeTab === 'MCH'" style="width: 450px" label="交易时间"
prop="payTimeList">
<template #label>
<statistics-table-header label="交易时间" :tip="transactionTimeTip" />
</template>
<el-date-picker v-model="state.queryForm.payTimeList" value-format="YYYY-MM-DD HH:mm:ss"
type="datetimerange" />
type="datetimerange" start-placeholder="开始时间(含)" end-placeholder="结束时间(不含)" />
</el-form-item>
<el-form-item v-if="activeTab === 'MONTH'" style="width: 450px" label="创建时间(年)"
<el-form-item v-if="activeTab === 'MONTH'" style="width: 450px" label="交易年份"
prop="payTimeYearStr">
<el-date-picker value-format="YYYY" v-model="state.queryForm.payTimeYearStr"
type="year" />
@@ -47,7 +50,7 @@
</el-row>
</el-form>
<el-row class="flex">
<el-tabs class="flex-1" v-model="activeTab" @tab-click="tabClick">
<el-tabs class="flex-1" v-model="activeTab">
<el-tab-pane v-for="(v) in tabPane" :key="v.name" :label="v.label" :name="v.name" />
</el-tabs>
<div class="mb8">
@@ -61,15 +64,19 @@
</template>
</DraggableDepMchTree>
</div>
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
</div>
</template>
<script setup lang="ts" name="hard-sb">
import { ElMessage } from "element-plus"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { useBatchExport } from "/@/hooks/batchExport"
import { submitMerchantExport as submitBatchExport } from "/@/api/hard/hard-total"
import { submitMerchantMonthExport } from "/@/api/hard/hard-total"
import dayjs from "dayjs"
import other from "/@/utils/other"
import { formatDate } from '/@/utils/formatTime';
const HardShByDay = defineAsyncComponent(() => import("./components/hard-sh-by-day.vue"))
const HardShByMonth = defineAsyncComponent(() => import("./components/hard-sh-by-month.vue"))
@@ -96,6 +103,7 @@ const tabPane = ref([
}
])
const activeTab = ref("MCH")
const transactionTimeTip = "按钱包流水的交易时间统计,包含开始时间,不包含结束时间。例如,统计 9 月可选择 9 月 1 日 00:00:00 到 10 月 1 日 00:00:00。"
const defaultCreateTimeList = [
dayjs().subtract(3, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
@@ -109,59 +117,56 @@ const state = reactive<any>({
queryForm: { ...defaultQueryForm },
})
watch(() => [activeTab.value, hardShByDayRef.value, hardShByMonthRef.value], () => {
// 异步子表挂载后统一查询,避免 mounted、tab-click 和 ref watcher 重复触发重型统计。
watch([hardShByDayRef, hardShByMonthRef], () => {
if (activeTab.value === "MCH" && hardShByDayRef.value?.doSearch) {
search()
}
if (activeTab.value === "MONTH" && hardShByMonthRef.value?.doSearch) {
search()
}
}, { flush: "post" })
watch(activeTab, () => {
state.queryForm = { ...defaultQueryForm }
})
onMounted(() => {
nextTick(() => {
search()
})
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:merchant",
taskName: "商户交易记录", fileName: "商户交易记录.xlsx", submitTask: (query) => activeTab.value === "MONTH" ? submitMerchantMonthExport(query) : submitBatchExport(query),
})
const exportExcel = () => {
ElMessage.info("下载中,请稍后")
switch (activeTab.value) {
case "MCH":
other.postDownBlobFile("/hard/hard-total/export-cs", Object.assign(state.queryForm), formatDate(new Date(), "YYYY年mm月dd日 HH时MM分SS秒 ") + "商户交易记录.xlsx")
break;
case "MONTH":
other.postDownBlobFile("/hard/hard-total/export-cs-by-month", Object.assign(state.queryForm), formatDate(new Date(), "YYYY年mm月dd日 HH时MM分SS秒 ") + "商户交易记录(按月).xlsx")
break;
}
normalizeTimeQuery()
exportTaskConfig.redisKey = activeTab.value === "MONTH" ? "hard:export:merchant-month" : "hard:export:merchant"
return submitExport({ ...state.queryForm })
}
const tabClick = () => {
console.log(123123);
cancel()
}
const search = () => {
const normalizeTimeQuery = () => {
if (activeTab.value === "MCH") {
state.queryForm.payTimeYearStr = ""
}
if (activeTab.value === "MONTH") {
state.queryForm.createTimeList = []
state.queryForm.payTimeList = []
}
}
const search = () => {
normalizeTimeQuery()
switch (activeTab.value) {
case "MCH":
hardShByDayRef.value.doSearch(state.queryForm)
hardShByDayRef.value?.doSearch(state.queryForm)
break;
case "MONTH":
hardShByMonthRef.value.doSearch(state.queryForm)
hardShByMonthRef.value?.doSearch(state.queryForm)
break;
}
}
const cancel = () => {
state.queryForm = { ...defaultQueryForm }
console.log("state.queryForm:", state.queryForm);
normalizeTimeQuery()
switch (activeTab.value) {
case "MCH":
@@ -0,0 +1,596 @@
<template>
<el-drawer
v-model="visible"
size="min(1040px, 100vw)"
class="third-order-drawer"
:close-on-click-modal="false"
destroy-on-close
@closed="closeDetail"
>
<template #header="{ titleId }">
<div class="drawer-heading">
<h2 :id="titleId">支付底单详情</h2>
<p>支付、退款与接口调用记录</p>
</div>
</template>
<div v-loading="loading" class="detail-content">
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<el-result v-else-if="loadError" icon="warning" title="底单加载失败" :sub-title="loadError">
<template #extra><el-button type="primary" @click="loadDetail">重新加载</el-button></template>
</el-result>
<template v-else-if="detail">
<div class="detail-banner">
<div>
<span class="eyebrow">第三方订单号</span>
<div class="detail-order-no">
{{ detail.order.orderNo || "未记录" }}
<el-button
v-if="detail.order.orderNo"
:icon="CopyDocument"
link
type="primary"
aria-label="复制第三方订单号"
@click="copyNumber(detail.order.orderNo)"
/>
</div>
<div class="platform-no">
平台底单号 {{ detail.order.id }}
<el-button :icon="CopyDocument" link type="primary" aria-label="复制平台底单号" @click="copyNumber(detail.order.id)" />
</div>
</div>
<el-tag :type="orderState(detail.order).type">{{ orderState(detail.order).label }}</el-tag>
</div>
<div class="amount-grid">
<div>
<span>支付金额</span>
<strong>{{ displayPrice(detail.order.payPrice) }}</strong>
</div>
<div>
<span>累计已退</span>
<strong>{{ displayPrice(detail.order.refundPrice) }}</strong>
</div>
<div>
<span>剩余可退</span>
<strong>{{ displayPrice(remainingPrice(detail.order)) }}</strong>
</div>
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="支付详情" name="overview">
<div class="overview-grid">
<section class="detail-section">
<h3>支付信息</h3>
<el-descriptions :column="1" border label-width="116px">
<el-descriptions-item label="支付用户">{{ detail.order.username || "未记录" }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ detail.order.userMobile || "未记录" }}</el-descriptions-item>
<el-descriptions-item label="用户部门">{{ detail.order.userDeptName || "未记录" }}</el-descriptions-item>
<el-descriptions-item label="支付商户">{{ detail.order.merchantName || "未记录" }}</el-descriptions-item>
<el-descriptions-item label="钱包类型">
<dict-tag
v-if="detail.order.walletTypeUniqueCode"
:options="wallet_type"
:value="String(detail.order.walletTypeUniqueCode)"
/>
<span v-else>未记录</span>
</el-descriptions-item>
<el-descriptions-item label="支付方式">第三方应用钱包扣款</el-descriptions-item>
<el-descriptions-item label="钱包编号">
<span class="breakable number-text">{{ detail.order.walletId || "未记录" }}</span>
</el-descriptions-item>
<el-descriptions-item label="商品总额">{{ displayPrice(detail.order.totalPrice) }}</el-descriptions-item>
<el-descriptions-item label="支付差额">{{ displayPrice(detail.order.extraPrice) }}</el-descriptions-item>
<el-descriptions-item label="商品描述">
<span class="breakable">{{ detail.order.productDesc || "未提供商品描述" }}</span>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-section lifecycle">
<h3>交易生命周期</h3>
<el-timeline>
<el-timeline-item
v-for="event in timeline"
:key="event.id"
:timestamp="event.time || '未记录时间'"
placement="top"
:type="event.type"
>
<strong>{{ event.title }}</strong>
<p>{{ event.description }}</p>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!timeline.length" description="暂无生命周期记录" :image-size="72" />
</section>
</div>
</el-tab-pane>
<el-tab-pane :label="`商品明细(${detail.products.length})`" name="products">
<el-table :data="detail.products" border>
<el-table-column type="index" label="#" width="48" />
<el-table-column prop="productName" label="商品名称" min-width="220" show-overflow-tooltip />
<el-table-column label="商品单价" min-width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.productPrice) }}</template>
</el-table-column>
<el-table-column prop="productCount" label="数量" width="90" align="right" />
<el-table-column label="商品金额" min-width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.totalPrice) }}</template>
</el-table-column>
<template #empty><el-empty description="第三方应用未提供商品明细" :image-size="88" /></template>
</el-table>
<p class="section-note">
商品金额按第三方应用传入的商品快照展示;与实付金额的差额为 {{ displayPrice(detail.order.extraPrice) }}。
</p>
</el-tab-pane>
<el-tab-pane :label="`退款记录(${detail.refunds.length})`" name="refunds">
<el-table :data="detail.refunds" border>
<el-table-column label="退款单号" min-width="235">
<template #default="{ row }">
<div class="breakable number-text">{{ row.no || "未记录退款单号" }}</div>
<span class="section-note">平台退款单 {{ row.id }}</span>
</template>
</el-table-column>
<el-table-column label="退款金额" width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.refundPrice) }}</template>
</el-table-column>
<el-table-column label="售后状态" width="115">
<template #default="{ row }">
<el-tag :type="saleState(row.status).type" size="small">{{ saleState(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="退款原因" min-width="170">
<template #default="{ row }">
<div>{{ row.applyReason || "未填写" }}</div>
<div v-if="row.applyDescription" class="section-note">{{ row.applyDescription }}</div>
</template>
</el-table-column>
<el-table-column label="交易时间" min-width="180">
<template #default="{ row }">
<div>{{ refundTime(row) || "未记录时间" }}</div>
<span class="section-note">{{ String(row.status) === "50" ? "退款完成 / 审核时间" : "申请时间" }}</span>
</template>
</el-table-column>
<template #empty><el-empty description="该底单暂无退款记录" :image-size="88" /></template>
</el-table>
</el-tab-pane>
<el-tab-pane v-if="auth('third_thirdPayLog_view')" label="接口调用记录" name="logs">
<div class="logs-toolbar">
<p class="section-note">按平台底单号与第三方订单号关联支付、退款及订单详情查询。</p>
<el-select v-model="logType" clearable placeholder="全部接口类型" style="width: 160px" @change="loadLogs(true)">
<el-option label="支付" value="10" />
<el-option label="退款" value="20" />
<el-option label="订单详情查询" value="50" />
</el-select>
</div>
<el-alert v-if="logError" :title="logError" type="error" :closable="false" show-icon class="logs-error" />
<el-table v-loading="logsLoading" :data="logs" border>
<el-table-column label="调用时间" prop="createTime" width="175" />
<el-table-column label="调用方" min-width="130">
<template #default="{ row }"><dict-tag :options="third_key_type" :value="row.paySourceType" /></template>
</el-table-column>
<el-table-column label="接口类型" width="120">
<template #default="{ row }">{{ apiTypeLabel(row.bizType) }}</template>
</el-table-column>
<el-table-column label="调用结果" min-width="140">
<template #default="{ row }">
<el-tag :type="apiResult(row).type" size="small">{{ apiResult(row).label }}</el-tag>
<div v-if="row.error" class="section-note breakable">{{ row.error }}</div>
</template>
</el-table-column>
<el-table-column label="耗时" width="90" align="right">
<template #default="{ row }">{{ row.timeConsuming ? `${row.timeConsuming} ms` : "—" }}</template>
</el-table-column>
<el-table-column label="请求 / 响应" width="120">
<template #default="{ row }">
<div class="json-links">
<span>请求</span>
<el-button v-if="row.req" link type="primary" @click="showPayload(row.req, '请求报文')">查看</el-button>
<span v-else>—</span>
</div>
<div class="json-links">
<span>响应</span>
<el-button v-if="row.res" link type="primary" @click="showPayload(row.res, '响应报文')">查看</el-button>
<span v-else>—</span>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty :description="logError ? '接口记录加载失败' : '暂无关联接口记录'" :image-size="80">
<el-button v-if="logError" @click="loadLogs()">重试</el-button>
</el-empty>
</template>
</el-table>
<div class="logs-pagination">
<el-pagination
:current-page="logPagination.current"
:page-size="logPagination.size"
:total="logPagination.total"
layout="total, prev, pager, next"
:pager-count="5"
background
@current-change="changeLogPage"
/>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button :icon="Refresh" :loading="loading" @click="refreshCurrent">刷新详情</el-button>
<el-tooltip
v-if="detail && auth('hard_hardAfterSale_adminRefund')"
:disabled="!refundDisabledReason(detail.order, detail.refunds)"
:content="refundDisabledReason(detail.order, detail.refunds)"
>
<span>
<el-button
type="warning"
:disabled="loading || Boolean(refundDisabledReason(detail.order, detail.refunds))"
@click="emit('refund', detail.order)"
>
发起退款
</el-button>
</span>
</el-tooltip>
</div>
</template>
</el-drawer>
<el-dialog v-model="payloadVisible" :title="payloadTitle" width="min(860px, calc(100vw - 24px))" append-to-body destroy-on-close>
<pre class="payload-content" tabindex="0">{{ payloadText }}</pre>
<template #footer><el-button @click="payloadVisible = false">关闭</el-button></template>
</el-dialog>
</template>
<script setup lang="ts" name="hardThirdOrderDetail">
import { computed, ref, reactive, watch } from "vue"
import { useClipboard } from "@vueuse/core"
import { CopyDocument, Refresh } from "@element-plus/icons-vue"
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
import type { ThirdOrder, ThirdOrderDetail, ThirdOrderId } from "/@/api/hard/hard-third-order"
import * as thirdPayLogApi from "/@/api/thirdparty/third-pay-log"
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import { auth } from "/@/utils/authFunction"
import { displayPrice } from "/@/utils/formatMoney"
import { apiResult, orderState, refundDisabledReason, refundTime, remainingPrice, saleState } from "../order"
const emit = defineEmits<{ (event: "refund", order: ThirdOrder): void }>()
const { wallet_type, third_key_type } = useDict("wallet_type", "third_key_type")
const { copy } = useClipboard({ legacy: true })
const visible = ref(false)
const loading = ref(false)
const loadError = ref("")
const detail = ref<ThirdOrderDetail>()
const currentId = ref<ThirdOrderId>()
const activeTab = ref("overview")
const logs = ref<any[]>([])
const logsLoading = ref(false)
const logsLoaded = ref(false)
const logError = ref("")
const logType = ref("")
const payloadVisible = ref(false)
const payloadTitle = ref("")
const payloadText = ref("")
const logPagination = reactive({ current: 1, size: 10, total: 0 })
let detailSequence = 0
let logSequence = 0
const timeline = computed(() => {
if (!detail.value) return []
const { order, refunds } = detail.value
const events: {
id: string
title: string
description: string
time?: string
type: "primary" | "success" | "warning" | "danger" | "info"
}[] = []
if (order.createTime)
events.push({ id: "created", title: "底单创建", description: "第三方应用发起钱包支付", time: order.createTime, type: "primary" })
if (order.payTime)
events.push({
id: "paid",
title: "钱包扣款完成",
description: `支付金额 ${displayPrice(order.payPrice)}`,
time: order.payTime,
type: "success",
})
for (const refund of refunds) {
const state = saleState(refund.status)
events.push({
id: `refund-${refund.id}`,
title: state.label,
description: `${displayPrice(refund.refundPrice)} · ${refund.no || refund.id}`,
time: refundTime(refund),
type: state.type || "info",
})
}
return events.sort((a, b) => (a.time || "").localeCompare(b.time || ""))
})
async function openDialog(id: ThirdOrderId) {
currentId.value = id
visible.value = true
detail.value = undefined
activeTab.value = "overview"
resetLogs()
await loadDetail()
}
async function loadDetail() {
if (currentId.value == null) return
const sequence = ++detailSequence
loading.value = true
loadError.value = ""
try {
const { data } = await thirdOrderApi.detail(currentId.value)
if (sequence !== detailSequence) return
if (!data?.order) throw new Error("底单不存在或无权查看")
detail.value = { ...data, products: data.products || [], refunds: data.refunds || [] }
} catch (error: any) {
if (sequence === detailSequence) loadError.value = error?.msg || error?.message || "详情加载失败"
} finally {
if (sequence === detailSequence) loading.value = false
}
}
async function refreshOrder(id: ThirdOrderId) {
if (!visible.value || String(id) !== String(currentId.value)) return
await refreshCurrent()
}
async function refreshCurrent() {
await loadDetail()
resetLogs()
if (activeTab.value === "logs") await loadLogs(true)
}
function resetLogs() {
++logSequence
logs.value = []
logsLoaded.value = false
logsLoading.value = false
logError.value = ""
logPagination.current = 1
logPagination.total = 0
}
async function loadLogs(resetPage = false) {
if (!detail.value || !auth("third_thirdPayLog_view")) return
if (resetPage) logPagination.current = 1
const sequence = ++logSequence
logsLoading.value = true
logError.value = ""
try {
const { data } = await thirdPayLogApi.fetchList({
relatedPlatformOrderId: String(detail.value.order.id),
relatedThirdOrderId: detail.value.order.orderNo,
bizType: logType.value || undefined,
current: logPagination.current,
size: logPagination.size,
})
if (sequence !== logSequence) return
logs.value = data?.records || []
logPagination.total = Number(data?.total || 0)
logsLoaded.value = true
} catch (error: any) {
if (sequence !== logSequence) return
logs.value = []
logPagination.total = 0
logError.value = error?.msg || "接口记录加载失败,请重试"
} finally {
if (sequence === logSequence) logsLoading.value = false
}
}
function changeLogPage(page: number) {
logPagination.current = page
loadLogs()
}
function apiTypeLabel(type: string) {
return (
({ "10": "支付", "20": "退款", "30": "钱包查询", "40": "流水查询", "50": "订单详情查询" } as Record<string, string>)[String(type)] ||
type ||
"未知接口"
)
}
function showPayload(payload: string, title: string) {
// 原文展示既兼容非 JSON 错误响应,也避免格式化时损失长整型订单号的精度。
payloadText.value = payload
payloadTitle.value = title
payloadVisible.value = true
}
async function copyNumber(value: ThirdOrderId) {
try {
await copy(String(value))
useMessage().success("已复制订单号")
} catch {
useMessage().warning("复制失败,请手动选择订单号复制")
}
}
function closeDetail() {
++detailSequence
payloadVisible.value = false
resetLogs()
detail.value = undefined
currentId.value = undefined
}
watch(activeTab, (tab) => {
if (tab === "logs" && !logsLoaded.value) loadLogs(true)
})
defineExpose({ openDialog, refreshOrder })
</script>
<style scoped lang="scss">
.drawer-heading h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
}
.drawer-heading p {
margin: 6px 0 0;
font-size: 12px;
color: var(--el-text-color-regular);
}
.detail-content {
min-height: 300px;
color: var(--el-text-color-primary);
}
.detail-banner {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-extra-light);
border-radius: 8px;
> div {
min-width: 0;
flex: 1;
}
}
.eyebrow {
font-size: 12px;
color: var(--el-text-color-regular);
}
.detail-order-no {
margin-top: 8px;
font-size: 17px;
font-weight: 600;
overflow-wrap: anywhere;
line-height: 26px;
}
.platform-no {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-regular);
overflow-wrap: anywhere;
}
.amount-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
padding: 20px 0;
> div {
padding: 0 16px;
border-left: 2px solid var(--el-border-color-light);
}
span {
display: block;
color: var(--el-text-color-regular);
font-size: 12px;
}
strong {
display: block;
margin-top: 8px;
font-size: 21px;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
}
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 32px;
}
.detail-section h3 {
margin: 4px 0 18px;
font-size: 14px;
font-weight: 600;
}
.lifecycle {
padding-right: 8px;
:deep(.el-timeline) {
padding-left: 2px;
}
strong {
font-size: 13px;
}
p {
font-size: 12px;
color: var(--el-text-color-regular);
line-height: 20px;
margin-top: 6px;
overflow-wrap: anywhere;
}
}
.breakable {
overflow-wrap: anywhere;
}
.number-text {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.section-note {
margin: 8px 0;
font-size: 12px;
color: var(--el-text-color-regular);
line-height: 20px;
}
.logs-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.logs-error {
margin-bottom: 12px;
}
.json-links {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.logs-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
overflow-x: auto;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
.payload-content {
max-height: 65vh;
margin: 0;
padding: 16px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.7;
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
@media (max-width: 767px) {
.overview-grid {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.amount-grid {
gap: 8px;
> div {
padding: 0 8px;
}
strong {
font-size: 16px;
}
}
}
</style>
@@ -0,0 +1,271 @@
<template>
<el-dialog
v-model="visible"
title="第三方支付底单退款"
width="min(560px, calc(100vw - 24px))"
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
destroy-on-close
@closed="closeDialog"
>
<div v-loading="loading" class="refund-content">
<el-skeleton v-if="loading && !detail" :rows="5" animated />
<el-alert v-else-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
<template v-else-if="detail">
<div class="refund-order">
<span>第三方订单号</span>
<strong>{{ detail.order.orderNo || "未记录" }}</strong>
<span>平台底单号 {{ detail.order.id }}</span>
</div>
<div class="refund-summary">
<div>
<span>支付金额</span>
<strong>{{ displayPrice(detail.order.payPrice) }}</strong>
</div>
<div>
<span>累计已退</span>
<strong>{{ displayPrice(detail.order.refundPrice) }}</strong>
</div>
<div>
<span>剩余可退</span>
<strong>{{ displayPrice(remainingPrice(detail.order)) }}</strong>
</div>
</div>
<el-alert v-if="disabledReason" :title="disabledReason" type="info" :closable="false" show-icon />
<template v-else>
<el-alert title="退款金额将退回原支付钱包,请核对金额后提交。" type="warning" :closable="false" show-icon />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="refund-form" :disabled="submitting">
<el-form-item label="退款金额(元)" prop="amount">
<div class="amount-input">
<el-input-number
v-model="form.amount"
:min="0.01"
:max="remainingPrice(detail.order) / 100"
:precision="2"
:step="1"
controls-position="right"
aria-label="退款金额,单位元"
/>
<el-button link type="primary" @click="form.amount = remainingPrice(detail.order) / 100">剩余全退</el-button>
</div>
<div class="field-hint">支持部分退款,最多 {{ displayPrice(remainingPrice(detail.order)) }}</div>
</el-form-item>
<el-form-item label="退款原因" prop="applyReason">
<el-input
v-model.trim="form.applyReason"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请填写退款原因"
/>
</el-form-item>
<el-form-item label="补充说明" prop="applyDescription">
<el-input
v-model.trim="form.applyDescription"
type="textarea"
:rows="2"
maxlength="255"
show-word-limit
placeholder="选填,可补充退款处理说明"
/>
</el-form-item>
</el-form>
</template>
</template>
</div>
<template #footer>
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button v-if="loadError" :loading="loading" @click="loadDetail">重新加载</el-button>
<el-button
v-else
type="warning"
:loading="submitting"
:disabled="loading || !detail || Boolean(disabledReason)"
@click="submitRefund"
>
确认退款
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="hardThirdOrderRefund">
import { computed, nextTick, reactive, ref } from "vue"
import { ElMessageBox } from "element-plus"
import type { FormInstance, FormRules } from "element-plus"
import { nanoid } from "nanoid"
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
import type { ThirdOrderDetail, ThirdOrderId } from "/@/api/hard/hard-third-order"
import { useMessage } from "/@/hooks/message"
import { displayPrice, storePrice } from "/@/utils/formatMoney"
import { refundDisabledReason, remainingPrice } from "../order"
const emit = defineEmits<{ (event: "success", orderId: ThirdOrderId): void }>()
const formRef = ref<FormInstance>()
const visible = ref(false)
const loading = ref(false)
const submitting = ref(false)
const loadError = ref("")
const detail = ref<ThirdOrderDetail>()
const orderId = ref<ThirdOrderId>()
const requestNo = ref("")
let loadSequence = 0
const form = reactive({ amount: undefined as number | undefined, applyReason: "", applyDescription: "" })
const disabledReason = computed(() => (detail.value ? refundDisabledReason(detail.value.order, detail.value.refunds) : ""))
const rules: FormRules = {
amount: [
{
validator: (_rule, value, callback) => {
if (typeof value !== "number" || !Number.isFinite(value) || storePrice(value) <= 0) return callback(new Error("请输入有效退款金额"))
if (!detail.value || storePrice(value) > remainingPrice(detail.value.order))
return callback(new Error("退款金额不能超过剩余可退金额"))
callback()
},
trigger: "change",
},
],
applyReason: [{ required: true, whitespace: true, message: "请填写退款原因", trigger: "blur" }],
}
async function openDialog(id: ThirdOrderId) {
if (submitting.value) return
orderId.value = id
requestNo.value = nanoid(24)
detail.value = undefined
Object.assign(form, { amount: undefined, applyReason: "", applyDescription: "" })
visible.value = true
await nextTick()
formRef.value?.clearValidate()
await loadDetail()
}
async function loadDetail() {
if (orderId.value == null) return
const sequence = ++loadSequence
loading.value = true
loadError.value = ""
try {
const { data } = await thirdOrderApi.detail(orderId.value)
if (sequence !== loadSequence) return
if (!data?.order) throw new Error("底单不存在或无权查看")
detail.value = { ...data, products: data.products || [], refunds: data.refunds || [] }
form.amount = remainingPrice(data.order) / 100
} catch (error: any) {
if (sequence === loadSequence) loadError.value = error?.msg || error?.message || "底单加载失败,请重试"
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function submitRefund() {
if (submitting.value || loading.value || !detail.value || disabledReason.value) return
if (!(await formRef.value?.validate().catch(() => false))) return
const refundPrice = storePrice(form.amount)
const currentOrder = detail.value.order
submitting.value = true
try {
await ElMessageBox.confirm(
`确认将 ${displayPrice(refundPrice)} 退回用户「${currentOrder.username || currentOrder.userId || "未记录"}」的原支付钱包?`,
"确认退款",
{ type: "warning", confirmButtonText: "确认退款", cancelButtonText: "返回核对", closeOnClickModal: false },
)
} catch {
submitting.value = false
return
}
try {
const { data } = await thirdOrderApi.refund({
orderId: currentOrder.id,
refundPrice,
requestNo: requestNo.value,
applyReason: form.applyReason,
applyDescription: form.applyDescription,
})
if (data !== true) throw new Error("退款未完成,请刷新底单确认状态")
useMessage().success("退款成功,金额已退回原支付钱包")
visible.value = false
emit("success", currentOrder.id)
} catch (error: any) {
useMessage().error(error?.msg || error?.message || "退款请求失败,请刷新底单确认状态后重试")
} finally {
submitting.value = false
}
}
function closeDialog() {
++loadSequence
detail.value = undefined
orderId.value = undefined
}
defineExpose({ openDialog })
</script>
<style scoped lang="scss">
.refund-content {
min-height: 180px;
color: var(--el-text-color-primary);
}
.refund-order {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
span {
font-size: 12px;
color: var(--el-text-color-regular);
overflow-wrap: anywhere;
}
strong {
font-size: 15px;
overflow-wrap: anywhere;
}
}
.refund-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 20px 0;
span {
display: block;
color: var(--el-text-color-regular);
font-size: 12px;
}
strong {
display: block;
margin-top: 8px;
font-size: 16px;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
}
.refund-form {
margin-top: 20px;
}
.amount-input {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.field-hint {
width: 100%;
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 20px;
margin-top: 6px;
}
@media (max-width: 480px) {
.refund-summary {
gap: 8px;
strong {
font-size: 14px;
}
}
}
</style>
+632
View File
@@ -0,0 +1,632 @@
<template>
<div class="layout-padding third-order-page">
<div class="layout-padding-auto layout-padding-view third-order-view">
<header class="page-header">
<div>
<div class="page-title">
<span class="title-mark" aria-hidden="true"></span>
<h1>第三方应用支付底单</h1>
<el-tag size="small" effect="plain">钱包支付</el-tag>
</div>
<p>集中查看第三方应用支付、商品明细与退款记录</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadOrders(false)">刷新底单</el-button>
</header>
<div class="page-scroll">
<section class="summary-grid" aria-label="本页底单汇总" aria-live="polite">
<div class="summary-item">
<span>本页底单</span>
<strong>
{{ orders.length }}
<small>笔</small>
</strong>
</div>
<div class="summary-item">
<span>本页支付</span>
<strong>
{{ displayPrice(pageSummary.paid, "") }}
<small>元</small>
</strong>
</div>
<div class="summary-item">
<span>本页已退</span>
<strong class="refund-amount">
{{ displayPrice(pageSummary.refunded, "") }}
<small>元</small>
</strong>
</div>
<div class="summary-item">
<span>本页净收</span>
<strong>
{{ displayPrice(pageSummary.paid - pageSummary.refunded, "") }}
<small>元</small>
</strong>
</div>
</section>
<SearchPanel
ref="queryRef"
:model="queryForm"
title="筛选底单"
:loading="loading"
:hidden-active-count="hiddenActiveCount"
@search="loadOrders()"
@reset="resetQuery"
>
<el-form-item label="第三方订单号" prop="orderNo">
<el-input v-model.trim="queryForm.orderNo" placeholder="输入完整第三方订单号" clearable />
</el-form-item>
<el-form-item label="平台底单号" prop="id">
<el-input v-model.trim="queryForm.id" placeholder="输入完整平台底单号" clearable />
</el-form-item>
<el-form-item label="支付用户" prop="userId"><select-user v-model="queryForm.userId" /></el-form-item>
<el-form-item label="创建时间" prop="createTimeList" class="range-field">
<el-date-picker
v-model="queryForm.createTimeList"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
:format="windowWidth < 768 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<template #more>
<el-form-item label="支付商户" prop="merchantId"><select-mch v-model="queryForm.merchantId" /></el-form-item>
<el-form-item label="钱包类型" prop="walletTypeIdList">
<select-wallet-type v-model="queryForm.walletTypeIdList" multiple />
</el-form-item>
<el-form-item label="退款进度" prop="thirdRefundState">
<el-select v-model="queryForm.thirdRefundState" placeholder="全部退款进度" clearable>
<el-option label="未退款" value="NONE" />
<el-option label="部分退款" value="PARTIAL" />
<el-option label="全额退款" value="FULL" />
<el-option label="退款处理中" value="PROCESSING" />
</el-select>
</el-form-item>
<el-form-item label="退款单号" prop="afterSaleNo">
<el-input v-model.trim="queryForm.afterSaleNo" placeholder="输入完整退款单号" clearable />
</el-form-item>
<el-form-item label="支付时间" prop="payTimeList" class="range-field">
<el-date-picker
v-model="queryForm.payTimeList"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
:format="windowWidth < 768 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
</template>
</SearchPanel>
<div class="table-toolbar">
<el-tabs v-model="queryForm.orderStatus" class="status-tabs" @tab-change="loadOrders()">
<el-tab-pane label="全部底单" name="" />
<el-tab-pane label="待支付" name="1" />
<el-tab-pane label="已支付" name="5" />
<el-tab-pane label="发生退款" name="10" />
</el-tabs>
<el-button
v-if="auth('hard_hardwareOrder_export')"
:icon="Download"
:loading="exporting"
:disabled="loading"
@click="exportOrders"
>
{{ selectedIds.length ? `导出已选 ${selectedIds.length} 笔` : "导出筛选结果" }}
</el-button>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="orders"
row-key="id"
class="order-table"
:header-cell-style="{ background: 'var(--el-fill-color-light)', color: 'var(--el-text-color-regular)' }"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="44" />
<el-table-column
type="index"
label="#"
width="48"
:index="(index: number) => (pagination.current - 1) * pagination.size + index + 1"
/>
<el-table-column label="底单信息" min-width="270">
<template #default="{ row }">
<div class="order-number">
<el-button link type="primary" :title="row.orderNo" @click="openDetail(row)">
{{ row.orderNo || "未记录第三方订单号" }}
</el-button>
</div>
<div class="cell-secondary number-text">平台 {{ row.id }}</div>
<div class="cell-secondary product-desc" :title="row.productDesc">{{ row.productDesc || "未提供商品描述" }}</div>
</template>
</el-table-column>
<el-table-column label="支付用户" min-width="205">
<template #default="{ row }">
<user-info-cell
:user="{ name: row.username, phone: row.userMobile, avatar: row.userAvatar, deptName: row.userDeptName }"
:avatar-size="32"
:show-account="false"
:show-gender="false"
/>
</template>
</el-table-column>
<el-table-column label="商户 / 钱包" min-width="165">
<template #default="{ row }">
<div class="cell-title">{{ row.merchantName || "未记录商户" }}</div>
<div class="cell-secondary">
<dict-tag v-if="row.walletTypeUniqueCode" :options="wallet_type" :value="String(row.walletTypeUniqueCode)" />
<span v-else>未记录钱包类型</span>
</div>
</template>
</el-table-column>
<el-table-column label="支付金额" min-width="140" align="right">
<template #default="{ row }">
<div class="money-value">{{ displayPrice(row.payPrice) }}</div>
<div class="cell-secondary">商品 {{ displayPrice(row.totalPrice) }}</div>
<div v-if="Number(row.extraPrice)" class="cell-secondary">差额 {{ displayPrice(row.extraPrice) }}</div>
</template>
</el-table-column>
<el-table-column label="状态 / 退款" min-width="150">
<template #default="{ row }">
<el-tag :type="orderState(row).type" effect="light" size="small">{{ orderState(row).label }}</el-tag>
<div class="cell-secondary">
已退
<span :class="{ 'refund-amount': Number(row.refundPrice) > 0 }">{{ displayPrice(row.refundPrice) }}</span>
</div>
<div v-if="Number(row.refundPrice) > 0" class="cell-secondary">剩余可退 {{ displayPrice(remainingPrice(row)) }}</div>
</template>
</el-table-column>
<el-table-column label="交易时间" min-width="195">
<template #default="{ row }">
<div class="time-row">
<span>创建</span>
{{ row.createTime || "—" }}
</div>
<div class="time-row">
<span>支付</span>
{{ row.payTime || "—" }}
</div>
<div v-if="row.refundTime" class="time-row">
<span>退款</span>
{{ row.refundTime }}
</div>
</template>
</el-table-column>
<el-table-column label="操作" :fixed="windowWidth >= 1024 ? 'right' : false" width="152">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
<el-tooltip
v-if="auth('hard_hardAfterSale_adminRefund')"
:disabled="!refundDisabledReason(row)"
:content="refundDisabledReason(row)"
placement="top"
>
<span>
<el-button link type="warning" :disabled="Boolean(refundDisabledReason(row))" @click="openRefund(row)">退款</el-button>
</span>
</el-tooltip>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty :description="loadError || '暂无符合条件的第三方支付底单'">
<el-button v-if="loadError" @click="loadOrders(false)">重新加载</el-button>
<el-button v-else @click="resetQuery">重置筛选</el-button>
</el-empty>
</template>
</el-table>
<footer class="table-footer">
<span class="cell-secondary">金额汇总仅统计本页,净收为已支付金额减已退款金额</span>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
:pager-count="5"
:layout="windowWidth < 768 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
background
@size-change="changeSize"
@current-change="loadOrders(false)"
/>
</footer>
</div>
</div>
<order-detail ref="detailRef" @refund="openRefund" />
<refund-dialog ref="refundRef" @success="handleRefundSuccess" />
</div>
</template>
<script setup lang="ts" name="hardThirdOrder">
import { computed, defineAsyncComponent, onMounted, reactive, ref } from "vue"
import { useRoute } from "vue-router"
import { useWindowSize } from "@vueuse/core"
import { Download, Refresh } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
import type { ThirdOrder, ThirdOrderId } from "/@/api/hard/hard-third-order"
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import { auth } from "/@/utils/authFunction"
import { displayPrice } from "/@/utils/formatMoney"
import other from "/@/utils/other"
import { orderState, refundDisabledReason, remainingPrice } from "./order"
const OrderDetail = defineAsyncComponent(() => import("./components/order-detail.vue"))
const RefundDialog = defineAsyncComponent(() => import("./components/refund-dialog.vue"))
const { wallet_type } = useDict("wallet_type")
const { width: windowWidth } = useWindowSize()
const route = useRoute()
const queryRef = ref()
const tableRef = ref()
const detailRef = ref()
const refundRef = ref()
const orders = ref<ThirdOrder[]>([])
const selectedIds = ref<ThirdOrderId[]>([])
const loading = ref(false)
const exporting = ref(false)
const loadError = ref("")
let requestSequence = 0
const initialQuery = () => ({
orderNo: "",
id: "",
userId: undefined as ThirdOrderId | undefined,
merchantId: undefined as ThirdOrderId | undefined,
orderStatus: "",
thirdRefundState: "",
afterSaleNo: "",
walletTypeIdList: [] as ThirdOrderId[],
createTimeList: [
dayjs().subtract(1, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs().endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
payTimeList: [] as string[],
})
const queryForm = reactive(initialQuery())
const pagination = reactive({ current: 1, size: 20, total: 0 })
const hiddenActiveCount = computed(
() =>
[
queryForm.merchantId,
queryForm.thirdRefundState,
queryForm.afterSaleNo,
queryForm.walletTypeIdList.length,
queryForm.payTimeList?.length,
].filter(Boolean).length,
)
const pageSummary = computed(() =>
orders.value.reduce(
(summary, order) => ({
paid: summary.paid + (order.payTime || ["5", "10", "20"].includes(String(order.orderStatus)) ? Number(order.payPrice || 0) : 0),
refunded: summary.refunded + Number(order.refundPrice || 0),
}),
{ paid: 0, refunded: 0 },
),
)
async function loadOrders(resetPage = true) {
if (queryForm.id && !/^\d+$/.test(queryForm.id)) {
useMessage().warning("平台底单号只能包含数字")
return
}
if (resetPage) pagination.current = 1
const sequence = ++requestSequence
loading.value = true
loadError.value = ""
selectedIds.value = []
tableRef.value?.clearSelection()
try {
const { data } = await thirdOrderApi.page({ ...queryForm, current: pagination.current, size: pagination.size })
if (sequence !== requestSequence) return
orders.value = data?.records || []
pagination.total = Number(data?.total || 0)
} catch (error: any) {
if (sequence !== requestSequence) return
orders.value = []
pagination.total = 0
loadError.value = error?.msg || "底单加载失败,请重试"
} finally {
if (sequence === requestSequence) loading.value = false
}
}
function resetQuery() {
Object.assign(queryForm, initialQuery())
queryRef.value?.clearValidate()
loadOrders()
}
function changeSize() {
loadOrders()
}
function handleSelectionChange(rows: ThirdOrder[]) {
selectedIds.value = rows.map((row) => row.id)
}
function openDetail(order: ThirdOrder) {
detailRef.value?.openDialog(order.id)
}
function openRefund(order: ThirdOrder) {
refundRef.value?.openDialog(order.id)
}
async function handleRefundSuccess(orderId: ThirdOrderId) {
await Promise.all([loadOrders(false), detailRef.value?.refreshOrder(orderId)])
}
async function exportOrders() {
if (exporting.value) return
exporting.value = true
try {
await other.downBlobFile(
"/hard/hard-third-order/export",
selectedIds.value.length ? { orderType: "6", orderIds: [...selectedIds.value] } : { ...queryForm, orderType: "6" },
`第三方支付底单_${dayjs().format("YYYYMMDD_HHmmss")}.xlsx`,
)
} finally {
exporting.value = false
}
}
onMounted(() => {
const orderNo = typeof route.query.orderNo === "string" ? route.query.orderNo : ""
const id = typeof route.query.id === "string" ? route.query.id : ""
if (orderNo || id) Object.assign(queryForm, { orderNo, id, createTimeList: [] })
loadOrders()
})
</script>
<style scoped lang="scss">
.third-order-view {
padding: 0;
color: var(--el-text-color-primary);
background: var(--el-bg-color);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.page-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
}
.title-mark {
width: 4px;
height: 20px;
border-radius: 2px;
background: var(--el-color-primary);
}
.page-header p {
margin: 6px 0 0 14px;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 20px;
}
.page-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 20px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
}
.summary-item {
padding: 16px 20px;
> span {
display: block;
font-size: 12px;
color: var(--el-text-color-regular);
}
strong {
display: block;
margin-top: 8px;
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
small {
margin-left: 6px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-regular);
}
+ .summary-item {
border-left: 1px solid var(--el-border-color-lighter);
}
}
.refund-amount {
color: var(--el-color-warning-dark-2);
}
:deep(.search-panel__form .el-form-item) {
width: 280px;
margin-right: 16px;
}
:deep(.search-panel__form .range-field) {
width: 440px;
}
:deep(.el-form-item__content) {
min-width: 0;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 4px;
}
.status-tabs {
min-width: 0;
flex: 1;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
}
.order-table {
width: 100%;
:deep(.el-table__cell) {
padding: 14px 0;
}
:deep(.el-table__body .cell) {
line-height: 22px;
}
}
.order-number {
overflow: hidden;
.el-button {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
line-height: 24px;
}
}
.cell-secondary {
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 22px;
}
.cell-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.number-text {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
.product-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.money-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.time-row {
font-size: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
span {
color: var(--el-text-color-regular);
margin-right: 8px;
}
}
.row-actions {
display: flex;
align-items: center;
gap: 14px;
white-space: nowrap;
.el-button + .el-button {
margin: 0;
}
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding-top: 16px;
}
@media (max-width: 1024px) {
.page-header {
padding: 16px;
}
.page-scroll {
padding: 12px 16px 16px;
}
.summary-item {
padding: 14px;
strong {
font-size: 21px;
}
}
}
@media (max-width: 767px) {
.page-header {
padding: 12px;
}
.page-title h1 {
font-size: 17px;
}
.page-header p {
margin-left: 0;
}
.page-scroll {
padding: 12px;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-item:nth-child(3) {
border-left: 0;
}
.summary-item:nth-child(n + 3) {
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(.search-panel__form .el-form-item),
:deep(.search-panel__form .range-field) {
width: 100%;
margin-right: 0;
}
:deep(.search-panel__form .range-field) {
display: block;
.el-form-item__label {
display: block;
height: auto;
margin-bottom: 4px;
line-height: 20px;
text-align: left;
}
.el-form-item__content {
width: 100%;
}
}
.table-toolbar {
align-items: stretch;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.table-toolbar > .el-button {
align-self: flex-end;
}
.table-footer {
overflow-x: auto;
}
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import type { ThirdOrder, ThirdOrderRefund } from "/@/api/hard/hard-third-order"
type StatusType = "success" | "warning" | "danger" | "info" | ""
/** 部分退款与全额退款共用 orderStatus=10,必须结合累计退款金额判断。 */
export function refundState(order: ThirdOrder): { label: string; type: StatusType } {
if (String(order.refundStatus) === "10") return { label: "退款处理中", type: "warning" }
const refunded = Number(order.refundPrice || 0)
if (refunded > 0 && refunded >= Number(order.payPrice || 0)) return { label: "已全额退款", type: "info" }
if (refunded > 0) return { label: "已部分退款", type: "warning" }
if (String(order.refundStatus) === "30") return { label: "退款未通过", type: "danger" }
return { label: "未退款", type: "info" }
}
export function orderState(order: ThirdOrder): { label: string; type: StatusType } {
if (Number(order.refundPrice || 0) > 0 || String(order.refundStatus) === "10") return refundState(order)
const states: Record<string, { label: string; type: StatusType }> = {
"1": { label: "待支付", type: "warning" },
"2": { label: "待支付", type: "warning" },
"5": { label: "已支付", type: "success" },
"6": { label: "离线支付", type: "success" },
"10": { label: "退款", type: "warning" },
"20": { label: "已核销", type: "success" },
"30": { label: "已取消", type: "info" },
}
return states[String(order.orderStatus)] || { label: `状态 ${order.orderStatus || "未知"}`, type: "info" }
}
export const remainingPrice = (order: ThirdOrder) =>
order.payTime && ["5", "10", "20"].includes(String(order.orderStatus))
? Math.max(0, Number(order.payPrice || 0) - Number(order.refundPrice || 0))
: 0
export function refundDisabledReason(order: ThirdOrder, refunds: ThirdOrderRefund[] = []): string {
if (String(order.orderType) !== "6") return "仅支持第三方应用支付底单"
if (!["5", "10", "20"].includes(String(order.orderStatus)) || !order.payTime) return "订单尚未完成支付"
if (String(order.payType) !== "3" || !order.walletId) return "该支付方式不支持底单钱包退款"
if (String(order.refundStatus) === "10" || refunds.some((item) => String(item.status) === "10")) return "存在处理中的退款"
if (remainingPrice(order) <= 0) return "订单已全额退款"
return ""
}
export function saleState(status: string): { label: string; type: StatusType } {
return (
(
{
"10": { label: "申请中", type: "warning" },
"50": { label: "退款完成", type: "success" },
"61": { label: "已取消", type: "info" },
"62": { label: "已拒绝", type: "danger" },
} as Record<string, { label: string; type: StatusType }>
)[String(status)] || { label: `状态 ${status || "未知"}`, type: "info" }
)
}
/** 历史第三方退款未写 refundTime 时,以已完成售后单的审核时间补充展示。 */
export const refundTime = (refund: ThirdOrderRefund) =>
refund.refundTime || (String(refund.status) === "50" ? refund.auditTime : undefined) || refund.createTime
export function apiResult(log: { error?: string; res?: string }): { label: string; type: StatusType } {
if (log.error) return { label: "调用异常", type: "danger" }
try {
const result = JSON.parse(log.res || "null")
if (result?.code === 200) return { label: "成功", type: "success" }
if (result?.code != null) return { label: result.message || `返回码 ${result.code}`, type: "danger" }
} catch {
// 保留原始响应预览,无法解析时不推断调用成功。
}
return { label: "查看响应", type: "info" }
}
+3 -3
View File
@@ -6,7 +6,7 @@
:class="['nav-menu-item-top', item.id == currentActiveMenuId ? 'nav-menu-item-active' : '']"
@click="handleClickMenvLevel1(item)"
>
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
<div class="nav-menu-item-name">{{ $t(item.name) }}</div>
</div>
</el-col>
@@ -27,7 +27,7 @@
:class="['nav-menu-item-bottom', item.id == curLevel2ActiveMenuId ? 'nav-menu-item-active' : '']"
@click="handleClickBottomMenu(item)"
>
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
<div class="nav-menu-item-name">
{{ $t(item.name) }}
<el-icon v-if="item.children?.length > 0"><CaretBottom /></el-icon>
@@ -47,7 +47,7 @@
:key="index"
>
<div v-if="!item.meta?.isHide" class="nav-menu-item-bottom" @click="handleClickNavMenuLevel3(item)">
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
<div class="nav-menu-item-name">{{ $t(item.name) }}</div>
</div>
</el-col>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="text-center">
<p class="text-xs text-gray-600 text-center mt-8">&copy; {{ currentNew.createBy }} {{ currentNew.createTime }}</p>
</div>
<p class="text-sm text-gray-600 text-justify mt-8 mb-6" v-html="currentNew.content"></p>
<p class="text-sm text-gray-600 text-justify mt-8 mb-6" v-html="$fileHtml(currentNew.content)"></p>
</div>
</div>
</el-drawer>
@@ -10,7 +10,7 @@ export default {
<el-card class="flex items-center justify-center h-[191px]">
<div class="flex items-center justify-center">
<div class="relative">
<img class="object-cover w-16 h-16 rounded-full" :src="userData.avatar" alt="Avatar" />
<img class="object-cover w-16 h-16 rounded-full" :src="$fileUrl(userData.avatar)" alt="Avatar" @error="$fileRetry(userData.avatar)" />
<div class="absolute inset-0 rounded-full shadow-inner"></div>
</div>
<div class="ml-8">
@@ -61,7 +61,7 @@ const initUserInfo = async (userId: any): Promise<void> => {
userData.value = res.data; // 将查询到的数据保存到 userData 变量中
userData.value.postName = res.data?.postList?.map((item: any) => item.postName).join(',') || ''; // 将 postList 中的 postName 合并成字符串并保存到 userData 变量中
// 文件上传增加后端前缀
userData.value.avatar = proxy.baseURL + res.data.avatar;
userData.value.avatar = res.data.avatar;
} finally {
loading.value = false; // 结束加载状态
}
+4 -3
View File
@@ -50,7 +50,7 @@
</el-table-column>
<el-table-column prop="downloadUrl" label="下载地址" width="220" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.downloadUrl" target="_blank" :underline="false">打开</el-link>
<el-link :href="$fileUrl(row.downloadUrl)" target="_blank" :underline="false">下载</el-link>
<el-divider direction="vertical" />
<el-button text type="primary" icon="CopyDocument" @click="copyUrl(row.downloadUrl)">复制</el-button>
</template>
@@ -89,6 +89,7 @@
</template>
<script setup lang="ts" name="systemAppVersion">
import { accessFileUrl, isProtectedFile } from '/@/utils/file-access';
import { BasicTableProps, useTable } from "/@/hooks/table";
import * as appVersionApi from "/@/api/integrated/app-version";
import { useMessage, useMessageBox } from "/@/hooks/message";
@@ -157,8 +158,8 @@ const handleDelete = async (ids: string[]) => {
const copyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url || '')
useMessage().success('已复制下载链接')
await navigator.clipboard.writeText(accessFileUrl(url))
useMessage().success(isProtectedFile(url) ? '已复制临时文件地址,请通过应用加载,不支持浏览器直接打开' : '已复制下载链接')
} catch (e) {
useMessage().error('复制失败')
}
+1 -1
View File
@@ -43,7 +43,7 @@
<span class="tech-notice-time">{{ item.createTime?.slice(5, 16) }}</span>
<span class="tech-notice-title" @click="toggleNoticeDetail(idx)">{{ item.title }}</span>
</div>
<div v-if=" hasValidDescription(item.description)" class="tech-notice-description" v-html="item.description"></div>
<div v-if=" hasValidDescription(item.description)" class="tech-notice-description" v-html="$fileHtml(item.description)"></div>
</div>
</div>
</div>
+1 -1
View File
@@ -58,7 +58,7 @@
</el-col>
<el-col :span="1"></el-col>
<el-col :span="8">
<img :src="imgSrc" @click="getVerifyImageCode" />
<img :src="$fileUrl(imgSrc)" @click="getVerifyImageCode" @error="$fileRetry(imgSrc)" />
</el-col>
</el-form-item>
<el-form-item class="mt-4 login-animation4">
+2 -2
View File
@@ -4,9 +4,9 @@
<div class="relative transition-transform duration-300 transform group-hover:scale-110 group-hover:-translate-y-1">
<img
class="w-24 h-24 rounded-lg ring-gray-200 dark:ring-slate-700 dark:bg-slate-800"
:src="!themeConfig.miniQr ? miniQr : baseURL + themeConfig.miniQr"
:src="$fileUrl(!themeConfig.miniQr ? miniQr : themeConfig.miniQr)"
:alt="t('scan.wechatApp')"
/>
@error="$fileRetry(!themeConfig.miniQr ? miniQr : themeConfig.miniQr)" />
</div>
<!-- 底部文字 -->
<div class="mt-2">
+2 -2
View File
@@ -14,9 +14,9 @@
>
<img
class="w-[85%] h-auto max-h-[650px] object-contain opacity-100 dark:opacity-75 transition-opacity duration-300 hover:opacity-90"
:src="!themeConfig.background ? illustration : baseURL + themeConfig.background"
:src="$fileUrl(!themeConfig.background ? illustration : themeConfig.background)"
alt="Login illustration"
/>
@error="$fileRetry(!themeConfig.background ? illustration : themeConfig.background)" />
</div>
<!-- 右侧登录表单区域 -->
+337 -147
View File
@@ -1,154 +1,194 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" width="50%" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="140px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="商户名:" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入商户名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="部门:" prop="deptId">
<select-dept v-model="form.deptId" placeholder="请选择部门" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="商户类型:" prop="types">
<el-radio-group v-model="form.types">
<el-radio :label="item.value" :disabled="merchantTypeRadioDisabled"
v-for="(item, index) in merchant_type" border :key="index"
@click="handleClickRadio(item.value)">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24" class="mb20">
<el-form-item label="合作商家类型:" prop="supType">
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
<el-radio border :label="item.value" v-for="(item, index) in wallet_mch_sup_type"
:key="index">{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
<el-form-item label="合作状态:" prop="cooperationStatus">
<el-radio-group v-model="form.cooperationStatus">
<el-radio border :label="item.value" v-for="(item, index) in yes_no_type" :key="index">{{
item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
<el-form-item label="品牌:" prop="parentId">
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24" class="mb20">
<el-form-item label="分类:" prop="catgoryId">
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="商户logo:" prop="logoPic">
<upload-img v-model:imageUrl="form.logoPic" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="轮播图:" prop="swiperPic">
<upload-imgs v-model="form.swiperPic" />
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item prop="sort">
<template #label>
<span>排序:</span>
<tip content="越大越在前" />
</template>
<el-input-number v-model="form.sort" placeholder="" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24" class="mb20">
<el-form-item prop="sort">
<template #label>
<span>是否对外开放:</span>
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
</template>
<el-radio-group v-model="form.canteenIsOpen">
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>
{{ v.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="12" class="mb20">
<el-form-item label="优惠价格:" prop="discountsPrice">
<el-input v-model="form.discountsPrice" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="12" class="mb20">
<el-form-item prop="discountsRate">
<template #label>
<span>优惠折扣:</span>
<tip content="范围1-99,填10表示1折;折扣越大(数值越小)的手机端排在越前面,留空表示无折扣" />
</template>
<div class="flex w-full items-center">
<el-input-number class="flex-1" v-model="form.discountsRate" :min="1" :max="99"
:value-on-clear="null" placeholder="如10表示1折" />
<span class="ml5">%</span>
</div>
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item prop="phone">
<template #label>
<span>联系电话:</span>
<tip content="多个联系电话用/分隔" />
</template>
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item prop="platformRanke">
<template #label>
<span>平台抽成:</span>
<tip content="多个联系电话用/分隔" />
</template>
<el-input v-model.trim="form.platformRanke" placeholder="请输入平台抽成">
<template #append>
<span>%</span>
</template>
</el-input>
<el-alert style="margin-top: 10px;"
:title="`该商户的平台抽成是${form.platformRanke}%,即每一笔订单的抽成金额为${form.platformRanke}%的订单金额`"
type="success" :closable="false" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24" class="mb20">
<el-form-item label="营业时间:" class="my-0" />
<el-form-item class="ml-2" label="早上" prop="jobTime">
<el-time-picker v-model="jobTime.morning" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item class="ml-2" label="中午" prop="jobTime">
<el-time-picker v-model="jobTime.noon" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item class="ml-2" label="晚上" prop="jobTime">
<el-time-picker v-model="jobTime.evening" placeholder="请选择营业时间" is-range range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="店铺地址:" prop="addressDes">
<el-input type="textarea" v-model.trim="form.addressDes" placeholder="请输入店铺地址" />
</el-form-item>
</el-col>
</el-row>
<el-dialog
class="merchant-form-dialog"
:title="form.id ? '编辑商户' : '新增商户'"
width="min(1040px, calc(100vw - 32px))"
v-model="visible"
:close-on-click-modal="false"
draggable
>
<el-form
ref="dataFormRef"
class="merchant-form"
:model="form"
:rules="dataRules"
label-width="112px"
v-loading="loading"
scroll-to-error
>
<section class="form-section" aria-label="基本信息">
<h2 class="form-section-title">基本信息</h2>
<div class="form-section-body">
<el-row :gutter="24">
<el-col :xs="24" :sm="12">
<el-form-item label="商户名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入商户名称" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="所属部门" prop="deptId">
<select-dept v-model="form.deptId" placeholder="请选择部门" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商户类型" prop="types">
<el-radio-group v-model="form.types">
<el-radio
v-for="item in merchant_type"
:key="item.value"
:label="item.value"
:disabled="merchantTypeRadioDisabled"
border
@click="handleClickRadio(item.value)"
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :span="24">
<el-form-item label="合作商家类型" prop="supType">
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
<el-radio v-for="item in wallet_mch_sup_type" :key="item.value" border :label="item.value">{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :xs="24" :sm="12">
<el-form-item label="合作状态" prop="cooperationStatus">
<el-radio-group v-model="form.cooperationStatus">
<el-radio v-for="item in yes_no_type" :key="item.value" border :label="item.value">{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2' && form.supType == '20'" :xs="24" :sm="12">
<el-form-item label="所属品牌" prop="parentId">
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
<el-form-item label="商户分类" prop="catgoryId">
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item prop="phone">
<template #label>
<span>联系电话</span>
<tip content="多个联系电话用/分隔" />
</template>
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item prop="sort">
<template #label>
<span>显示排序</span>
<tip content="越大越在前" />
</template>
<el-input-number v-model="form.sort" placeholder="请输入排序" />
</el-form-item>
</el-col>
</el-row>
</div>
</section>
<section class="form-section" aria-label="图片资料">
<h2 class="form-section-title">图片资料</h2>
<div class="form-section-body">
<el-row :gutter="24">
<el-col :xs="24" :md="8">
<el-form-item label="商户 Logo" prop="logoPic">
<upload-img v-model:imageUrl="form.logoPic" width="120px" height="120px" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="16">
<el-form-item label="商户轮播图" prop="swiperPic">
<upload-imgs v-model="form.swiperPic" width="120px" height="120px" />
</el-form-item>
</el-col>
</el-row>
</div>
</section>
<section class="form-section" aria-label="经营设置">
<h2 class="form-section-title">经营设置</h2>
<div class="form-section-body">
<el-row :gutter="24">
<el-col :xs="24" :sm="12">
<el-form-item prop="platformRanke">
<template #label>
<span>平台抽成</span>
<tip content="按订单金额计算平台抽成比例" />
</template>
<div class="field-with-help">
<el-input v-model.trim="form.platformRanke" placeholder="请输入平台抽成">
<template #append>%</template>
</el-input>
<p class="field-help">每笔订单按订单金额的 {{ form.platformRanke || 0 }}% 计算平台抽成。</p>
</div>
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
<el-form-item label="优惠价格" prop="discountsPrice">
<el-input v-model="form.discountsPrice" placeholder="请输入优惠价格" />
</el-form-item>
</el-col>
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
<el-form-item prop="discountsRate">
<template #label>
<span>优惠折扣</span>
<tip content="范围1-99,填10表示1折;折扣越大(数值越小)的手机端排在越前面,留空表示无折扣" />
</template>
<div class="field-with-help">
<div class="discount-input">
<el-input-number v-model="form.discountsRate" :min="1" :max="99" :value-on-clear="null" placeholder="如10表示1折" />
<span>%</span>
</div>
<p class="field-help">留空表示无折扣。</p>
</div>
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24">
<el-form-item prop="canteenIsOpen">
<template #label>
<span>对外开放</span>
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
</template>
<el-radio-group v-model="form.canteenIsOpen">
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>{{ v.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="form.types == '1'" :span="24">
<el-form-item label="营业时间" prop="jobTime">
<div class="job-time-grid">
<div v-for="slot in jobTimeSlots" :key="slot.key" class="job-time-item">
<span class="job-time-label">{{ slot.label }}</span>
<el-time-picker
v-model="jobTime[slot.key]"
is-range
range-separator="至"
:start-placeholder="`${slot.label}开始时间`"
:end-placeholder="`${slot.label}结束时间`"
value-format="HH:mm"
format="HH:mm"
/>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="店铺地址" prop="addressDes">
<el-input type="textarea" v-model.trim="form.addressDes" :rows="2" resize="vertical" placeholder="请输入店铺地址" />
</el-form-item>
</el-col>
</el-row>
</div>
</section>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
<el-button type="primary" @click="onSubmit" :loading="loading" :disabled="loading">确认</el-button>
</template>
</el-dialog>
</template>
@@ -202,6 +242,13 @@ const jobTime = reactive<{
})
const merchantTypeRadioDisabled = ref(false)
/** 营业时间仅调整展示布局,沿用当前接口的早、中、晚三个时段及序列化顺序。 */
const jobTimeSlots = [
{ key: "morning", label: "早上" },
{ key: "noon", label: "中午" },
{ key: "evening", label: "晚上" },
]
// 定义校验规则
const dataRules = {
name: [{ required: true, message: "商户名不能为空", trigger: "change" }],
@@ -318,3 +365,146 @@ defineExpose({
openDialog,
})
</script>
<style scoped lang="scss">
.form-section {
margin-bottom: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
background: var(--el-bg-color);
&:last-child {
margin-bottom: 0;
}
}
.form-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 12px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
line-height: 20px;
&::before {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--el-color-primary);
content: "";
}
}
.form-section-body {
padding: 20px 20px 0;
// 全局 last-of-type 会清零各列内表单项的间距,统一由列保留行距与校验提示空间。
:deep(.el-col) {
margin-bottom: 24px;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-select),
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.el-radio.is-bordered) {
margin: 0;
}
// 已上传缩略图与添加入口保持一致,避免编辑回填后出现两种尺寸。
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 120px;
height: 120px;
}
}
.field-with-help {
width: 100%;
}
.field-help {
margin: 6px 0 0;
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 20px;
}
.discount-input {
display: flex;
align-items: center;
gap: 8px;
:deep(.el-input-number) {
flex: 1;
min-width: 0;
}
}
.job-time-grid {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: 12px 16px;
}
.job-time-item {
min-width: 0;
:deep(.el-date-editor) {
width: 100%;
min-width: 0;
}
}
.job-time-label {
display: block;
margin-bottom: 6px;
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 20px;
}
@media (max-width: 767px) {
.form-section-title {
padding: 12px;
}
.form-section-body {
padding: 16px 12px 0;
}
.merchant-form {
:deep(.el-form-item) {
display: block;
}
:deep(.el-form-item__label) {
justify-content: flex-start;
width: auto !important;
height: auto;
margin-bottom: 8px;
line-height: 20px;
}
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
}
</style>
+524 -179
View File
@@ -1,175 +1,273 @@
<template>
<div class="layout-padding">
<div class="layout-padding mch-manage-page">
<div class="layout-padding-auto layout-padding-view">
<DraggableDepTree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
:defaultTreeWidth="20" :nodeClickable="false" :props="{
<DraggableDepTree
ref="draggableDepTreeRef"
placeholder="请输入部门名称"
:show-expand="true"
@getSelectNode="handleGetSelectNode"
switch-inactive-text="包含"
switch-active-text="不包含"
:defaultTreeWidth="20"
:nodeClickable="false"
:props="{
label: 'name',
children: 'children',
value: 'id',
disabled: 'isLockNone',
}">
}"
>
<template #default>
<el-form v-if="showSearch" ref="queryRef" :inline="true" :model="state.queryForm"
@keyup.enter="getDataList">
<el-row>
<el-form-item style="width: 250px" label="商户名" prop="name">
<el-input v-model="state.queryForm.name" placeholder="商户名" />
</el-form-item>
<el-form-item style="width: 250px" label="商户类型" prop="types">
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否显示在移动端" prop="visibleMobile">
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否同步正元" prop="isSyncZy">
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" />
</el-form-item>
<el-form-item style="width: 250px" label="是否对外开放" prop="canteenIsOpen">
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" />
</el-form-item>
<el-form-item>
<el-button icon="Search" type="primary" @click="getDataList">
{{ $t("common.queryBtn") }}
</el-button>
<el-button icon="Refresh" @click="resetQuery">{{ $t("common.resetBtn") }}</el-button>
</el-form-item>
</el-row>
</el-form>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()"
v-auth="'wallet_mchManage_add'">
新 增
</el-button>
<el-button plain icon="RefreshRight" type="primary" :loading="refreshAllQrcodeLoading"
v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshAllQrcode">
刷新全部二维码
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary"
v-auth="'wallet_mchManage_del'" @click="handleDelete(selectObjs)">
删 除
</el-button>
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_mchManage_export'"
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
@queryTable="getDataList" />
<header class="page-heading">
<h1>商户管理</h1>
<p>管理商户资料、收款二维码和展示设置</p>
</header>
<el-form
v-if="showSearch"
ref="queryRef"
class="merchant-search"
label-position="top"
:model="state.queryForm"
@keyup.enter="getDataList"
>
<el-form-item label="商户名称" prop="name">
<el-input v-model="state.queryForm.name" placeholder="请输入商户名称" clearable />
</el-form-item>
<el-form-item label="商户类型" prop="types">
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" />
</el-form-item>
<el-form-item label="移动端展示" prop="visibleMobile">
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" />
</el-form-item>
<el-form-item label="同步正元" prop="isSyncZy">
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" />
</el-form-item>
<el-form-item label="对外开放" prop="canteenIsOpen">
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" />
</el-form-item>
<div class="search-actions">
<el-button icon="Search" type="primary" @click="getDataList">{{ $t("common.queryBtn") }}</el-button>
<el-button icon="Refresh" @click="resetQuery">{{ $t("common.resetBtn") }}</el-button>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading"
:header-cell-style="tableStyle.headerCellStyle" :row-class-name="tableRowClassName"
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :default-expand-all="false"
:indent="30" row-key="id">
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="id" label="商户id" align="left" min-width="180" />
<el-table-column prop="name" label="商户名" align="left" min-width="160" />
<el-table-column prop="logoPic" label="logo" align="center">
<template #default="{ row }">
<preview-image :src="row.logoPic" />
</template>
</el-table-column>
<el-table-column prop="collectionCode" width="150" align="center" label="商户收款二维码">
<template #default="{ row }">
<div class="flex flex-col justify-center items-center">
<preview-image :src="row.collectionCode" download
:downloadName="`${row.name} 收款二维码`" />
</div>
</template>
</el-table-column>
<el-table-column prop="collectionUrlCode" width="150" align="center" label="商户url收款码">
<template #default="{ row }">
<div class="flex flex-col justify-center items-center">
<preview-image :src="row.collectionUrlCode" download
:downloadName="`${row.name} 商户url收款码`" />
</div>
</template>
</el-table-column>
<el-table-column prop="merchantType" label="商户类型" align="center" :min-width="160">
<template #default="{ row }">
<div class="flex">
<div class="flex justify-center items-center">
<div v-for="(v, i) in row.types?.split(',')" :key="i" class="mr-1">
<dict-tag :options="merchant_type" :value="v" />
</el-form>
<section class="merchant-list" aria-label="商户列表">
<div class="table-toolbar">
<div class="toolbar-actions">
<h2>商户列表</h2>
<el-button icon="Plus" type="primary" @click="formDialogRef.openDialog()" v-auth="'wallet_mchManage_add'">
新增商户
</el-button>
<el-button
plain
icon="RefreshRight"
:loading="refreshAllQrcodeLoading"
v-auth="'wallet_mchManage_refresh_qrcode'"
@click="refreshAllQrcode"
>
刷新全部二维码
</el-button>
<el-button
plain
:disabled="multiple"
icon="Delete"
type="danger"
v-auth="'wallet_mchManage_del'"
@click="handleDelete(selectObjs)"
>
批量删除
</el-button>
<span v-if="selectObjs.length" class="selection-count">已选 {{ selectObjs.length }} 项</span>
</div>
<right-toolbar
v-model:showSearch="showSearch"
:export="'wallet_mchManage_export'"
@exportExcel="exportExcel"
:gutter="0"
@queryTable="getDataList"
/>
</div>
<el-table
class="merchant-table"
:data="state.dataList"
v-loading="state.loading"
:header-cell-style="tableStyle.headerCellStyle"
:row-class-name="tableRowClassName"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
:indent="30"
row-key="id"
>
<el-table-column type="selection" width="44" align="center" />
<el-table-column type="index" label="#" width="48" align="center" />
<el-table-column prop="name" label="商户信息" min-width="300">
<template #default="{ row }">
<div class="merchant-identity">
<preview-image v-if="row.logoPic" class="merchant-logo" width="40px" height="40px" :src="row.logoPic" />
<el-avatar v-else class="merchant-logo" :size="40" shape="square">{{ row.name?.slice(0, 1) || "商" }}</el-avatar>
<div class="min-w-0 flex-1">
<div class="merchant-name-row">
<strong class="merchant-name" :title="row.name">{{ row.name || "未命名商户" }}</strong>
<el-tag v-if="row.parentId && row.parentId != '-1'" size="small" type="success" effect="plain">分店</el-tag>
<el-tag v-else-if="Number(row.mchCount) > 0" size="small" effect="plain">{{ row.mchCount }} 家分店</el-tag>
</div>
<div class="cell-secondary merchant-id" :title="String(row.id)">ID:{{ row.id }}</div>
<div class="cell-secondary truncate" :title="row.deptName">{{ row.deptName || "未关联部门" }}</div>
</div>
</div>
<el-tag v-if="row.catgoryName" type="primary">{{ row.catgoryName }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="isSyncZy" label="平台抽成" min-width="120" align="center">
<template #default="{ row }">
<span>{{ row?.platformRanke ?? 0 }}%</span>
</template>
</el-table-column>
<el-table-column prop="isSyncZy" label="是否同步正元" min-width="120" align="center">
<template #default="{ row }">
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
active-value="1" inactive-value="0" v-model="row.isSyncZy"
@change="isSyncZyChange(row)" />
</template>
</el-table-column>
<el-table-column label="是否在移动端展示" min-width="150" align="center">
<template #default="{ row }">
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
active-value="1" inactive-value="0" v-model="row.visibleMobile"
@change="visibleMobileChange(row)" />
</template>
</el-table-column>
<el-table-column width="200" label="支付默认钱包" align="center">
<template #default="{ row }">
<div class="flex items-center justify-center">
<div class="color-primary">{{ row.walletTypeName }}</div>
<div class="flex items-center just ml5">
<dict-tag :options="wallet_type" :value="row.walletTypeUniqueCode" />
</template>
</el-table-column>
<el-table-column prop="merchantType" label="商户类型" min-width="140">
<template #default="{ row }">
<div class="type-tags">
<dict-tag v-for="v in row.types?.split(',')" :key="v" :options="merchant_type" :value="v" />
<el-tag v-if="row.catgoryName" effect="plain">{{ row.catgoryName }}</el-tag>
<span v-if="!row.types && !row.catgoryName" class="cell-secondary">—</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="收款二维码" width="164" align="center">
<template #default="{ row }">
<div class="qr-cell">
<div class="qr-item" aria-label="商户收款二维码">
<span class="cell-secondary">收款码</span>
<preview-image
v-if="row.collectionCode"
width="40px"
height="40px"
:src="row.collectionCode"
download
:downloadName="`${row.name} 收款二维码`"
/>
<span v-else class="qr-empty">—</span>
</div>
<div class="qr-item" aria-label="商户URL收款码">
<span class="cell-secondary">URL码</span>
<preview-image
v-if="row.collectionUrlCode"
width="40px"
height="40px"
:src="row.collectionUrlCode"
download
:downloadName="`${row.name} 商户url收款码`"
/>
<span v-else class="qr-empty">—</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="结算信息" min-width="170">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title" :title="row.walletTypeName">{{ row.walletTypeName || "未设置默认钱包" }}</span>
<dict-tag v-if="row.walletTypeUniqueCode" :options="wallet_type" :value="row.walletTypeUniqueCode" />
<span class="cell-secondary"
>平台抽成 <strong class="commission-value">{{ row.platformRanke ?? 0 }}%</strong></span
>
</div>
</template>
</el-table-column>
<el-table-column label="展示设置" min-width="150">
<template #default="{ row }">
<div class="display-settings">
<div class="display-setting">
<span>同步正元</span>
<el-switch
:disabled="!isAdmin"
:label="`${row.name} 同步正元`"
active-value="1"
inactive-value="0"
v-model="row.isSyncZy"
@change="isSyncZyChange(row)"
/>
</div>
<div class="display-setting">
<span>移动端展示</span>
<el-switch
:disabled="!isAdmin"
:label="`${row.name} 移动端展示`"
active-value="1"
inactive-value="0"
v-model="row.visibleMobile"
@change="visibleMobileChange(row)"
/>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="businessTime" label="营业时间" min-width="155">
<template #default="{ row }">
<div class="cell-stack business-hours">
<span v-for="(label, index) in ['早上', '中午', '晚上']" :key="label" v-show="row.jobTime?.split(',')[index]">
{{ label }} {{ row.jobTime?.split(",")[index] }}
</span>
<span v-if="!row.jobTime?.split(',').some(Boolean)" class="cell-secondary">—</span>
</div>
</template>
</el-table-column>
<el-table-column label="联系方式 / 地址" min-width="190">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title" :title="row.phone">{{ row.phone || "未填写联系电话" }}</span>
<el-tooltip :content="row.addressDes" :disabled="!row.addressDes" placement="top">
<span class="cell-secondary truncate">{{ row.addressDes || "未填写地址" }}</span>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column prop="createTime" label="创建时间" width="175" />
<el-table-column
label="操作"
fixed="right"
v-auths="['wallet_mchManage_refresh_qrcode', 'wallet_mchManage_setting', 'wallet_mchManage_edit', 'wallet_mchManage_del']"
width="200"
align="center"
>
<template #default="{ row }">
<div class="row-actions">
<el-button
icon="RefreshRight"
link
type="primary"
v-auth="'wallet_mchManage_refresh_qrcode'"
@click="refreshQrcode(row)"
>
刷新二维码
</el-button>
<el-button
icon="setting"
link
type="primary"
v-auth="'wallet_mchManage_setting'"
@click="settingDialogRef.openDialog(row.id)"
>
配置
</el-button>
<el-button
icon="edit-pen"
link
type="primary"
v-auth="'wallet_mchManage_edit'"
@click="formDialogRef.openDialog(row.id)"
>
编辑
</el-button>
<el-button icon="delete" link type="danger" v-auth="'wallet_mchManage_del'" @click="handleDelete([row.id])">
删除
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无符合条件的商户" :image-size="88" />
</template>
</el-table-column>
<el-table-column prop="deptName" label="部门" align="center" min-width="100" />
<el-table-column prop="phone" label="联系电话" :min-width="120" align="center" />
<el-table-column prop="sort" label="排序" :min-width="100" align="center">
<template #default="{ row }">
<span class="color-success">{{ row.sort }}</span>
</template>
</el-table-column>
<el-table-column prop="mchCount" label="分店数量" :min-width="100" align="center">
<template #default="{ row }">
<span class="color-primary">{{ row.mchCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="businessTime" label="营业时间" :min-width="160" align="center">
<template #default="{ row }">
<div>{{ row.jobTime?.split(",")[0] ? "早上:" + row.jobTime?.split(",")[0] : "" }}</div>
<div>{{ row.jobTime?.split(",")[1] ? "中午:" + row.jobTime?.split(",")[1] : "" }}</div>
<div>{{ row.jobTime?.split(",")[2] ? "晚上:" + row.jobTime?.split(",")[2] : "" }}</div>
</template>
</el-table-column>
<el-table-column prop="addressDes" label="地址" :min-width="160" align="center" />
<el-table-column prop="createTime" label="创建时间" :min-width="160" align="center" />
<el-table-column label="操作" fixed="right"
v-auths="['wallet_mchManage_refresh_qrcode', 'wallet_mchManage_setting', 'wallet_mchManage_edit', 'wallet_mchManage_del']"
min-width="300" align="center">
<template #default="{ row }">
<el-button icon="RefreshRight" text type="primary"
v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshQrcode(row)">
刷新二维码
</el-button>
<el-button icon="setting" text type="primary" v-auth="'wallet_mchManage_setting'"
@click="settingDialogRef.openDialog(row.id)">
配置
</el-button>
<el-button icon="edit-pen" text type="primary" v-auth="'wallet_mchManage_edit'"
@click="formDialogRef.openDialog(row.id)">
编辑
</el-button>
<el-button icon="delete" text type="primary" v-auth="'wallet_mchManage_del'"
@click="handleDelete([row.id])"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
v-bind="state.pagination" />
</el-table>
<div class="table-pagination">
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
</section>
</template>
</DraggableDepTree>
</div>
@@ -189,7 +287,10 @@ import FormDialog from "./form.vue"
import Setting from "./setting.vue"
import { useUserInfo } from "/@/stores/userInfo"
const { merchant_type, wallet_type, yes_no_type } = useDict("merchant_type", "wallet_type", "yes_no_type")
const { merchant_type, wallet_type } = useDict("merchant_type", "wallet_type")
/** 展示开关继续仅允许管理员操作,集中复用当前用户角色判断。 */
const isAdmin = computed(() => useUserInfo().userInfos.roleCodes.includes("ROLE_ADMIN"))
// 定义变量内容
const formDialogRef = ref()
@@ -209,7 +310,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
const tableRowClassName = ({ row }: { row: any }) => {
if (row.parentId && row.parentId != "-1") {
return "success-row"
}
@@ -320,31 +421,275 @@ const handleDelete = async (ids: string[]) => {
</script>
<style scoped lang="scss">
.status {
.page-heading {
margin-bottom: 16px;
h1 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
line-height: 28px;
}
p {
margin: 4px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 20px;
}
}
.merchant-search {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
gap: 16px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-lighter);
:deep(.el-form-item) {
min-width: 0;
margin: 0;
}
:deep(.el-form-item__label) {
margin-bottom: 8px;
line-height: 20px;
}
:deep(.el-select) {
width: 100%;
}
}
.search-actions {
display: flex;
grid-column: -2 / -1;
align-items: center;
align-self: end;
justify-content: flex-end;
}
.merchant-list {
min-width: 0;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
background: var(--el-bg-color);
overflow: hidden;
}
.table-toolbar,
.toolbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.table-toolbar {
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
h2 {
margin: 0 4px 0 0;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
}
:deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.top-right-btn .el-row) {
gap: 8px;
}
}
.selection-count,
.cell-secondary {
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 20px;
}
// 使用行内布局,给树表自带的缩进和展开图标保留空间。
.merchant-identity {
display: inline-flex;
width: calc(100% - 28px);
min-width: 0;
align-items: center;
gap: 12px;
vertical-align: middle;
}
.merchant-logo {
flex: none;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.merchant-name-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
:deep(.el-tag) {
flex: none;
}
}
.merchant-name,
.merchant-id,
.cell-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.merchant-name {
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
line-height: 24px;
}
.merchant-id,
.commission-value {
font-variant-numeric: tabular-nums;
}
.type-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qr-cell {
display: flex;
justify-content: center;
gap: 20px;
:deep(.el-image__inner) {
object-fit: contain;
}
}
.qr-item,
.cell-stack {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.qr-item {
align-items: center;
}
.qr-empty {
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
border-radius: var(--el-border-radius-base);
}
.cell-title {
max-width: 100%;
color: var(--el-text-color-primary);
font-weight: 500;
}
.cell-stack > .truncate {
max-width: 100%;
}
.commission-value {
color: var(--el-text-color-primary);
font-weight: 500;
}
.display-settings {
display: grid;
gap: 4px;
}
.display-setting {
display: flex;
align-items: center;
margin-bottom: 2px;
justify-content: space-between;
gap: 8px;
color: var(--el-text-color-regular);
font-size: 12px;
}
span {
display: inline-block;
}
.business-hours {
gap: 2px;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
span:nth-child(1) {
min-width: 60px;
max-width: 100px;
text-align: left;
}
.row-actions {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 12px 16px;
span:nth-child(2) {
flex: 1;
min-width: 50px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
:deep(.el-button) {
justify-content: flex-start;
margin: 0;
}
}
:deep(.el-table) .success-row {
.table-pagination {
padding: 0 16px 16px;
overflow-x: auto;
}
.merchant-table {
:deep(.el-table__cell) {
padding-top: 12px;
padding-bottom: 12px;
}
:deep(.el-image) {
border: 1px solid var(--el-border-color-lighter);
box-shadow: none;
background: var(--el-fill-color-blank);
}
:deep(.el-image__inner:hover) {
transform: none;
}
}
:deep(.merchant-table .success-row) {
--el-table-tr-bg-color: var(--el-color-success-light-9);
}
@media (max-width: 767px) {
.merchant-search,
.table-toolbar {
padding: 12px;
}
.toolbar-actions {
gap: 8px;
h2 {
width: 100%;
}
}
}
</style>
+56 -6
View File
@@ -6,20 +6,50 @@
ref="queryRef"
:model="state.queryForm"
:loading="Boolean(state.loading)"
:hidden-active-count="hiddenActiveCount"
:search-text="$t('common.queryBtn')"
:reset-text="$t('common.resetBtn')"
@search="getDataList"
@reset="resetQuery"
>
<el-form-item style="width: 250px" label="用户" prop="paySourceType">
<dict-select v-model="state.queryForm.apiType" dict-code="third_key_type" />
<el-form-item style="width: 250px" label="发起方" prop="paySourceType">
<dict-select v-model="state.queryForm.paySourceType" dict-code="third_key_type" />
</el-form-item>
<el-form-item style="width: 250px" label="接口类型" prop="bizType">
<dict-select v-model="state.queryForm.bizType" dict-code="third_pay_api_type" />
</el-form-item>
<el-form-item style="width: 400px" label="请求时间" prop="createTimeList">
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD" type="daterange" />
<el-form-item style="width: 320px" label="接口 URL" prop="apiUrl">
<el-input v-model.trim="state.queryForm.apiUrl" placeholder="输入接口路径关键字" clearable />
</el-form-item>
<el-form-item style="width: 400px" label="请求时间" prop="createTimeList">
<el-date-picker
v-model="state.queryForm.createTimeList"
value-format="YYYY-MM-DD"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<template #more>
<el-form-item style="width: 320px" label="请求内容" prop="req">
<el-input v-model.trim="state.queryForm.req" placeholder="输入 req 内容关键字" clearable />
</el-form-item>
<el-form-item style="width: 320px" label="响应内容" prop="res">
<el-input v-model.trim="state.queryForm.res" placeholder="输入 res 内容关键字" clearable />
</el-form-item>
<el-form-item style="width: 320px" label="请求头" prop="header">
<el-input v-model.trim="state.queryForm.header" placeholder="输入 header 内容关键字" clearable />
</el-form-item>
<el-form-item style="width: 320px" label="错误内容" prop="error">
<el-input v-model.trim="state.queryForm.error" placeholder="输入错误内容关键字" clearable />
</el-form-item>
<el-form-item style="width: 320px" label="业务 ID" prop="bizId">
<el-input v-model.trim="state.queryForm.bizId" placeholder="输入完整业务 ID" clearable />
</el-form-item>
<el-form-item style="width: 320px" label="日志 ID" prop="id">
<el-input v-model.trim="state.queryForm.id" placeholder="输入完整日志 ID" clearable />
</el-form-item>
</template>
</SearchPanel>
<el-row>
<div class="mb8" style="width: 100%">
@@ -67,6 +97,11 @@
</template>
</el-table-column>
<el-table-column prop="apiUrl" label="api-url" width="250" />
<el-table-column prop="header" label="header" width="180">
<template #default="{ row }">
<preview-json :json="row.header" />
</template>
</el-table-column>
<el-table-column prop="res" label="res" width="180">
<template #default="{ row }">
<preview-json :json="row.res" />
@@ -133,10 +168,25 @@ const selectObjs = ref([]) as any
const multiple = ref(true)
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
queryForm: {
paySourceType: undefined,
bizType: undefined,
apiUrl: "",
createTimeList: [],
req: "",
res: "",
header: "",
error: "",
bizId: "",
id: "",
},
pageList: thirdPayLogApi.fetchList,
})
const hiddenActiveCount = computed(() =>
["req", "res", "header", "error", "bizId", "id"].filter((key) => String(state.queryForm[key] ?? "").trim() !== "").length
)
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
@@ -151,7 +201,7 @@ const resetQuery = () => {
// 导出excel
const exportExcel = () => {
downBlobFile("/third/thirdPayLog/export", Object.assign(state.queryForm, { ids: selectObjs }), "thirdPayLog.xlsx")
downBlobFile("/third/third-pay-log/export", { ...state.queryForm, ids: selectObjs.value }, "thirdPayLog.xlsx")
}
// 多选事件
+84
View File
@@ -0,0 +1,84 @@
<template>
<el-dialog title="修改核验手机号" v-model="visible" :close-on-click-modal="false" width="480px" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="100px" v-loading="loading">
<el-form-item label="姓名">
<el-input :model-value="form.displayName" disabled />
</el-form-item>
<el-form-item label="员工编码">
<el-input :model-value="form.employeeCode" disabled />
</el-form-item>
<el-form-item label="原手机号">
<el-input :model-value="form.oldPhone" disabled />
</el-form-item>
<el-form-item label="新手机号" prop="verifiedPhone">
<el-input v-model="form.verifiedPhone" maxlength="11" placeholder="请输入正确的11位手机号" clearable />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="loading" @click="onSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="zzdPhoneBindForm">
import { useMessage } from '/@/hooks/message'
import { updatePhone } from '/@/api/thirdparty/zzdPhoneBind'
const emit = defineEmits(['refresh'])
const visible = ref(false)
const loading = ref(false)
const dataFormRef = ref()
const form = reactive({
id: '' as string | number,
displayName: '',
employeeCode: '',
oldPhone: '',
verifiedPhone: '',
})
const dataRules = {
verifiedPhone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' },
],
}
const openDialog = (row: any) => {
visible.value = true
form.id = row?.id
form.displayName = row?.displayName || ''
form.employeeCode = row?.employeeCode || ''
form.oldPhone = row?.verifiedPhone || ''
form.verifiedPhone = row?.verifiedPhone || ''
nextTick(() => {
dataFormRef.value?.clearValidate()
})
}
const onSubmit = async () => {
const valid = await dataFormRef.value?.validate().catch(() => false)
if (!valid) return
if (form.verifiedPhone === form.oldPhone) {
useMessage().warning('手机号未变更')
return
}
loading.value = true
try {
await updatePhone({ id: form.id, verifiedPhone: form.verifiedPhone })
useMessage().success('手机号已更新')
visible.value = false
emit('refresh')
} catch (err: any) {
useMessage().error(err?.msg || err?.message || '更新失败')
} finally {
loading.value = false
}
}
defineExpose({ openDialog })
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :inline="true" :model="state.queryForm" @keyup.enter="getDataList" ref="queryRef">
<el-form-item label="姓名" prop="name">
<el-input v-model="state.queryForm.name" placeholder="名称模糊查询" clearable />
</el-form-item>
<el-form-item label="手机号" prop="verifiedPhone">
<el-input v-model="state.queryForm.verifiedPhone" placeholder="核验手机号" clearable />
</el-form-item>
<el-form-item label="员工编码" prop="employeeCode">
<el-input v-model="state.queryForm.employeeCode" placeholder="GE_…" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<right-toolbar v-model:showSearch="showSearch" class="ml10 mr20" style="float: right" @queryTable="getDataList" />
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@sort-change="sortChangeHandle"
>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="displayName" label="姓名" min-width="100" show-overflow-tooltip />
<el-table-column prop="verifiedPhone" label="核验手机号" width="130" show-overflow-tooltip />
<el-table-column prop="employeeCode" label="员工编码" min-width="200" show-overflow-tooltip />
<el-table-column prop="openId" label="openid" min-width="160" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="170" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button
icon="edit-pen"
text
type="primary"
v-auth="'thirdparty_zzdPhoneBind_edit'"
@click="formDialogRef.openDialog(scope.row)"
>
改手机号
</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
</div>
</template>
<script setup lang="ts" name="thirdpartyZzdPhoneBind">
import { BasicTableProps, useTable } from '/@/hooks/table'
import { fetchList } from '/@/api/thirdparty/zzdPhoneBind'
const FormDialog = defineAsyncComponent(() => import('./form.vue'))
const formDialogRef = ref()
const queryRef = ref()
const showSearch = ref(true)
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
name: '',
verifiedPhone: '',
employeeCode: '',
},
pageList: fetchList,
})
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
const resetQuery = () => {
queryRef.value?.resetFields()
getDataList()
}
</script>
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more