Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b505d9133 | ||
|
|
3c2b967274 | ||
|
|
827c23db1d | ||
|
|
ef61d51f61 | ||
|
|
02bc01f9c9 | ||
|
|
16c82739db | ||
|
|
f73a116a10 | ||
|
|
21275ef770 | ||
|
|
d3735e3afa | ||
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa | ||
|
|
4edb7619bc | ||
|
|
dfb74f3386 | ||
|
|
9fc3385d6c | ||
|
|
c1c75edc55 | ||
|
|
47cccc9ca5 | ||
|
|
5c97c57c76 | ||
|
|
ca0db57f24 | ||
|
|
8335ef780c | ||
|
|
1ad9793ea2 | ||
|
|
45e84722e1 | ||
|
|
f4e00b4d95 | ||
|
|
e740cbf403 | ||
|
|
44f75acebe | ||
|
|
0a100548a9 | ||
|
|
c0298c27a5 | ||
|
|
1fc34f1bcd | ||
|
|
2c325136ab | ||
|
|
631e3000d3 | ||
|
|
269adf1fed | ||
|
|
825fd41ee4 | ||
|
|
2162612892 | ||
|
|
63c18d89c2 | ||
|
|
79ed89c89f | ||
|
|
dc0a62d4c5 | ||
|
|
797b1c46cf | ||
|
|
aa1114338e | ||
|
|
7186a6b694 | ||
|
|
4c98170107 | ||
|
|
b624c43432 | ||
|
|
c6b48d5d91 | ||
|
|
8bd1ab804a | ||
|
|
c195133680 | ||
|
|
60c591fc68 | ||
|
|
438b38d121 | ||
|
|
b60c1ffb71 | ||
|
|
6e76e2f2ea | ||
|
|
76a152bccf | ||
|
|
08b9cb2468 | ||
|
|
469d7194bd | ||
|
|
28cf25756a | ||
|
|
e9b99d020b | ||
|
|
0aa5a7b4e6 | ||
|
|
d605c04707 | ||
|
|
018626935a | ||
|
|
d9c08d85f9 | ||
|
|
04b07b5821 | ||
|
|
0242ea44e8 | ||
|
|
9466b2b636 | ||
|
|
b834fa86a0 | ||
|
|
e666ee6f8a | ||
|
|
2ae72aabe7 | ||
|
|
0da93008d0 | ||
|
|
b67a56479e | ||
|
|
d036c9b986 | ||
|
|
5715987049 | ||
|
|
6b2ff0f73e | ||
|
|
f63e579a8d | ||
|
|
70134dd62e | ||
|
|
b2ef60c04c | ||
|
|
d707fb1f60 | ||
|
|
c061a55151 | ||
|
|
75272a5c21 | ||
|
|
4e1827295d | ||
|
|
ae2405f645 | ||
|
|
4f37df3284 | ||
|
|
f96dcb8aa6 | ||
|
|
16538d7af5 | ||
|
|
c77e6fcd63 | ||
|
|
3ff0c0641a | ||
|
|
9c26d4b7b2 | ||
|
|
c561bf46af | ||
|
|
2766e53fe2 | ||
|
|
af5412646b | ||
|
|
8dee67040c | ||
|
|
69cf6a2cc7 | ||
|
|
ed725daccd | ||
|
|
cbb4f64fdc | ||
|
|
3a4e03a9f9 | ||
|
|
7682abd85c | ||
|
|
730dddde30 | ||
|
|
ae1bd2b734 | ||
|
|
050d005c0a | ||
|
|
2fbdbc32d0 | ||
|
|
0580da0c62 | ||
|
|
22c778b111 | ||
|
|
ff22c0d06a | ||
|
|
d74f0ea099 | ||
|
|
72b4e9b846 | ||
|
|
3f60611a74 | ||
|
|
307c9efb36 | ||
|
|
556f95a562 | ||
|
|
29112eed6e | ||
|
|
755a570849 | ||
|
|
7a2693b239 | ||
|
|
5b3bad326e | ||
|
|
c607e87b45 | ||
|
|
52e11a6c65 | ||
|
|
fd7195ecb0 | ||
|
|
03a03c7c11 | ||
|
|
4e478ce6ba | ||
|
|
e4af367609 | ||
|
|
60eeb8aef5 | ||
|
|
6ae08b89fb | ||
|
|
bc4531a734 | ||
|
|
ce172ffd4f | ||
|
|
3244e02e7e | ||
|
|
687a156350 | ||
|
|
eaf8fdf195 | ||
|
|
9d73087eb0 | ||
|
|
4f91be0cab | ||
|
|
918368159c | ||
|
|
5cbd931fa2 | ||
|
|
0418856ce3 | ||
|
|
e2a8a8c85f | ||
|
|
cf3a84e725 | ||
|
|
eda2ce19a8 | ||
|
|
8b62b5b5df | ||
|
|
0da82c9914 | ||
|
|
984827f415 | ||
|
|
dd58ab6eae | ||
|
|
22325ec2b0 | ||
|
|
adfa4ff31e | ||
|
|
3e5d2314aa | ||
|
|
146ebab61d | ||
|
|
0f06d82a92 | ||
|
|
7009ec079c | ||
|
|
751f14d5ee | ||
|
|
93fc9b0d88 | ||
|
|
e59038790c | ||
|
|
fd5f4d6e80 | ||
|
|
ea2bb2439d | ||
|
|
63cbbff2de | ||
|
|
bb380d40f6 | ||
|
|
10887f40ed | ||
|
|
0518cda95c | ||
|
|
1aea6f7791 | ||
|
|
a6037ded59 | ||
|
|
b7dc52b517 | ||
|
|
36b15d9ca8 | ||
|
|
df47543501 | ||
|
|
311f251609 | ||
|
|
8b8972a77b | ||
|
|
72b0758e8c | ||
|
|
fbafe693b6 | ||
|
|
9279359678 | ||
|
|
ad9d32f544 | ||
|
|
3a634b4ef2 | ||
|
|
097c4c4778 | ||
|
|
61ca737e02 | ||
|
|
eed4400611 | ||
|
|
c66e42dd8a | ||
|
|
1deda1e943 | ||
|
|
56e8329b59 | ||
|
|
8b5d2b35f8 | ||
|
|
d8f3c2a42e | ||
|
|
d8338f225b | ||
|
|
564fc88fc8 | ||
|
|
47dacb5a1b | ||
|
|
aad666d1a0 | ||
|
|
159772f615 | ||
|
|
8550ab980d | ||
|
|
b8c33b9a0c | ||
|
|
ff451d3185 | ||
|
|
8432587b83 | ||
|
|
ac2327b499 |
@@ -0,0 +1,37 @@
|
||||
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
|
||||
#
|
||||
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
|
||||
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
|
||||
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
|
||||
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
|
||||
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
|
||||
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
|
||||
|
||||
# —— 默认兜底 ——
|
||||
* @g1307092382
|
||||
|
||||
# —— 后端(Spring Boot / Java)——
|
||||
/backend/ @g1307092382
|
||||
|
||||
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
|
||||
/backend/src/main/resources/db/migration/ @g1307092382
|
||||
|
||||
# —— 微信小程序(原生 WXML/WXSS/TS)——
|
||||
/miniapp/ @g1307092382
|
||||
|
||||
# —— 管理端(Vue3 / TS / pnpm)——
|
||||
/admin-web/ @g1307092382
|
||||
|
||||
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
|
||||
/tests/ @g1307092382
|
||||
/ui-tests/ @g1307092382
|
||||
/scripts/run-ui-contracts.mjs @g1307092382
|
||||
|
||||
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
|
||||
/infrastructure/ @g1307092382
|
||||
|
||||
# —— 治理与审查机制文档(变更须经技术负责人确认)——
|
||||
/docs/governance/ @g1307092382
|
||||
/.github/ @g1307092382
|
||||
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
|
||||
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
|
||||
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
|
||||
-->
|
||||
|
||||
## 变更摘要
|
||||
<!-- 改了什么、为什么、影响面 -->
|
||||
|
||||
## 风险分级(见审查标准 §2)
|
||||
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
|
||||
- [ ] T1 常规(单端功能/Bug/UI/单 API)
|
||||
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
|
||||
|
||||
## 验证方式(必填,证据优先)
|
||||
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
|
||||
- 本地测试/契约守卫命令与结果:
|
||||
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
|
||||
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
|
||||
|
||||
## 技术栈审查清单(按本 PR 涉及项打勾)
|
||||
### 后端 Java / Spring Boot
|
||||
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
|
||||
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
|
||||
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
|
||||
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
|
||||
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
|
||||
|
||||
### 小程序 miniapp
|
||||
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
|
||||
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
|
||||
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
|
||||
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
|
||||
- [ ] 胶囊安全区让出 `--nav-right`
|
||||
|
||||
### 管理端 admin-web
|
||||
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
|
||||
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
|
||||
- [ ] 表单校验类型安全、出错保留输入
|
||||
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
|
||||
|
||||
### 数据库迁移 SQL
|
||||
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
|
||||
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
|
||||
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
|
||||
|
||||
### 测试 / 契约守卫
|
||||
- [ ] 无空壳 `skip`/`pass` 测试
|
||||
- [ ] 已做负向注入证明判别力
|
||||
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
|
||||
|
||||
### 脚本 / 基础设施
|
||||
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
|
||||
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
|
||||
|
||||
## 判据纪律自查(见审查标准 §5,对应改动必查)
|
||||
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
|
||||
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
|
||||
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
|
||||
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
|
||||
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
|
||||
|
||||
## 已知阻塞 / 决策关联
|
||||
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
|
||||
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
|
||||
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
|
||||
|
||||
## 关联
|
||||
<!-- 关联 Issue / 决策 / 风险编号 -->
|
||||
@@ -25,6 +25,13 @@ jobs:
|
||||
working-directory: backend
|
||||
run: mvn verify -B
|
||||
|
||||
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
|
||||
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
|
||||
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
|
||||
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
|
||||
- name: 后端契约门禁(curated + 下限校验)
|
||||
run: node scripts/run-backend-contracts.mjs
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -42,8 +49,36 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: admin-web/pnpm-lock.yaml
|
||||
# 这里不能写 cache: 'pnpm'。
|
||||
# 2026-09-22 实测:runner 镜像(ubuntu-24.04 / runner 2.337.0)**已不再预装
|
||||
# pnpm**,而 setup-node 的 cache 步骤在 restore 阶段就需要调用 pnpm 可执行文件,
|
||||
# 于是直接报 "Unable to locate executable file: pnpm",整个 job 在第一步失败、
|
||||
# 后续步骤全部 skipped —— 表现为 admin-web job 秒红,且与业务代码无关。
|
||||
# (同批的 miniapp job 没配 cache 所以活了下来,正好反向印证了这一点。)
|
||||
# 因此:先让 pnpm 存在于 PATH,缓存改为手动配置。
|
||||
|
||||
- name: 启用并锁定 pnpm
|
||||
# corepack 随 Node 分发,runner 上没有 pnpm 但有 corepack。
|
||||
# 钉 9.15.9 的理由:pnpm 9 与 10 对「是否默认放行依赖 build 脚本」语义相反,
|
||||
# 而 pnpm-workspace.yaml 里的 onlyBuiltDependencies 正是版本敏感项
|
||||
# (esbuild / vue-demi 的 postinstall 若被静默阻断,会出现 install 能过、
|
||||
# build 变红,且错误信息不指向真正原因)。钉死后不再随 runner 镜像升级而变化。
|
||||
run: |
|
||||
corepack enable pnpm
|
||||
corepack prepare pnpm@9.15.9 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: 解析 pnpm store 路径
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 缓存 pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('admin-web/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: admin-web
|
||||
@@ -79,15 +114,205 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Flyway migrations
|
||||
working-directory: backend
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# Phase 23 KEEP-VERIFY:本步骤原注释称「验证 migration SQL 语法」,实际只检查文件非空,
|
||||
# 属名不副实的假校验(SQL 语法与基线表完整性由后端测试 MigrationValidationTest 覆盖,
|
||||
# 该测试随 backend job 的 `mvn verify` 运行)。现改为该测试未覆盖的**序列**校验。
|
||||
- name: 迁移序列校验(命名 / 非空 / 版本号唯一 / 无缺号)
|
||||
run: |
|
||||
# 验证 migration SQL 语法(通过检查文件是否存在且非空)
|
||||
find src/main/resources/db/migration -name "V*.sql" -type f | sort | while read f; do
|
||||
echo "Validating: $f"
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "ERROR: Empty migration file: $f"
|
||||
exit 1
|
||||
python3 - <<'PY'
|
||||
import re, sys, pathlib, collections
|
||||
|
||||
d = pathlib.Path("backend/src/main/resources/db/migration")
|
||||
files = sorted(d.glob("V*.sql"))
|
||||
if not files:
|
||||
sys.exit("ERROR: 未找到任何迁移文件")
|
||||
|
||||
vers, bad_name, empty = [], [], []
|
||||
for f in files:
|
||||
m = re.match(r"V(\d+)__", f.name)
|
||||
if not m:
|
||||
bad_name.append(f.name)
|
||||
continue
|
||||
if f.stat().st_size == 0:
|
||||
empty.append(f.name)
|
||||
vers.append(int(m.group(1)))
|
||||
|
||||
problems = []
|
||||
if bad_name:
|
||||
problems.append(f"迁移文件命名不合规(应为 V<序号>__<描述>.sql): {bad_name}")
|
||||
if empty:
|
||||
problems.append(f"空迁移文件: {empty}")
|
||||
dups = sorted(v for v, c in collections.Counter(vers).items() if c > 1)
|
||||
if dups:
|
||||
problems.append(f"迁移版本号重复: {dups}")
|
||||
gaps = [v for v in range(min(vers), max(vers) + 1) if v not in set(vers)]
|
||||
if gaps:
|
||||
problems.append(f"迁移版本号缺号: {gaps}")
|
||||
|
||||
if problems:
|
||||
for p in problems:
|
||||
print("ERROR: " + p, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OK: {len(files)} 个迁移文件,版本 {min(vers)}~{max(vers)} 连续且唯一")
|
||||
PY
|
||||
|
||||
test-shell-guard:
|
||||
name: Test Shell Guard (Phase 23 Gate)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# Phase 23 Gate:「不允许 skip/pass 空壳」。
|
||||
# 机械阻断空壳回归 —— 详见 scripts/check-no-empty-tests.py。
|
||||
- name: 禁止 skip/pass 空壳测试
|
||||
run: python3 scripts/check-no-empty-tests.py tests
|
||||
|
||||
ui-contracts:
|
||||
name: UI Contracts (source-structure gates)
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 23 遗留(收尾计划 批 1 Task 1.1):UI 契约门禁此前**只在本地手工执行**,
|
||||
# 没有 CI 把关 —— 即「改动破坏了源码结构契约也不会被发现」。
|
||||
# 2026-09-19:14 → 15 → 18 个守卫。
|
||||
# 第 15 项 WXML 结构守卫(此前 14 绿时小程序仍有两个页面编译失败:
|
||||
# service-history / service-program 各多一个 </view>);
|
||||
# 第 16/17 项:`this._x()` 无定义(运行期 TypeError,曾致所有请求 Promise 永不 settle)
|
||||
# 与组件 WXSS 违规选择器(标签/ID/属性选择器);
|
||||
# 第 18 项:api.js 请求层**行为**契约(桩 wx.request 真跑,非源码断言)。
|
||||
# 统一执行器负责逐个跑 + 汇总 + 非零退出,
|
||||
# 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# 2026-09-22 修正:本 job 此前注释称「零 npm 依赖,故无需装依赖」,这个声明是错的。
|
||||
# 至少 phase04 守卫会 import miniapp/node_modules/typescript/lib/typescript.js
|
||||
# (用 TS 编译器 API 解析小程序源码)。本地能跑通只因为工作区早已装过 miniapp 依赖;
|
||||
# CI 的每个 job 都在**独立 runner**上,miniapp job 装的东西不会共享过来,
|
||||
# 于是这里直接 ERR_MODULE_NOT_FOUND。
|
||||
# 「依赖声明与实际不符」正是本地绿 / CI 红最典型的一类来源。
|
||||
- name: 安装 miniapp 依赖(phase04 守卫需要 typescript 编译器 API)
|
||||
working-directory: miniapp
|
||||
run: npm install
|
||||
|
||||
- name: 运行 UI 契约门禁
|
||||
run: node scripts/run-ui-contracts.mjs
|
||||
|
||||
e2e:
|
||||
name: E2E (live stack)
|
||||
runs-on: ubuntu-latest
|
||||
# 防挂死:GH Actions 默认 360 分钟,起栈失败若无人超时会把流水线拖住数小时。
|
||||
timeout-minutes: 30
|
||||
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
|
||||
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
|
||||
needs: [backend, migration]
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:8085/api/v1
|
||||
# CI 专用测试口令,仅存在于 runner 运行期,不入库、不用于任何真实环境
|
||||
E2E_OPERATOR_PASSWORD: ci-e2e-only-password
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: pip install -r tests/e2e/requirements.txt
|
||||
|
||||
- name: 生成 CI 测试用 .env(仅运行期存在,不入库)
|
||||
run: |
|
||||
cat > infrastructure/.env <<'ENV'
|
||||
DB_PASSWORD=ci-e2e-db-password
|
||||
REDIS_PASSWORD=ci-e2e-redis-password
|
||||
MINIO_ACCESS_KEY=ci-e2e-minio
|
||||
MINIO_SECRET_KEY=ci-e2e-minio-secret
|
||||
JWT_ACCESS_SECRET=ci-e2e-access-secret-only-for-ci
|
||||
JWT_REFRESH_SECRET=ci-e2e-refresh-secret-only-for-ci
|
||||
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
WECHAT_APP_SECRET=
|
||||
ENV
|
||||
|
||||
# 「活栈供给方式」决策(2026-09-18):**在 runner 内用 Compose 起全栈**。
|
||||
# 备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar`,但那需要为后端
|
||||
# 另写一套 host 网络配置,等于制造第二条与本地不一致的路径;Compose 全栈则与本地验证
|
||||
# 路径完全相同(2026-09-18 本机按同一序列实测:中间件三项 healthcheck 全 Healthy、
|
||||
# backend /health/live 200、seed 幂等可执行、**pytest tests/e2e 49 passed**)。
|
||||
- name: 校验 compose 配置(秒级快速失败,早于任何构建)
|
||||
working-directory: infrastructure
|
||||
run: docker compose config --quiet
|
||||
|
||||
# 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。
|
||||
# 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出,
|
||||
# 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 ——
|
||||
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库
|
||||
# (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。
|
||||
- name: 校验 seaweedfs 钉版镜像可拉取并打印版本
|
||||
run: |
|
||||
docker pull chrislusf/seaweedfs:4.47
|
||||
docker run --rm chrislusf/seaweedfs:4.47 version
|
||||
|
||||
# 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留,
|
||||
# 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。
|
||||
- name: 清理可能的残留栈与数据卷
|
||||
working-directory: infrastructure
|
||||
run: docker compose down -v --remove-orphans || true
|
||||
|
||||
# 构建与起栈分两步:构建失败时的日志不混杂在 up 输出里,定位更快。
|
||||
- name: 构建 backend 镜像
|
||||
working-directory: infrastructure
|
||||
run: docker compose build backend
|
||||
|
||||
- name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend)
|
||||
# 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们,
|
||||
# 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。
|
||||
# storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶,
|
||||
# PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的
|
||||
# (E2E 对存储零覆盖,不会替你发现)。
|
||||
working-directory: infrastructure
|
||||
run: docker compose up -d postgres redis seaweedfs storage-init backend
|
||||
|
||||
- name: 等待 backend 就绪(超时即失败,不静默跳过)
|
||||
# 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。
|
||||
run: |
|
||||
for i in $(seq 1 90); do
|
||||
if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then
|
||||
echo "backend READY after ${i} checks"; exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "All migration files validated."
|
||||
echo "::error::backend 未在 7.5 分钟内就绪"
|
||||
docker compose -f infrastructure/docker-compose.yml logs --tail=200 backend
|
||||
exit 1
|
||||
|
||||
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
|
||||
working-directory: infrastructure
|
||||
run: |
|
||||
docker compose exec -T postgres psql -U postgres -d danyang_match \
|
||||
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
|
||||
|
||||
- name: Run E2E
|
||||
# 显式给出目录:pytest.ini 的 `testpaths = tests/e2e` 已限定范围,但显式传参可防
|
||||
# 该配置被改动后悄悄扩大收集范围(仓库根另有 tests/*_test.py 不是 E2E 用例,
|
||||
# 且它们在 import 期即依赖 psycopg2 与 E2E_DB_PASSWORD,会被收集成 error)。
|
||||
run: pytest tests/e2e -v
|
||||
|
||||
- name: Teardown(无论成败都清理并留日志)
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f infrastructure/docker-compose.yml logs backend || true
|
||||
docker compose -f infrastructure/docker-compose.yml down -v || true
|
||||
|
||||
@@ -10,11 +10,30 @@ package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 例外:依赖锁文件必须入库,CI 的可复现安装依赖它们。
|
||||
#
|
||||
# 2026-09-22 修复背景:上面四条规则此前把 admin-web/pnpm-lock.yaml 与
|
||||
# miniapp/package-lock.json 一并挡在版本控制之外,于是 CI checkout 后的环境
|
||||
# 与本机完全不同:
|
||||
# - admin-web:`pnpm install --frozen-lockfile` 找不到 lockfile → 直接失败
|
||||
# (frozen 模式的语义就是「必须存在且不可变更」,缺文件不是警告而是错误);
|
||||
# - miniapp:`npm install` 无 lockfile 约束,版本随上游浮动,
|
||||
# `npx tsc --noEmit` 的结果不与任何一次本地验证对齐。
|
||||
# 二者共同导致「本地绿、CI 红」且二者都不可解释。
|
||||
#
|
||||
# 只放行每个包真正使用的那把锁:admin-web 走 pnpm,miniapp 走 npm。
|
||||
# admin-web/package-lock.json 仍然忽略 —— 同一目录并存两把锁会让两种包管理器
|
||||
# 各自按自己的锁安装,漂移时彼此看不出问题,属于典型的双锁冲突。
|
||||
!admin-web/pnpm-lock.yaml
|
||||
!miniapp/package-lock.json
|
||||
|
||||
# Build outputs
|
||||
backend/target/
|
||||
admin-web/dist/
|
||||
miniapp/dist/
|
||||
miniapp/unpackage/
|
||||
# vite 在 dev server 被强杀(未优雅退出)时会遗留配置临时文件
|
||||
admin-web/vite.config.ts.timestamp-*.mjs
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -41,6 +60,7 @@ tests/fixtures/*.json
|
||||
# Local workspace and developer settings
|
||||
.worktrees/
|
||||
.zcode/
|
||||
.workbuddy/
|
||||
project.private.config.json
|
||||
/nul
|
||||
/infrastructure/nul
|
||||
@@ -50,7 +70,22 @@ Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Secrets and certificates
|
||||
docker-compose.prod.yml
|
||||
#
|
||||
# 2026-09-22 修正:`docker-compose.prod.yml` 原先被列在本段(当作密钥)而排除出
|
||||
# 版本控制,这是**误分类** —— 该文件不含任何明文密钥:所有敏感值都通过
|
||||
# `${VAR:?请设置...}` 注入,db 密码走 Docker secrets(./secrets/db_password.txt),
|
||||
# 文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。
|
||||
#
|
||||
# 后果:backend 的 `DeploymentTests.testDockerComposeProdExists` 断言该文件存在,
|
||||
# 于是「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」——
|
||||
# 一次典型的本地绿/CI 红假绿,且从本地永远发现不了。
|
||||
# 生产部署配置需要可复现、可评审,应与 staging 版一致纳入版本控制。
|
||||
# 真正的密钥仍由下面三条挡住。
|
||||
infrastructure/secrets/
|
||||
infrastructure/certs/
|
||||
infrastructure/backups/
|
||||
|
||||
# Local artifacts and backups
|
||||
artifacts/backups/
|
||||
*.dump
|
||||
*.bundle
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 善缘丹阳 V1.1 - AGENTS.md
|
||||
|
||||
> 本文件为项目最高执行规则摘要,Codex 每次启动时必须首先阅读。
|
||||
> 版本:V1.1 | 最后更新:2026-09-08 | 基线:v1.0.0-RC.2
|
||||
> 本文件为项目最高执行规则摘要,WorkBuddy 每次启动时必须首先阅读。
|
||||
> 版本:V1.1 | 最后更新:2026-09-16 | 基线:v1.0.0-RC.2
|
||||
|
||||
---
|
||||
|
||||
@@ -9,18 +9,20 @@
|
||||
|
||||
以下文件共同构成当前 V1.1 需求,不得只读取其中一份:
|
||||
|
||||
1. `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`:执行纪律、产品红线、不变量和阶段门禁;
|
||||
1. `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md`:执行纪律、产品红线、不变量和阶段门禁;
|
||||
2. `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`:页面、功能、状态和用户路径;
|
||||
3. `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`:视觉、组件、响应式和前端验收;
|
||||
3. `docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md`:当前唯一 UI 视觉、组件、响应式和前端验收基线;
|
||||
4. `docs/善缘丹阳 V1.1 数据库与API增量设计.md`:数据模型、API、事务、幂等和权限契约;
|
||||
5. `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`:Phase 顺序和增量实施策略;
|
||||
6. `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`:任务粒度、依赖和验收条件。
|
||||
5. `docs/善缘丹阳 V1.1 WorkBuddy 开发执行计划修订版.md`:Phase 顺序和增量实施策略;
|
||||
6. `docs/善缘丹阳 V1.1 WorkBuddy-Jira 最终开发任务增量清单.md`:任务粒度、依赖和验收条件。
|
||||
|
||||
冲突时先服从用户最新明确指令,再由上述文件各自在其专业域内裁决。文档中的“已完成、已部署、✅”是历史进度声明,不是需求本身;只有当前代码、测试和人工验收可以证明完成。
|
||||
|
||||
V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因为重新审查而推倒重写;错误首页和活动互选规则必须替换,新增业务按 EXTEND / REFACTOR / REPLACE / ADD 执行。
|
||||
|
||||
当前唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
|
||||
业务 Phase 唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
|
||||
|
||||
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 **18/18** 全绿(2026-09-16 时为 13 项,此后新增端口与 `/admin/` 子路径一致性门禁;2026-09-19 连补 4 项 —— 第 15 项 **WXML 结构守卫**:此前全绿时小程序实际有两个页面 `service-history` / `service-program` 因多一个 `</view>` **编译失败**;第 16 项 **私有方法调用守卫**:`this._x()` 无定义会抛 TypeError,且该调用位于 `resolve` 之前时会让**所有请求**的 Promise 永不 settle;第 17 项 **组件 WXSS 选择器守卫**;第 18 项 **api.js 请求层行为契约**(桩掉 `wx.request` 真跑)。前三项说明既有门禁全是源码内容断言、没有一条会解析 WXML/WXSS 或符号)。管理端桌面端目视复验仍待用户执行。
|
||||
|
||||
---
|
||||
|
||||
@@ -59,6 +61,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
|
||||
24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务
|
||||
25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制
|
||||
26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断
|
||||
27. **配置与规则治理(V1.0.1 纠偏基线)** — 技术默认值 ≠ 产品正式确认规则。所有未经产品确认的数量/阈值/时间/比例/权重/容量/开关/冷却期,一律按「`DEFAULT + CONFIG`」管理:可配置化(`@ConfigurationProperties`/Feature Flag)、**不得**作为 `LOCKED` 业务规则写死;每个配置有唯一 canonical key,登记 `docs/governance/config-register.md`,未确认项登记 `docs/governance/open-questions.md`,已确认项才入 `docs/governance/decision-register.md`。涉及并发的上限必须有服务器端强校验 + 并发突破测试。详见 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)。
|
||||
|
||||
---
|
||||
|
||||
@@ -152,13 +155,55 @@ Phase 完成后必须输出 Completion Report,不得自动进入下一阶段
|
||||
|
||||
---
|
||||
|
||||
# 版本控制约定(本机环境强制,务必先读)
|
||||
|
||||
## 分支命名:禁止使用带 `/` 的嵌套名
|
||||
|
||||
本机存在一个**常驻监听进程**,会**即时(< 0.2 秒)删除 `refs/heads/` 下的嵌套子目录**。
|
||||
2026-09-16 定向实验结果:
|
||||
|
||||
| ref 位置 | 结果 |
|
||||
|---|---|
|
||||
| `refs/heads/<单段名>`(如 `feat-ui-v11`) | **存活**(连续观测 30s 稳定) |
|
||||
| `refs/heads/<任意>/<子名>`(嵌套) | **0.2s 内被删除**(连父目录一起) |
|
||||
| `refs/tags/<任意深度>` | **存活** |
|
||||
| `.git/` 下非 refs 的普通子目录 | **存活** |
|
||||
|
||||
**后果**:分支指针被删后 **HEAD 会悬空**(`ref: refs/heads/<被删分支>`)。此时
|
||||
任何 `git commit` 会生成**根提交**,切断全部历史;`git status` 还会把全仓库文件标成新增。
|
||||
|
||||
**因此标准 Git Flow 的 `feature/`、`fix/`、`ui/` 等带斜杠前缀在本仓库一律不可用**,
|
||||
必须使用**顶层单段名**(如 `feat-ui-v11`、`ui-acceptance`)。
|
||||
|
||||
## 提交后自保(本机环境必做)
|
||||
|
||||
```bash
|
||||
git tag <name> <sha> # tag 不受清理影响,让提交变为可达,免被 gc 回收
|
||||
git bundle create <file>.bundle <tag-name> # 参数必须是 ref 名;传裸 sha 会报 empty bundle
|
||||
sleep 2 && ls .git/refs/heads/ && git log --oneline -1 # 回读验证,别信退出码
|
||||
```
|
||||
|
||||
- `git update-ref` **返回退出码 0 不代表持久化成功**,必须回读验证。
|
||||
- 判断是否有并发进程**不能看 reflog 署名**:本仓库 `git config user.name` 是一个与真实执行工具无关的固定占位值,
|
||||
所有 reflog 条目都署名同一个名字,与该条目实际由谁产生无关。
|
||||
- 若发现 HEAD 悬空(`git log` 报 `does not have any commits yet`),按以下方式恢复,
|
||||
**不要触碰任何工作区文件**:
|
||||
|
||||
```bash
|
||||
git symbolic-ref HEAD refs/heads/<顶层单段分支>
|
||||
git reset -q # 仅同步索引,不改工作区
|
||||
git status --short # 应干净
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 文档
|
||||
|
||||
- 主启动提示词: `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`
|
||||
- Jira 任务清单: `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`
|
||||
- 开发执行计划: `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`
|
||||
- 主启动提示词: `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md`
|
||||
- Jira 任务清单: `docs/善缘丹阳 V1.1 WorkBuddy-Jira 最终开发任务增量清单.md`
|
||||
- 开发执行计划: `docs/善缘丹阳 V1.1 WorkBuddy 开发执行计划修订版.md`
|
||||
- 数据库/API设计: `docs/善缘丹阳 V1.1 数据库与API增量设计.md`
|
||||
- 前端 UI/UX: `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`
|
||||
- 前端 UI/UX: `docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md`
|
||||
- 页面级功能: `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`
|
||||
- 状态跟踪: `docs/STATUS.md`
|
||||
- 待确认项: `docs/PENDING_DECISIONS.md`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 丹阳公益婚恋服务平台 - 项目规则与任务拆解(摘要)
|
||||
|
||||
> 完整规则见:`Codex开发执行总指令、项目规则与任务拆解.md`
|
||||
> 完整规则见:`WorkBuddy开发执行总指令、项目规则与任务拆解.md`
|
||||
|
||||
## 最高原则
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
> **📌 指针(2026-09-18 头注)**:V1.1 变更记录见 `docs/V1_1_CHANGELOG.md`;发布级变更另见
|
||||
> `releases/v1.0.0-RC.1|RC.2/`。本文件不再逐版本维护。
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
所有重要变更将记录在此文件中。
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -1,322 +1,96 @@
|
||||
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
|
||||
# 丹阳公益婚恋服务平台 V1.1 — 上线前剩余工作
|
||||
|
||||
> 本文件为上线前必须完成的工作清单,按优先级排序。
|
||||
> 交由 Codex/zcode 执行。
|
||||
> **最后更新:2026-09-18**(原文为 2026-09-01,内容已严重过期,本轮按实测重写)
|
||||
>
|
||||
> ⚠️ **重写说明**:本文件此前被多处引用,但内容与实际严重不符 —— 称 `CHANGELOG.md` 有 **3.3MB**(实际
|
||||
> **4234 字节 / 73 行**);P1-3 指向**已删除**的 `docs/Phase-23-Completion-Report.md`。2026-09-18 文档治理按实测重写,
|
||||
> 并保留原编号以便对照历史引用。**逐项处置计划与批次划分**见
|
||||
> `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md`。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 上线前必须完成(阻塞性)
|
||||
## 一、已关闭项(原文标为阻塞 / 重要,实测已完成)
|
||||
|
||||
### P0-1: 产品确认16项技术默认值
|
||||
|
||||
**文件**: `docs/PENDING_DECISIONS.md`
|
||||
|
||||
**问题**: 以下技术默认值已硬编码,但无产品最终确认:
|
||||
|
||||
| 决策项 | 当前技术默认值 | 需要确认 |
|
||||
| 原编号 | 事项 | 结论(2026-09-18 复核) |
|
||||
|---|---|---|
|
||||
| 每日推荐数量 | 6 人 | 产品确认 |
|
||||
| 推荐最低阈值分数 | 60 分 | 产品确认 |
|
||||
| Pass 冷却时间 | 180 天 | 产品确认 |
|
||||
| 照片上限 | 6 张 | 产品确认 |
|
||||
| 兴趣标签上限 | 8 个 | 产品确认 |
|
||||
| 自我介绍长度上限 | 500 字符 | 产品确认 |
|
||||
| Privacy 默认值 | 全部 TRUE | 产品确认 |
|
||||
| 活动是否需要审核 | TRUE | 产品确认 |
|
||||
| 活动互选最大人数 | 3 人 | 产品确认 |
|
||||
| 活动互选截止时间 | 48 小时 | 产品确认 |
|
||||
| 红娘是否允许电话联系 | FALSE | 产品确认 |
|
||||
| "正在了解"是否自动暂停推荐 | 用户选择 | 产品确认 |
|
||||
| RelationshipStatus 最终命名 | searching/getting_to_know/paused/matched_successfully/service_exited | 产品确认 |
|
||||
| Appeal 是否 V1.0 首发 | 否(Feature Flag 预留) | 产品确认 |
|
||||
| 每日推荐重置时间 | 00:00 Asia/Shanghai | 产品确认 |
|
||||
|
||||
**操作**:
|
||||
1. 产品负责人逐一确认以上16项
|
||||
2. 确认后更新 `application.yml` 或对应配置类
|
||||
3. 从 `docs/PENDING_DECISIONS.md` 移除已确认项
|
||||
4. 更新 `AGENTS.md` 中的规则说明(如有影响)
|
||||
| P0-1 | 产品确认 16 项技术默认值 | ✅ **已确认 16 项**(见 `docs/PENDING_DECISIONS.md`「已确认决策列表」);仍有 13 项待确认(另见该文件「第三方开放遗留」7 项) |
|
||||
| P0-2 | 隐私政策最终版本确认 | ✅ `docs/legal/privacy-policy.md` 已存在(2996 字节)。上线前仍需确认是否经法务终审签署 |
|
||||
| P0-3 | PIA(隐私影响评估)书面记录 | ✅ `docs/security/PIA.md` 已存在(4700 字节) |
|
||||
| P1-1 | CoreInvariantsTest 补实质性测试 | ✅ **已如实处置**:该类(195 行 / 12 用例)已明确降级为「**常量取值口径守卫,不验证行为**」(DisplayName 已改),真实行为验证转由各 Integration 测试承担(Phase 13 复验)。原文"只是检查常量定义"的批评成立,但处置方式是**如实命名 + 转移**,而非硬塞行为断言 |
|
||||
| P1-2 | PermissionMatrixTest 补自动化验证 | ✅ 已有 341 行 / 12 用例;权限覆盖另由 `V11PermissionMatrixContractTest`(**扫描全部迁移**)+ `OperatorEndpointPermissionCoverageTest` 承担 |
|
||||
| P1-3 | 补全 Phase 23 系统测试完成报告 | ✅ `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` 已存在(结论:**部分关闭**)。**注意原文指向的 `docs/Phase-23-Completion-Report.md` 从未存在** |
|
||||
| P1-4 | 清理 CHANGELOG 重复内容 | ✅ **不成立**:`CHANGELOG.md` 实际 **4234 字节 / 73 行**,无重复条目问题;已加指向 `docs/V1_1_CHANGELOG.md` 的指针头注(2026-09-18) |
|
||||
| P2-1 | AccountLifecycle 注销联动完善 | ✅ Phase 21 复验关闭(2026-09-18):**CLOSED 仍可登录、两处 TODO 桩、匿名化死代码、注销障碍设计矛盾**等 6 处缺陷已修 |
|
||||
| P2-2 | PhotoService Presigned URL 实现 | ⚠️ **部分**:`PhotoService` 魔数校验恒失败、PDF 被误挡等已修(Phase 10 复验);`POST /me/photos/upload-token` / 下载凭证 / EXIF 清理的落地程度**待确认** |
|
||||
| P2-3 | WebSocket 在线状态管理 | ✅ Phase 09 复验关闭(2026-09-16):下行链路死代码、握手鉴权、Unmatch 不联动会话等 4 处缺陷已修 |
|
||||
|
||||
---
|
||||
|
||||
### P0-2: 隐私政策最终版本确认
|
||||
## 二、仍未完成(按优先级)
|
||||
|
||||
**问题**: Go/No-Go 检查单标注"待确认",为上线前绝对阻塞项。
|
||||
### A. 阻塞 Phase 24(UAT)启动
|
||||
|
||||
**操作**:
|
||||
1. 确认隐私政策文本(用户数据采集、存储、使用、共享、权利)
|
||||
2. 确认用户协议文本
|
||||
3. 将最终版本放入 `docs/legal/` 目录
|
||||
4. 更新小程序登录页和设置页的协议链接
|
||||
1. **Phase 23 剩余**:E2E 场景真实实现 + CI 串联 E2E —— 需「活栈供给方式」**基础设施决策**
|
||||
(占位文件头已保留场景清单:首页 11 / 本地资格 11 / 互选 8 / 权限 10 / 预约 7 项)。
|
||||
2. **管理端桌面端目视复验** —— 需**用户执行**。
|
||||
3. **`docs/PENDING_DECISIONS.md` 13 项待决策** —— 需产品确认。
|
||||
|
||||
> 在 A 组完成前,**不应宣布「失败为零可进入 UAT」**(Phase 23 Gate 要求端到端链路被真实执行)。
|
||||
|
||||
### B. 上线前(运维 / 产品,需人工)
|
||||
|
||||
- **P0-4** 微信小程序审核提交(人工)。
|
||||
- **P0-5** SSL 证书申请与 DNS 配置(人工)。
|
||||
- **Phase 22 Deployment / Ops 口径 5 项未复验**:删除镜像跳过测试的发布路径、生产关 Mock WeChat/演示 Seed/Debug/Swagger、
|
||||
端口与依赖检测、**备份加密 + 真实恢复演练**、Rollback 目标明确。
|
||||
⚠️ 该 Phase 与 `STATUS.md` 所述「Phase 22 部署运维配置扩展(Feature Flag)」**是两份不同工作**。
|
||||
- **Phase 25 运营素材**(真实 Banner/站点/咨询/课堂/反诈内容,不 Seed 假数据)、**Phase 26 上线前 13 项 smoke**。
|
||||
|
||||
### C. 代码级遗留(不阻塞 UAT,但需清偿)
|
||||
|
||||
- **Phase 21**:① 红娘服务进行中拦截**硬编码 `0`** —— 根因是数据模型**没有 `matchmaker_services` 表**,
|
||||
拦截项在设计上无法落地(建表属扩权,待产品确认存储模型);② `getPrivacySettings` / `updatePrivacySettings` 仍是 TODO 桩。
|
||||
- **Phase 22**:T01~T05 五个模块开关 + T07 私有桶为**死配置**(配置暴露但从未被业务读取)。
|
||||
- **任务级 ⏳**:P16-T13(互选超时接线)、P13A-T31(随访审计)、P13A-T33(`RiskEvent source=counseling`)、
|
||||
P04-T10(Onboarding 本地资格步骤)。
|
||||
|
||||
---
|
||||
|
||||
### P0-3: PIA(隐私影响评估)书面记录
|
||||
## 三、原编号索引(便于对照历史引用)
|
||||
|
||||
**问题**: 9个场景需要书面记录,当前无记录。
|
||||
|
||||
**操作**:
|
||||
1. 按以下场景撰写PIA记录:
|
||||
- 实名认证(身份证采集)
|
||||
- 照片存储与展示
|
||||
- 位置/地区信息
|
||||
- 聊天消息存储
|
||||
- 活动报名数据
|
||||
- 红娘服务数据
|
||||
- 举报与风控数据
|
||||
- 账号注销与数据删除
|
||||
- 跨组织数据访问
|
||||
2. 保存到 `docs/security/PIA.md`
|
||||
| 原编号 | 原文标题 | 本文件位置 |
|
||||
|---|---|---|
|
||||
| P0-1 | 产品确认 16 项技术默认值 | 一、已关闭 |
|
||||
| P0-2 | 隐私政策最终版本确认 | 一、已关闭(待法务终审) |
|
||||
| P0-3 | PIA 书面记录 | 一、已关闭 |
|
||||
| P0-4 | 提交微信小程序审核 | 二、B |
|
||||
| P0-5 | SSL 证书申请与 DNS 配置 | 二、B |
|
||||
| P1-1 | CoreInvariantsTest 补充实质性测试 | 一、已关闭 |
|
||||
| P1-2 | PermissionMatrixTest 补充自动化验证 | 一、已关闭 |
|
||||
| P1-3 | 补全 Phase 23 系统测试完成报告 | 一、已关闭 |
|
||||
| P1-4 | 清理 CHANGELOG.md 重复内容 | 一、已关闭(原描述不成立) |
|
||||
| P2-1 | AccountLifecycle 注销联动完善 | 一、已关闭 |
|
||||
| P2-2 | PhotoService Presigned URL 实现 | 一、部分 |
|
||||
| P2-3 | WebSocket 在线状态管理 | 一、已关闭 |
|
||||
|
||||
---
|
||||
|
||||
### P0-4: 提交微信小程序审核
|
||||
## 四、验收标准(更新)
|
||||
|
||||
**问题**: 用户端核心入口,无审核通过的小程序无法服务用户。
|
||||
|
||||
**操作**:
|
||||
1. 在微信公众平台提交小程序审核
|
||||
2. 确保所有页面符合微信小程序审核规范
|
||||
3. 配置好 `app.json` 中的类目和标签
|
||||
4. 准备好隐私政策链接(见 P0-2)
|
||||
| 项目 | 验收条件 | 状态 |
|
||||
|---|---|---|
|
||||
| P0-1 | 16 项全部产品确认,配置正确,文档更新 | ✅ 已完成 |
|
||||
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 | ✅ 文件已就位(待终审确认) |
|
||||
| P0-3 | `PIA.md` 存在,覆盖全部 9 个场景 | ✅ 文件已就位 |
|
||||
| P1-1 | CoreInvariantsTest 用例如实命名;真实行为由 Integration 测试覆盖 | ✅ 已如实处置 |
|
||||
| P1-2 | 权限矩阵 9×18 覆盖,实际调用验证 | ✅ 由契约守卫承担 |
|
||||
| P1-3 | Phase 23 完成报告存在且内容完整 | ✅ 已存在(部分关闭) |
|
||||
| P1-4 | `CHANGELOG.md` < 100KB,无重复条目 | ✅ 已满足(4.2KB) |
|
||||
| P0-4 | 微信审核提交并通过 | ⬜ 待人工 |
|
||||
| P0-5 | HTTPS 正常,证书有效 | ⬜ 待人工 |
|
||||
| Phase 23 剩余 | E2E 场景实现 + CI 串联 | ⬜ 待决策 |
|
||||
| Phase 24 | UAT 分角色验收,阻断缺陷为零 | ⬜ 待用户确认启动 |
|
||||
|
||||
---
|
||||
|
||||
### P0-5: SSL证书申请与DNS配置
|
||||
|
||||
**问题**: 生产HTTPS必需。
|
||||
|
||||
**操作**:
|
||||
1. 申请SSL证书(Let's Encrypt 或商业证书)
|
||||
2. 配置生产域名 DNS 解析(`match.danyang.gov.cn` 或约定域名)
|
||||
3. 将证书放入 `infrastructure/certs/`
|
||||
4. 验证 `nginx-production.conf` 中的证书路径
|
||||
|
||||
---
|
||||
|
||||
## P1 — 上线前应该完成(重要)
|
||||
|
||||
### P1-1: CoreInvariantsTest 补充实质性测试
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java`
|
||||
|
||||
**问题**: 当前测试只是检查常量定义,不是真正的业务逻辑验证。
|
||||
|
||||
**需要实现**(每个不变量需要真实业务逻辑测试):
|
||||
|
||||
```java
|
||||
// INV-001: Required不可自动放宽
|
||||
@Test
|
||||
void testRequiredConditionNotRelaxed() {
|
||||
// 创建用户A(要求对方学历=本科)和用户B(学历=专科)
|
||||
// B进入A的推荐池 → 应该被Required条件过滤
|
||||
// 验证B不在推荐结果中
|
||||
}
|
||||
|
||||
// INV-002: 未Mutual不可聊天
|
||||
@Test
|
||||
void testCannotMessageWithoutMutual() {
|
||||
// 用户A Like用户B,但B未Like A
|
||||
// 调用发送消息接口 → 应返回403/禁止
|
||||
}
|
||||
|
||||
// INV-003: Block优先级最高
|
||||
@Test
|
||||
void testBlockStopsAllInteractions() {
|
||||
// A Block B后
|
||||
// 验证B不在A的推荐中
|
||||
// 验证A Like B后自动取消
|
||||
// 验证A和B的Match自动解除
|
||||
}
|
||||
|
||||
// INV-004: 双向并发Like只创建一个Match
|
||||
@Test
|
||||
void testConcurrentLikeCreatesOneMatch() {
|
||||
// 线程A: A Like B
|
||||
// 线程B: B Like A(并发)
|
||||
// 验证只创建1个Match记录
|
||||
}
|
||||
|
||||
// INV-005: 活动名额不超卖
|
||||
@Test
|
||||
void testActivityCapacityNotOversold() {
|
||||
// 活动剩余1个名额
|
||||
// 线程A和线程B同时报名
|
||||
// 验证只有1人成功,1人被拒绝
|
||||
}
|
||||
|
||||
// INV-006: 单向活动意向不泄露
|
||||
@Test
|
||||
void testActivityInterestNotDisclosed() {
|
||||
// A选择B,但B没选A
|
||||
// 验证B的查询结果中看不到A的选择
|
||||
}
|
||||
|
||||
// INV-007: 红娘不能替用户Like
|
||||
@Test
|
||||
void testMatchmakerCannotLikeForUser() {
|
||||
// 红娘角色调用Like接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-008: 非授权Operator不能读取身份证
|
||||
@Test
|
||||
void testUnauthorizedOperatorCannotViewIdCard() {
|
||||
// reviewer角色调用敏感身份查看接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-009: 非案件上下文不能读取聊天
|
||||
@Test
|
||||
void testNoChatAccessOutsideCaseContext() {
|
||||
// risk_operator无案件授权调用聊天记录 → 应返回403
|
||||
}
|
||||
|
||||
// INV-010: 跨Organization不能越权
|
||||
@Test
|
||||
void testCrossOrgCannotAccess() {
|
||||
// Organization A的用户,不能访问Organization B的数据
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 测试应使用真实数据库(集成测试模式),通过 `@SpringBootTest` + Testcontainers 或指定测试数据库。
|
||||
|
||||
---
|
||||
|
||||
### P1-2: PermissionMatrixTest 补充自动化验证
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/PermissionMatrixTest.java`
|
||||
|
||||
**问题**: 当前只有权限矩阵定义,没有实际API调用验证。
|
||||
|
||||
**需要实现**:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testPermissionMatrixAllRoles() {
|
||||
// 遍历9个角色 × 18个权限
|
||||
// 每个组合:
|
||||
// 有权限 → 调用对应API → 应返回2xx
|
||||
// 无权限 → 调用对应API → 应返回403
|
||||
}
|
||||
|
||||
// 需要验证的角色:
|
||||
// super_admin, platform_admin, organization_admin,
|
||||
// reviewer, matchmaker, activity_manager,
|
||||
// risk_operator, volunteer, data_viewer
|
||||
|
||||
// 需要验证的权限模块:
|
||||
// user.view, user.sensitive.view, user.status.change,
|
||||
// review.view, review.process,
|
||||
// matchmaker.view, matchmaker.assign, matchmaker.service,
|
||||
// activity.view, activity.create, activity.review_registration,
|
||||
// risk.report.view, risk.report.process, risk.user.ban,
|
||||
// organization.manage, permission.manage,
|
||||
// analytics.view, data.export
|
||||
```
|
||||
|
||||
**验证方式**: 通过 `MockMvc` 或 `RestAssured` 调用管理端API,使用不同角色的JWT Token认证。
|
||||
|
||||
---
|
||||
|
||||
### P1-3: 补全 Phase 23 系统测试完成报告
|
||||
|
||||
**文件**: `docs/Phase-23-Completion-Report.md`
|
||||
|
||||
**问题**: Phase 23(系统测试)缺少完成报告。
|
||||
|
||||
**需要包含**:
|
||||
- 执行的测试类型和数量
|
||||
- 测试结果(通过/失败/阻塞)
|
||||
- 安全测试结果(SQL注入/XSS/CSRF等)
|
||||
- 并发测试结果
|
||||
- 发现的问题和修复情况
|
||||
|
||||
---
|
||||
|
||||
### P1-4: 清理 CHANGELOG.md 重复内容
|
||||
|
||||
**文件**: `CHANGELOG.md`
|
||||
|
||||
**问题**: 文件大小3.3MB,存在大量重复条目(同一个Phase变更被复制数十次)。
|
||||
|
||||
**操作**:
|
||||
1. 去重,保留每项变更的唯一记录
|
||||
2. 按版本(v1.0.0-rc.1, v1.0.0)组织
|
||||
3. 文件压缩到合理大小(目标 < 100KB)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 建议完成(非阻塞)
|
||||
|
||||
### P2-1: AccountLifecycle 注销联动完善
|
||||
|
||||
**现状**: 代码已实现,STATUS.md 标记为TODO。
|
||||
|
||||
**验证**: 确认注销后以下联动正确:
|
||||
- 推荐停止
|
||||
- 聊天停止(新消息禁止)
|
||||
- 红娘服务停止
|
||||
- 活动处理(未开始的按规则)
|
||||
- 历史关系保留但标记为已注销
|
||||
|
||||
---
|
||||
|
||||
### P2-2: PhotoService Presigned URL 实现
|
||||
|
||||
**现状**: 代码骨架存在,功能未完整实现。
|
||||
|
||||
**需要完成**:
|
||||
- `PhotoService` 生成 MinIO Presigned URL
|
||||
- 上传凭证接口 `POST /me/photos/upload-token`
|
||||
- 下载凭证接口(私有图片访问)
|
||||
- EXIF信息清理逻辑
|
||||
|
||||
---
|
||||
|
||||
### P2-3: WebSocket 在线状态管理
|
||||
|
||||
**现状**: 骨架实现。
|
||||
|
||||
**需要完善**:
|
||||
- 用户在线/离线状态跟踪
|
||||
- 消息送达状态(已发送/已读)
|
||||
- WebSocket 重连后的状态恢复
|
||||
- 通知推送(`notification.new` 事件)
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
1. P0-1 (产品确认) → 产品负责人
|
||||
2. P0-2 (隐私政策) → 产品/法务
|
||||
3. P0-3 (PIA记录) → 安全负责人
|
||||
4. P1-1 (Invariants测试) → 研发
|
||||
5. P1-2 (权限矩阵测试) → 研发
|
||||
6. P1-3 (Phase23报告) → 研发
|
||||
7. P1-4 (CHANGELOG) → 研发
|
||||
8. P0-4 (微信审核) → 产品
|
||||
9. P0-5 (SSL/DNS) → 运维
|
||||
10. P2-1 ~ P2-3 → 研发(可并行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
每项完成后应满足:
|
||||
|
||||
| 项目 | 验收条件 |
|
||||
|---|---|
|
||||
| P0-1 | 16项全部产品确认,配置正确,文档更新 |
|
||||
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 |
|
||||
| P0-3 | PIA.md 存在,覆盖全部9个场景 |
|
||||
| P1-1 | CoreInvariantsTest 10个用例全部包含真实业务逻辑 |
|
||||
| P1-2 | PermissionMatrixTest 9×18全部覆盖,实际调用验证 |
|
||||
| P1-3 | Phase-23-Completion-Report.md 存在且内容完整 |
|
||||
| P1-4 | CHANGELOG.md < 100KB,无重复条目 |
|
||||
| P0-4 | 微信审核提交并通过 |
|
||||
| P0-5 | HTTPS正常,证书有效 |
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-09-01*
|
||||
*最后更新: 2026-09-18 — 按实测重写(原文 2026-09-01,含 3 处与实际不符的陈述)*
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
> **⚠️ 部分取代(2026-09-18 头注)**:本文件为 V1.0 REST API 全量规范。V1.1 增量(Banner、
|
||||
> 服务站、预约、咨询、本地资格、首页聚合等)以 `docs/善缘丹阳 V1.1 数据库与API增量设计.md` 为准,
|
||||
> 路径收敛以 `docs/api/API_INVENTORY.md` 为准。本文件仍是后端代码注释与契约测试援引的协议出处
|
||||
> (如 WebSocket 事件信封第 125 章),该部分继续有效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# V1.0 REST API 接口详细设计规范
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 项目规则总纲(333 章),最高执行文件已由
|
||||
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.0 核心规则中仍生效的
|
||||
> 部分已摘要进 `AGENTS.md`。本文件保留原位(`AGENTS_SUMMARY.md` 与历史报告仍援引),其余内容不再生效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# V1.0 Codex开发执行总指令、项目规则与任务拆解
|
||||
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
|
||||
|
||||
版本:V1.0
|
||||
文档级别:最高优先级开发执行文件
|
||||
适用对象:Codex、研发人员、测试人员、后续维护人员
|
||||
适用对象:WorkBuddy、研发人员、测试人员、后续维护人员
|
||||
执行原则:严格继承既有设计,不擅自扩展,不擅自删减,不擅自重新定义产品
|
||||
|
||||
---
|
||||
|
||||
# 第一章 文档定位
|
||||
|
||||
本文件用于指导Codex完整开发:
|
||||
本文件用于指导WorkBuddy完整开发:
|
||||
|
||||
> 丹阳公益婚恋服务平台 V1.0
|
||||
|
||||
@@ -34,7 +38,7 @@
|
||||
任务拆解
|
||||
```
|
||||
|
||||
Codex在整个项目开发周期内,应将本文件视为:
|
||||
WorkBuddy在整个项目开发周期内,应将本文件视为:
|
||||
|
||||
> **项目最高执行规则。**
|
||||
|
||||
@@ -114,7 +118,7 @@ Codex在整个项目开发周期内,应将本文件视为:
|
||||
|
||||
# 第五章 明确禁止偏离产品方向
|
||||
|
||||
Codex不得将平台开发成:
|
||||
WorkBuddy不得将平台开发成:
|
||||
|
||||
```text
|
||||
商业婚恋VIP平台
|
||||
@@ -382,7 +386,7 @@ Preferred:
|
||||
|
||||
> 可参与评分。
|
||||
|
||||
Codex不得把Required自动降级。
|
||||
WorkBuddy不得把Required自动降级。
|
||||
|
||||
---
|
||||
|
||||
@@ -1308,7 +1312,7 @@ Node.js + NestJS
|
||||
|
||||
尚未锁死。
|
||||
|
||||
Codex如果当前仓库没有既有技术栈:
|
||||
WorkBuddy如果当前仓库没有既有技术栈:
|
||||
|
||||
优先建议:
|
||||
|
||||
@@ -1632,7 +1636,7 @@ Match active pair
|
||||
- 历史记录
|
||||
- Active Pair Unique
|
||||
|
||||
具体表结构由Codex合理实现。
|
||||
具体表结构由WorkBuddy合理实现。
|
||||
|
||||
---
|
||||
|
||||
@@ -1665,7 +1669,7 @@ closed
|
||||
|
||||
属于建议技术模型。
|
||||
|
||||
如果Codex优化命名:
|
||||
如果WorkBuddy优化命名:
|
||||
|
||||
> 语义必须一致。
|
||||
|
||||
@@ -2050,9 +2054,9 @@ UI细节
|
||||
|
||||
---
|
||||
|
||||
# 第一百二十章 Codex开发模式
|
||||
# 第一百二十章 WorkBuddy开发模式
|
||||
|
||||
Codex必须采用:
|
||||
WorkBuddy必须采用:
|
||||
|
||||
> 分阶段、可运行、可验证。
|
||||
|
||||
@@ -2090,7 +2094,7 @@ Codex必须采用:
|
||||
|
||||
# 第一百二十二章 不允许静默改变需求
|
||||
|
||||
如果Codex发现:
|
||||
如果WorkBuddy发现:
|
||||
|
||||
- 规则冲突
|
||||
- 前文未确认
|
||||
@@ -2260,7 +2264,7 @@ ADR-003-object-storage.md
|
||||
|
||||
---
|
||||
|
||||
# 第一百三十一章 Codex项目记忆文件
|
||||
# 第一百三十一章 WorkBuddy项目记忆文件
|
||||
|
||||
建议仓库根目录建立:
|
||||
|
||||
@@ -2270,7 +2274,7 @@ AGENTS.md
|
||||
|
||||
用于放:
|
||||
|
||||
> Codex执行规则摘要。
|
||||
> WorkBuddy执行规则摘要。
|
||||
|
||||
---
|
||||
|
||||
@@ -2311,7 +2315,7 @@ docs/STATUS.md
|
||||
待确认事项
|
||||
```
|
||||
|
||||
Codex每阶段更新。
|
||||
WorkBuddy每阶段更新。
|
||||
|
||||
---
|
||||
|
||||
@@ -2994,7 +2998,7 @@ MFA强化
|
||||
|
||||
# 第一百八十章 V1明确不做列表
|
||||
|
||||
Codex遇到以下需求不得自行开发:
|
||||
WorkBuddy遇到以下需求不得自行开发:
|
||||
|
||||
```text
|
||||
直播
|
||||
@@ -3203,7 +3207,7 @@ UI走:
|
||||
|
||||
# 第一百九十二章 核心Invariant
|
||||
|
||||
Codex必须在代码、测试中明确保护以下Invariant:
|
||||
WorkBuddy必须在代码、测试中明确保护以下Invariant:
|
||||
|
||||
```text
|
||||
未Mutual不可聊天
|
||||
@@ -3316,7 +3320,7 @@ Operator
|
||||
|
||||
每完成模块:
|
||||
|
||||
Codex必须运行:
|
||||
WorkBuddy必须运行:
|
||||
|
||||
> 相关测试。
|
||||
|
||||
@@ -3458,7 +3462,7 @@ SQLSTATE
|
||||
|
||||
---
|
||||
|
||||
# 第二百零九章 Codex每阶段输出要求
|
||||
# 第二百零九章 WorkBuddy每阶段输出要求
|
||||
|
||||
每完成一个阶段:
|
||||
|
||||
@@ -3533,7 +3537,7 @@ Appeal是否V1首发
|
||||
|
||||
---
|
||||
|
||||
# 第二百一十三章 Codex不得把待确认项硬编码
|
||||
# 第二百一十三章 WorkBuddy不得把待确认项硬编码
|
||||
|
||||
必须:
|
||||
|
||||
@@ -3795,7 +3799,7 @@ V1不需要大量SDK。
|
||||
|
||||
# 第二百三十三章 License
|
||||
|
||||
Codex新增开源库时:
|
||||
WorkBuddy新增开源库时:
|
||||
|
||||
必须确认License。
|
||||
|
||||
@@ -3935,7 +3939,7 @@ is_test_account
|
||||
|
||||
# 第二百四十四章 小程序发布前
|
||||
|
||||
Codex必须准备:
|
||||
WorkBuddy必须准备:
|
||||
|
||||
```text
|
||||
微信合法域名清单
|
||||
@@ -4017,7 +4021,7 @@ agreed_at
|
||||
|
||||
# 第二百五十章 删除/注销
|
||||
|
||||
Codex必须设计:
|
||||
WorkBuddy必须设计:
|
||||
|
||||
> 正式后端流程。
|
||||
|
||||
@@ -4119,7 +4123,7 @@ V1至少保证:
|
||||
|
||||
---
|
||||
|
||||
# 第二百五十九章 Codex不得擅自加入“内部用户价值评分”
|
||||
# 第二百五十九章 WorkBuddy不得擅自加入“内部用户价值评分”
|
||||
|
||||
禁止:
|
||||
|
||||
@@ -4341,7 +4345,7 @@ Mutual
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十二章 Codex阶段验收机制
|
||||
# 第二百七十二章 WorkBuddy阶段验收机制
|
||||
|
||||
每完成Phase:
|
||||
|
||||
@@ -4357,7 +4361,7 @@ Mutual
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十三章 Codex禁止声明完成的情况
|
||||
# 第二百七十三章 WorkBuddy禁止声明完成的情况
|
||||
|
||||
如果:
|
||||
|
||||
@@ -4449,7 +4453,7 @@ Phase 26 Production
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十八章 Codex每次启动工作时
|
||||
# 第二百七十八章 WorkBuddy每次启动工作时
|
||||
|
||||
必须先阅读:
|
||||
|
||||
@@ -4464,7 +4468,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十九章 Codex发现旧代码与文档冲突
|
||||
# 第二百七十九章 WorkBuddy发现旧代码与文档冲突
|
||||
|
||||
优先级:
|
||||
|
||||
@@ -4506,7 +4510,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
# 第二百八十一章 已知需要纠正的旧示例
|
||||
|
||||
Codex特别注意:
|
||||
WorkBuddy特别注意:
|
||||
|
||||
以下不是固定产品要求:
|
||||
|
||||
@@ -4573,7 +4577,7 @@ Codex特别注意:
|
||||
线下闭环
|
||||
```
|
||||
|
||||
Codex投入重点必须放在这些地方。
|
||||
WorkBuddy投入重点必须放在这些地方。
|
||||
|
||||
---
|
||||
|
||||
@@ -4744,7 +4748,7 @@ Rollback Pass
|
||||
|
||||
# 第二百九十六章 项目最终交付物
|
||||
|
||||
Codex必须至少交付:
|
||||
WorkBuddy必须至少交付:
|
||||
|
||||
```text
|
||||
完整源码
|
||||
@@ -4840,7 +4844,7 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零一章 Codex最终执行宣言
|
||||
# 第三百零一章 WorkBuddy最终执行宣言
|
||||
|
||||
在整个开发过程中必须始终遵守:
|
||||
|
||||
@@ -4876,7 +4880,7 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零二章 Codex项目最终原则
|
||||
# 第三百零二章 WorkBuddy项目最终原则
|
||||
|
||||
整个项目只围绕一件事情设计:
|
||||
|
||||
@@ -4910,9 +4914,9 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零三章 Codex收到本文档后的第一条执行指令
|
||||
# 第三百零三章 WorkBuddy收到本文档后的第一条执行指令
|
||||
|
||||
Codex不得立即开始无序编码。
|
||||
WorkBuddy不得立即开始无序编码。
|
||||
|
||||
首先执行:
|
||||
|
||||
@@ -4947,7 +4951,7 @@ Codex不得立即开始无序编码。
|
||||
|
||||
---
|
||||
|
||||
# 第三百零四章 Codex首次仓库审计必须输出
|
||||
# 第三百零四章 WorkBuddy首次仓库审计必须输出
|
||||
|
||||
```text
|
||||
当前技术栈
|
||||
@@ -4994,7 +4998,7 @@ Pending Decisions
|
||||
Test Result
|
||||
```
|
||||
|
||||
保证下一次Codex恢复上下文时:
|
||||
保证下一次WorkBuddy恢复上下文时:
|
||||
|
||||
> 可以从项目状态继续,而不是重新理解整个项目。
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 首轮启动提示词,最高执行文件已由
|
||||
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.1 需求以 `AGENTS.md`
|
||||
> 点名的六份修订文档为准。本文件保留原位,仅作为 V1.0 规则来源与 Phase Gate 行号出处
|
||||
> (`docs/Phase-XX-V11-Recovery-*` 报告援引)被继续引用,其余内容不再生效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# Codex首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
|
||||
# WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
|
||||
|
||||
版本:V1.0
|
||||
适用对象:Codex
|
||||
使用方式:首次启动项目时完整提供给Codex;后续每次开发任务继续要求Codex先读取 `AGENTS.md`、`docs/STATUS.md`、`docs/PENDING_DECISIONS.md` 后再执行。
|
||||
适用对象:WorkBuddy
|
||||
使用方式:首次启动项目时完整提供给WorkBuddy;后续每次开发任务继续要求WorkBuddy先读取 `AGENTS.md`、`docs/STATUS.md`、`docs/PENDING_DECISIONS.md` 后再执行。
|
||||
文档性质:研发启动执行包,不重新定义产品需求。
|
||||
|
||||
---
|
||||
|
||||
# 第一部分:Codex首轮启动提示词
|
||||
# 第一部分:WorkBuddy首轮启动提示词
|
||||
|
||||
以下内容可以直接复制给Codex执行。
|
||||
以下内容可以直接复制给WorkBuddy执行。
|
||||
|
||||
---
|
||||
|
||||
## 一、Codex首轮完整启动提示词
|
||||
## 一、WorkBuddy首轮完整启动提示词
|
||||
|
||||
你现在负责完整开发一个正式项目:
|
||||
|
||||
@@ -631,7 +636,7 @@ Step 8
|
||||
|
||||
---
|
||||
|
||||
# 十三、Codex每次开发前
|
||||
# 十三、WorkBuddy每次开发前
|
||||
|
||||
必须读取:
|
||||
|
||||
@@ -646,7 +651,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
---
|
||||
|
||||
# 十四、Codex每次完成任务后
|
||||
# 十四、WorkBuddy每次完成任务后
|
||||
|
||||
必须更新:
|
||||
|
||||
@@ -2134,7 +2139,7 @@ INV-010 跨Organization不能越权
|
||||
|
||||
---
|
||||
|
||||
# 第五部分:Codex每阶段输出格式
|
||||
# 第五部分:WorkBuddy每阶段输出格式
|
||||
|
||||
每个Phase完成以后必须输出以下报告。
|
||||
|
||||
@@ -2192,9 +2197,9 @@ Phase XX+1
|
||||
|
||||
---
|
||||
|
||||
# 第六部分:Codex首轮实际执行任务
|
||||
# 第六部分:WorkBuddy首轮实际执行任务
|
||||
|
||||
首次把本文档交给Codex后,不要让它直接执行26个Phase。
|
||||
首次把本文档交给WorkBuddy后,不要让它直接执行26个Phase。
|
||||
|
||||
第一轮只执行以下任务。
|
||||
|
||||
@@ -2319,7 +2324,7 @@ Status
|
||||
|
||||
# 三十一、首轮任务06:Phase 01详细Task Breakdown
|
||||
|
||||
Codex把P01继续拆成:
|
||||
WorkBuddy把P01继续拆成:
|
||||
|
||||
> 0.5~1天左右粒度的工程任务。
|
||||
|
||||
@@ -2704,7 +2709,7 @@ Jira
|
||||
|
||||
# 第十二部分:最终执行要求
|
||||
|
||||
Codex必须将整个项目理解为:
|
||||
WorkBuddy必须将整个项目理解为:
|
||||
|
||||
> **一个长期可运营的公益婚恋服务系统。**
|
||||
|
||||
@@ -2744,7 +2749,7 @@ Codex必须将整个项目理解为:
|
||||
|
||||
---
|
||||
|
||||
# 第十三部分:给Codex的首轮最终指令
|
||||
# 第十三部分:给WorkBuddy的首轮最终指令
|
||||
|
||||
现在开始执行本项目。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env*
|
||||
pnpm-lock.yaml
|
||||
@@ -2,7 +2,11 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- 原指向一个 SVG 图标文件,但仓库内并不存在该文件(public/ 下只有 brand/),
|
||||
属既有的 404;且以 / 开头的绝对路径不会随 vite base 变化,
|
||||
在 /admin/ 子路径部署下即使补上文件也取不到。
|
||||
改为指向已存在、且会被 vite 自动加上 base 前缀的品牌标。 -->
|
||||
<link rel="icon" type="image/png" href="/brand/logo-mark.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>丹阳公益婚恋 - 运营管理后台</title>
|
||||
</head>
|
||||
|
||||
@@ -15,14 +15,29 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# 根路径没有内容:管理端产物挂在 /admin/ 子路径下(构建 base=/admin/)。
|
||||
# 显式 301 而不是返回 nginx 默认页或 404,避免"打开有东西但其实是别的页面"的假成功。
|
||||
location = / {
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
# SPA:/admin/** 映射到产物根目录。
|
||||
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /admin 前缀地转发到本容器,
|
||||
# 因此这里必须自己认识这个前缀。
|
||||
# try_files 的兜底写成 URI(/admin/index.html)而不是文件路径:与 alias 配合时,
|
||||
# 写文件路径会被再次拼接 alias 而失效 —— 这是 alias + try_files 的已知坑。
|
||||
location /admin/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 还没就绪就 [emerg] 退出
|
||||
# Docker Compose 默认网络的内置 DNS resolver 是 127.0.0.11
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend_upstream "http://backend:8080";
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -31,7 +46,7 @@ server {
|
||||
|
||||
# WebSocket proxy for chat
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "danyang-match-admin",
|
||||
"version": "1.0.0-rc.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
# 本目录只有一个前端包,不是 pnpm workspace。
|
||||
#
|
||||
# 2026-09-22 修复背景:
|
||||
# 本文件原先只有两行 allowBuilds(esbuild / vue-demi),既没有 packages 声明,
|
||||
# 也没写实际取值。pnpm 一旦检测到工作目录下存在 pnpm-workspace.yaml,
|
||||
# 就会强制进入 workspace 模式,随后因读取不到 packages 而直接报错退出:
|
||||
#
|
||||
# ERROR packages field missing or empty
|
||||
#
|
||||
# 结果是 admin-web 的 `pnpm install --frozen-lockfile` 在任何环境都装不上依赖
|
||||
# (本地与 CI 表现一致),表现为 admin-web job 秒红,且与业务代码无关。
|
||||
#
|
||||
# allowBuilds 是 pnpm 10 才引入的配置项;pnpm 9 不认识它。这意味着
|
||||
# 「这个残缺文件是否被接受」取决于 runner 上的 pnpm 版本 —— 典型的版本漂移陷阱。
|
||||
# 因此这里显式声明单包 workspace,并在 CI 中锁死 pnpm 版本(见 .github/workflows/ci.yml),
|
||||
# 让「workspace 模式」与「只读 RFC2119 白名单」两条路径都不再受 runner 升级影响。
|
||||
#
|
||||
# esbuild 与 vue-demi 的 postinstall 是必需步骤(前者下载平台原生二进制,
|
||||
# 后者生成 Vue 版本桥接),允许它们执行。
|
||||
packages:
|
||||
- '.'
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- vue-demi
|
||||
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 672 KiB |
@@ -1,14 +1,18 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 防止 401 时并发触发多次刷新
|
||||
let refreshing = false
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('accessToken')
|
||||
router.push('/login')
|
||||
async (err) => {
|
||||
const status = err.response?.status
|
||||
const original = err.config
|
||||
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
|
||||
|
||||
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
|
||||
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
|
||||
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
|
||||
refreshing = true
|
||||
try {
|
||||
const resp: any = await client.post('/auth/operator/refresh', {})
|
||||
if (resp?.success) {
|
||||
const newToken = resp.data.accessToken
|
||||
setAccessToken(newToken)
|
||||
refreshing = false
|
||||
;(original as any).__retried = true
|
||||
const headers = (original.headers || {}) as Record<string, string>
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
original.headers = headers as any
|
||||
return client(original)
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败:清除并跳登录
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
clearAccessToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
|
||||
if (status === 401) {
|
||||
if (!isAuthEndpoint) {
|
||||
clearAccessToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
} else if (err.response?.data?.message) {
|
||||
ElMessage.error(err.response.data.message)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 品牌静态资源的运行时前缀。
|
||||
//
|
||||
// admin-web/public/ 下的文件不受 vite `base` 影响(无论 base 是什么,都会被原样复制到
|
||||
// 构建产物的根目录),因此 .vue 模板里若写死 `src="/brand/xxx.png"`,在产物部署于
|
||||
// /admin/ 子路径时,浏览器会去站点根路径请求,最终 404(表现为 logo / 登录插画裂图)。
|
||||
//
|
||||
// 结论:public/ 下的运行时引用必须显式带上 import.meta.env.BASE_URL。
|
||||
// 这里集中导出,避免每处各写一遍拼接而在改 base 时漏改。
|
||||
const BRAND_BASE = `${import.meta.env.BASE_URL}brand/`
|
||||
|
||||
export const BRAND_LOGO = `${BRAND_BASE}logo-mark.png`
|
||||
export const BRAND_LOGIN_SCENE = `${BRAND_BASE}login-illustration.jpg`
|
||||
export const BRAND_ACTIVITY_FALLBACK = `${BRAND_BASE}activity-default.jpg`
|
||||
export const BRAND_BACKDROP = `${BRAND_BASE}admin-danyang-backdrop.jpg`
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动签到工作台</span>
|
||||
<el-tag v-if="stats" type="info" size="small">
|
||||
已通过 {{ stats.totalApproved }} · 已签到 {{ stats.checkedInCount }} · 未到场
|
||||
{{ stats.notCheckedInCount }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 活动选择:签到必须绑定活动,避免跨活动误签 -->
|
||||
<el-form :inline="true" class="pick-form">
|
||||
<el-form-item label="当前活动">
|
||||
<el-select v-model="activityId" filterable placeholder="选择活动" style="width: 320px"
|
||||
@change="loadStats">
|
||||
<el-option v-for="a in activities" :key="a.id" :label="a.title" :value="a.id">
|
||||
<span>{{ a.title }}</span>
|
||||
<span class="opt-type">{{ typeText(a.activityType) }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button :disabled="!activityId" @click="loadStats">刷新统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="!activityId" type="warning" :closable="false" show-icon
|
||||
title="请先选择活动:签到码的作用域由活动决定,不可跨活动复用。" />
|
||||
|
||||
<el-row :gutter="16" v-else>
|
||||
<!-- 扫码 / 粘贴签到码 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>扫码签到</span></template>
|
||||
<el-input v-model="token" type="textarea" :rows="4"
|
||||
placeholder="粘贴用户小程序上的签到码(一次性,有效期 30 分钟)" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="scanning" :disabled="!token"
|
||||
@click="scan">确认签到</el-button>
|
||||
<el-button @click="token = ''">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 手工签到 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>手工签到(特殊情形)</span></template>
|
||||
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||||
@click="manual">手工签到</el-button>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
仅可操作被指派给自己的活动;主办/协办组织关系不授予签到权限。
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 签到结果 -->
|
||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||
<template #header><span>最近一次签到</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本次是否为重复签到">
|
||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="签到时间">{{ formatTime(lastResult.checkedInAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* U32 活动签到工作台(Phase 15)
|
||||
*
|
||||
* 后端端点:
|
||||
* - POST /api/v1/activities/operator/checkin/scan 扫码签到(一次性 token)
|
||||
* - POST /api/v1/activities/operator/{id}/checkin/manual 手工签到
|
||||
* - GET /api/v1/activities/operator/{id}/checkin-stats 签到统计
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const activities = ref<any[]>([])
|
||||
const activityId = ref('')
|
||||
const token = ref('')
|
||||
const manualUserNumber = ref('')
|
||||
const stats = ref<any>(null)
|
||||
const lastResult = ref<any>(null)
|
||||
const scanning = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const TYPE_TEXT: Record<string, string> = {
|
||||
matchmaking: '公益联谊',
|
||||
interest_social: '兴趣社交',
|
||||
relationship_education: '婚恋课堂',
|
||||
premarital_guidance: '婚前辅导',
|
||||
marriage_custom_culture: '婚俗文化',
|
||||
anti_fraud: '婚恋反诈',
|
||||
}
|
||||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
|
||||
|
||||
const formatTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function loadActivities() {
|
||||
const res: any = await get('/operator/activities', {}, authHeader())
|
||||
activities.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
stats.value = null
|
||||
if (!activityId.value) return
|
||||
try {
|
||||
const res: any = await get(`/activities/operator/${activityId.value}/checkin-stats`, {}, authHeader())
|
||||
stats.value = res.data
|
||||
} catch (e) {
|
||||
stats.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
if (!token.value.trim()) return
|
||||
scanning.value = true
|
||||
try {
|
||||
const res: any = await post('/activities/operator/checkin/scan', { token: token.value.trim() }, authHeader())
|
||||
lastResult.value = res.data
|
||||
token.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(重复扫码已幂等处理)' : '签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manual() {
|
||||
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserNumber.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||||
manualUserNumber.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadActivities)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pick-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inner-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-hint {
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.opt-type {
|
||||
float: right;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -4,13 +4,13 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动互选管理</span>
|
||||
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
|
||||
<el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
|
||||
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="activityId" label="活动ID" width="160" />
|
||||
<el-table-column prop="activityDisplayId" label="活动编号" width="160" />
|
||||
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
|
||||
<el-table-column label="双向互选对数" width="140">
|
||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||
@@ -31,8 +31,8 @@
|
||||
<div v-if="currentActivity">
|
||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
||||
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" />
|
||||
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" />
|
||||
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
|
||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||
</el-table>
|
||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||
@@ -42,10 +42,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const searchActivityId = ref('')
|
||||
const searchActivityNumber = ref('')
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
@@ -55,11 +56,11 @@ const mutualList = ref<any[]>([])
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
const rows = res.data || []
|
||||
tableData.value = searchActivityId.value
|
||||
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
|
||||
tableData.value = searchActivityNumber.value
|
||||
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
|
||||
: rows
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
|
||||
drawerVisible.value = true
|
||||
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
|
||||
mutualList.value = mutualRes.data || []
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动管理</span>
|
||||
<el-button type="primary" @click="openCreate">新建活动</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="displayId" label="活动编号" min-width="180" />
|
||||
<el-table-column prop="title" label="活动名称" min-width="180" />
|
||||
<el-table-column prop="activityType" label="活动类型" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ typeText(row.activityType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'registration_open' ? 'success' : 'info'">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalLimit" label="名额" width="80" />
|
||||
<el-table-column prop="startAt" label="开始时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.startAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="success" :disabled="row.status !== 'draft'"
|
||||
@click="publish(row)">发布</el-button>
|
||||
<el-button size="small" @click="openOrgs(row)">组织/分组</el-button>
|
||||
<el-button size="small" type="danger"
|
||||
:disabled="row.status !== 'draft' && row.status !== 'registration_open'"
|
||||
@click="cancel(row)">取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- ==================== 动态表单:字段随活动类型变化 ==================== -->
|
||||
<el-dialog v-model="formVisible" :title="editId ? '编辑活动' : '新建活动'" width="720px">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="活动类型" required>
|
||||
<el-select v-model="form.activityType" style="width: 100%">
|
||||
<el-option v-for="t in ACTIVITY_TYPES" :key="t.code" :label="t.label" :value="t.code" />
|
||||
</el-select>
|
||||
<div class="form-hint">{{ typeHint(form.activityType) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="活动名称" required>
|
||||
<el-input v-model="form.title" placeholder="例如:丹阳市青年公益联谊第 12 期" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="活动简介">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="开始时间" required>
|
||||
<el-date-picker v-model="form.startAt" type="datetime" placeholder="选择开始时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" required>
|
||||
<el-date-picker v-model="form.endAt" type="datetime" placeholder="选择结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="地点名称"><el-input v-model="form.locationName" /></el-form-item>
|
||||
<el-form-item label="详细地址"><el-input v-model="form.locationAddress" /></el-form-item>
|
||||
|
||||
<!-- 动态区 1:仅相亲 / 兴趣社交按性别名额 -->
|
||||
<template v-if="usesGenderQuota">
|
||||
<el-form-item label="男性名额"><el-input-number v-model="form.maleLimit" :min="0" /></el-form-item>
|
||||
<el-form-item label="女性名额"><el-input-number v-model="form.femaleLimit" :min="0" /></el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="总名额"><el-input-number v-model="form.totalLimit" :min="1" /></el-form-item>
|
||||
|
||||
<!-- 动态区 2:年龄门槛(选填,留空=不限) -->
|
||||
<el-form-item label="年龄下限">
|
||||
<el-input-number v-model="form.minAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
|
||||
</el-form-item>
|
||||
<el-form-item label="年龄上限">
|
||||
<el-input-number v-model="form.maxAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 动态区 3:仅相亲 / 兴趣社交有互选环节 -->
|
||||
<el-form-item v-if="form.postEventInterestEnabled !== undefined && hasPostEventInterest" label="活动后互选">
|
||||
<el-switch v-model="form.postEventInterestEnabled" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="报名需审核">
|
||||
<el-switch v-model="form.needReview" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="目标群体">
|
||||
<el-input v-model="form.targetGroup" placeholder="例如:丹阳市机关企事业单位青年" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主办组织">
|
||||
<el-select v-model="form.organizerOrgId" clearable style="width: 100%" placeholder="选择主办组织">
|
||||
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="协作组织">
|
||||
<div v-for="(p, i) in form.partnerOrganizations" :key="i" class="partner-row">
|
||||
<el-select v-model="p.organizationId" placeholder="组织" style="width: 220px">
|
||||
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
<el-select v-model="p.role" style="width: 150px">
|
||||
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
|
||||
</el-select>
|
||||
<el-button size="small" type="danger" @click="removePartner(i)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addPartner">+ 添加协作组织</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ==================== 多组织 / 容量分组 ==================== -->
|
||||
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
||||
<h4>组织关系</h4>
|
||||
<el-table :data="orgs" border size="small">
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="inline-form">
|
||||
<el-select v-model="newOrg.organizationId" placeholder="组织" style="width: 200px">
|
||||
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
<el-select v-model="newOrg.role" style="width: 140px">
|
||||
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" @click="addOrg">添加组织</el-button>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 18px">容量分组</h4>
|
||||
<el-table :data="groups" border size="small">
|
||||
<el-table-column prop="groupCode" label="分组代码" min-width="140" />
|
||||
<el-table-column prop="capacity" label="容量" width="80" />
|
||||
<el-table-column prop="approvedCount" label="已通过" width="80" />
|
||||
<el-table-column prop="ruleDefinition" label="规则" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<div class="inline-form">
|
||||
<el-input v-model="newGroup.groupCode" placeholder="如 male_30_40" style="width: 180px" />
|
||||
<el-input-number v-model="newGroup.capacity" :min="1" style="width: 120px" />
|
||||
<el-input v-model="newGroup.ruleDefinition" placeholder='如 {"gender":"male"}' style="width: 200px" />
|
||||
<el-button size="small" type="primary" @click="addGroup">添加分组</el-button>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="orgVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* 活动管理(V1.1 Phase 14)
|
||||
*
|
||||
* 关键点:**动态表单** —— 字段随 activityType 变化:
|
||||
* - matchmaking / interest_social:显示男女名额 + 活动后互选(有互选环节)
|
||||
* - relationship_education / marriage_custom_culture / anti_fraud:公益科普,
|
||||
* 不显示性别名额与互选,资格门槛也不同(见 typeHint)
|
||||
* - premarital_guidance:需实名,无互选
|
||||
*
|
||||
* 类型与角色枚举必须与后端生产 CHECK 白名单一致:
|
||||
* ActivityConstraintGuardTest 会解析 db/migration/V*.sql 校验后端常量,
|
||||
* 本页的下拉项同样不得偏离该白名单。
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
const ACTIVITY_TYPES = [
|
||||
{ code: 'matchmaking', label: '公益联谊' },
|
||||
{ code: 'interest_social', label: '兴趣社交' },
|
||||
{ code: 'relationship_education', label: '婚恋课堂' },
|
||||
{ code: 'premarital_guidance', label: '婚前辅导' },
|
||||
{ code: 'marriage_custom_culture', label: '婚俗文化' },
|
||||
{ code: 'anti_fraud', label: '反诈宣传' },
|
||||
]
|
||||
|
||||
const ORG_ROLES = [
|
||||
{ code: 'host', label: '主办' },
|
||||
{ code: 'organizer', label: '承办' },
|
||||
{ code: 'co_organizer', label: '协办' },
|
||||
{ code: 'partner', label: '合作方' },
|
||||
{ code: 'venue_partner', label: '场地合作方' },
|
||||
]
|
||||
|
||||
const TYPE_TEXT: Record<string, string> = Object.fromEntries(ACTIVITY_TYPES.map((t) => [t.code, t.label]))
|
||||
const ROLE_TEXT: Record<string, string> = Object.fromEntries(ORG_ROLES.map((r) => [r.code, r.label]))
|
||||
|
||||
/** 资格策略提示:与后端 ActivityService#resolveEligibility 保持一致 */
|
||||
const TYPE_HINT: Record<string, string> = {
|
||||
matchmaking: '需实名认证 + 丹阳本地资格核验;按男女名额分别扣减;活动后开放互选',
|
||||
interest_social: '按男女名额分别扣减;活动后开放互选',
|
||||
premarital_guidance: '需实名认证;只按总名额',
|
||||
relationship_education: '公益科普,不设实名与本地资格门槛;只按总名额',
|
||||
marriage_custom_culture: '公益科普,不设实名与本地资格门槛;只按总名额',
|
||||
anti_fraud: '公益科普,不设实名与本地资格门槛;只按总名额',
|
||||
}
|
||||
|
||||
const POST_EVENT_TYPES = ['matchmaking', 'interest_social']
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const organizations = ref<any[]>([])
|
||||
|
||||
const formVisible = ref(false)
|
||||
const editId = ref('')
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const orgVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const orgs = ref<any[]>([])
|
||||
const groups = ref<any[]>([])
|
||||
const newOrg = ref({ organizationId: '', role: 'partner' })
|
||||
const newGroup = ref({ groupCode: '', capacity: 20, ruleDefinition: '' })
|
||||
|
||||
const usesGenderQuota = computed(() => ['matchmaking', 'interest_social'].includes(form.value.activityType))
|
||||
const hasPostEventInterest = computed(() => POST_EVENT_TYPES.includes(form.value.activityType))
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
activityType: 'matchmaking',
|
||||
title: '',
|
||||
description: '',
|
||||
startAt: '',
|
||||
endAt: '',
|
||||
locationName: '',
|
||||
locationAddress: '',
|
||||
totalLimit: 50,
|
||||
maleLimit: 25,
|
||||
femaleLimit: 25,
|
||||
minAge: undefined as number | undefined,
|
||||
maxAge: undefined as number | undefined,
|
||||
needReview: true,
|
||||
postEventInterestEnabled: false,
|
||||
targetGroup: '',
|
||||
organizerOrgId: '',
|
||||
partnerOrganizations: [] as Array<{ organizationId: string; role: string }>,
|
||||
}
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { Authorization: `Bearer ${getAccessToken()}` }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/operator/activities', {}, authHeader())
|
||||
tableData.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrganizations() {
|
||||
try {
|
||||
const res: any = await get('/operator/organizations', {}, authHeader())
|
||||
organizations.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = ''
|
||||
form.value = emptyForm()
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function addPartner() {
|
||||
form.value.partnerOrganizations.push({ organizationId: '', role: 'partner' })
|
||||
}
|
||||
function removePartner(i: number) {
|
||||
form.value.partnerOrganizations.splice(i, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.title) {
|
||||
ElMessage.warning('请填写活动名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.startAt || !form.value.endAt) {
|
||||
ElMessage.warning('请填写开始与结束时间')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = { ...form.value }
|
||||
// 非性别名额类型不下发男女名额,避免后端误按性别限制
|
||||
if (!usesGenderQuota.value) {
|
||||
delete body.maleLimit
|
||||
delete body.femaleLimit
|
||||
}
|
||||
await post('/operator/activities', body, authHeader())
|
||||
ElMessage.success('活动已创建')
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(row: any) {
|
||||
try {
|
||||
await put(`/operator/activities/${row.id}/publish`, {}, authHeader())
|
||||
ElMessage.success('已发布')
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(row: any) {
|
||||
try {
|
||||
await put(`/operator/activities/${row.id}/cancel`, {}, authHeader())
|
||||
ElMessage.success('已取消')
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '取消失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openOrgs(row: any) {
|
||||
currentId.value = row.id
|
||||
orgVisible.value = true
|
||||
await Promise.all([loadOrgs(), loadGroups()])
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const res: any = await get(`/operator/activities/${currentId.value}/organizations`, {}, authHeader())
|
||||
orgs.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const res: any = await get(`/operator/activities/${currentId.value}/capacity-groups`, {}, authHeader())
|
||||
groups.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addOrg() {
|
||||
if (!newOrg.value.organizationId) {
|
||||
ElMessage.warning('请选择组织')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/activities/${currentId.value}/organizations`, newOrg.value, authHeader())
|
||||
ElMessage.success('已添加')
|
||||
await loadOrgs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
if (!newGroup.value.groupCode) {
|
||||
ElMessage.warning('请填写分组代码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/activities/${currentId.value}/capacity-groups`, newGroup.value, authHeader())
|
||||
ElMessage.success('已添加')
|
||||
await loadGroups()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
function typeText(t?: string) {
|
||||
return (t && TYPE_TEXT[t]) || t || '-'
|
||||
}
|
||||
function roleText(r?: string) {
|
||||
return (r && ROLE_TEXT[r]) || r || '-'
|
||||
}
|
||||
function typeHint(t?: string) {
|
||||
return (t && TYPE_HINT[t]) || ''
|
||||
}
|
||||
function statusText(s?: string) {
|
||||
const map: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
registration_open: '报名中',
|
||||
registration_closed: '报名结束',
|
||||
ongoing: '进行中',
|
||||
ended: '已结束',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return (s && map[s]) || s || '-'
|
||||
}
|
||||
function formatTime(t?: string) {
|
||||
return t ? new Date(t).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadOrganizations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.partner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inline-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,10 @@
|
||||
<el-descriptions-item label="审核通过">{{ funnelStats.profileApproved }}</el-descriptions-item>
|
||||
<el-descriptions-item label="7日活跃">{{ funnelStats.activeUsers }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="metric-note">
|
||||
说明:「实名用户」指身份证核验通过的人数(user_identities.verification_status = verified),
|
||||
与「审核通过」(资料审核)是两个不同口径,两者互不包含。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Match漏斗 -->
|
||||
@@ -46,6 +50,39 @@
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 按来源分离 -->
|
||||
<el-divider content-position="left">Match 按来源分离</el-divider>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.userLike ?? 0 }}</div>
|
||||
<div class="mini-label">用户互相 Like</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.activityMutual ?? 0 }}</div>
|
||||
<div class="mini-label">活动互选</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.matchmaker ?? 0 }}</div>
|
||||
<div class="mini-label">红娘促成</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ matchFunnelStats.matchmakerRecommendations ?? 0 }}</div>
|
||||
<div class="mini-label">红娘发出推荐</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="metric-note">
|
||||
说明:「开启聊天」只统计存在真人消息的会话(系统通知不计);Match 按来源分离统计,
|
||||
不同来源的量不可互相替代。2 个单向 Like 才形成 1 个 Match。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 安全统计 -->
|
||||
@@ -58,6 +95,109 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 公益服务数据 -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header>
|
||||
<span>公益服务数据</span>
|
||||
<el-tag v-if="v11 && v11.effectiveConnections?.effectiveMatchesProvisional"
|
||||
type="warning" size="small" style="margin-left:8px">含未确认口径</el-tag>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16" style="margin-bottom:12px">
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalUniqueServiceUsers ?? 0 }}</div>
|
||||
<div class="mini-label">服务用户数(去重)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalServiceVisits ?? 0 }}</div>
|
||||
<div class="mini-label">公益服务人次(不去重)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.serviceAppointments?.total ?? 0 }}</div>
|
||||
<div class="mini-label">服务预约(不含取消)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.counseling?.total ?? 0 }}</div>
|
||||
<div class="mini-label">婚恋咨询受理</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 活动漏斗:互选 vs 成功 -->
|
||||
<el-divider content-position="left">活动互选与成功(两个不同指标)</el-divider>
|
||||
<el-row :gutter="16" style="margin-bottom:12px">
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.activityFunnel?.mutualBothConfirmed ?? 0 }}</div>
|
||||
<div class="mini-label">互选成立对数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.activityFunnel?.mutualMatches ?? 0 }}</div>
|
||||
<div class="mini-label">互选后建立 Match</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.activityFunnel?.mutualSuccessful ?? 0 }}</div>
|
||||
<div class="mini-label">互选后成功牵手</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-value">{{ v11?.activityFunnel?.checkedInCount ?? 0 }}</div>
|
||||
<div class="mini-label">活动实际签到</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 口径警示 -->
|
||||
<el-alert
|
||||
v-if="v11?.effectiveConnections?.effectiveMatchesProvisional"
|
||||
type="warning" :closable="false" show-icon style="margin-bottom:12px"
|
||||
title="「有效交友匹配」口径尚未确认,仅供内部参考,禁止对外宣传">
|
||||
<div>{{ v11?.effectiveConnections?.provisionalNote }}</div>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-if="v11?.banners && !v11.banners.impressionsAvailable"
|
||||
type="info" :closable="false" show-icon style="margin-bottom:12px"
|
||||
title="Banner 曝光量 / 点击量当前不可用(未埋点)">
|
||||
<div>{{ v11?.banners?.unavailableReason }}</div>
|
||||
</el-alert>
|
||||
|
||||
<el-descriptions :column="4" border size="small">
|
||||
<el-descriptions-item label="Banner 总数">{{ v11?.banners?.totalBanners ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效中 Banner">{{ v11?.banners?.activeBanners ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="曝光量">
|
||||
<span v-if="v11?.banners?.impressionsAvailable">{{ v11.banners.impressions }}</span>
|
||||
<span v-else class="unavailable">未埋点</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="点击量">
|
||||
<span v-if="v11?.banners?.clicksAvailable">{{ v11.banners.clicks }}</span>
|
||||
<span v-else class="unavailable">未埋点</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已发布内容">{{ v11?.content?.totalPublishedArticles ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内容阅读量">{{ v11?.content?.totalViews ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="专项项目数">{{ v11?.servicePrograms?.totalPrograms ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目在册成员">{{ v11?.servicePrograms?.totalMembers ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本地资格已核验">{{ v11?.localEligibility?.verified ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="婚前辅导受理">{{ v11?.premaritalGuidance?.totalRequests ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="安全处理中举报">{{ v11?.counseling?.pending ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="有效交友匹配(未确认口径)">
|
||||
<el-tag type="warning" size="small">{{ v11?.effectiveConnections?.successConnections ?? 0 }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 每日汇总表 -->
|
||||
<el-card shadow="never" v-if="dailySummary.length > 0">
|
||||
<template #header><span>每日汇总(最近{{ dailySummary.length }}天)</span></template>
|
||||
@@ -73,8 +213,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { token } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<[string, string]>([
|
||||
@@ -84,83 +226,111 @@ const dateRange = ref<[string, string]>([
|
||||
const funnelStats = ref<any>({})
|
||||
const matchFunnelStats = ref<any>({})
|
||||
const safetyStats = ref<any>({})
|
||||
const v11 = ref<any>({})
|
||||
const dailySummary = ref<any[]>([])
|
||||
|
||||
const funnelStages = [
|
||||
{ key: 'registered', label: '累计注册', value: 0, rate: null, color: '#409EFF' },
|
||||
{ key: 'verified', label: '已实名', value: 0, rate: null, color: '#67C23A' },
|
||||
{ key: 'approved', label: '审核通过', value: 0, rate: null, color: '#E59A32' },
|
||||
{ key: 'active', label: '7日活跃', value: 0, rate: null, color: '#E96B6B' }
|
||||
]
|
||||
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
|
||||
//
|
||||
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
|
||||
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
|
||||
// 非响应式写入不触发重渲染,于是卡片恒显示 0,而同一张卡里的汇总表(走 ref)
|
||||
// 显示 12846/9217/8462/3812,界面自相矛盾。
|
||||
// 改为 computed 派生后两者同源,结构上不可能再对不上。
|
||||
const funnelStages = computed(() => [
|
||||
{
|
||||
key: 'registered', label: '累计注册',
|
||||
value: funnelStats.value.totalRegistered || 0,
|
||||
rate: null as string | null, color: token('--color-info')
|
||||
},
|
||||
{
|
||||
key: 'verified', label: '已实名',
|
||||
value: funnelStats.value.verifiedCount || 0,
|
||||
rate: funnelStats.value.verifyRate || '-', color: token('--color-success')
|
||||
},
|
||||
{
|
||||
key: 'approved', label: '审核通过',
|
||||
value: funnelStats.value.profileApproved || 0,
|
||||
rate: funnelStats.value.approvalRate || '-', color: token('--color-warning')
|
||||
},
|
||||
{
|
||||
key: 'active', label: '7日活跃',
|
||||
value: funnelStats.value.activeUsers || 0,
|
||||
rate: funnelStats.value.activeRate || '-', color: token('--color-primary')
|
||||
}
|
||||
])
|
||||
|
||||
const matchStages = [
|
||||
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
|
||||
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
|
||||
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
|
||||
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
|
||||
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
|
||||
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
|
||||
]
|
||||
const matchStages = computed(() => [
|
||||
{
|
||||
key: 'recommended', label: '推荐',
|
||||
value: matchFunnelStats.value.totalRecommended || 0,
|
||||
rate: '-', color: token('--color-info')
|
||||
},
|
||||
{
|
||||
key: 'liked', label: '喜欢',
|
||||
value: matchFunnelStats.value.totalLiked || 0,
|
||||
rate: matchFunnelStats.value.likeRate || '-', color: token('--color-success')
|
||||
},
|
||||
{
|
||||
key: 'matches', label: 'Match',
|
||||
value: matchFunnelStats.value.totalMatches || 0,
|
||||
rate: matchFunnelStats.value.matchRate || '-', color: token('--color-warning')
|
||||
},
|
||||
{
|
||||
key: 'chats', label: '聊天',
|
||||
value: matchFunnelStats.value.chatsStarted || 0,
|
||||
rate: matchFunnelStats.value.chatRate || '-', color: token('--color-neutral')
|
||||
},
|
||||
{
|
||||
key: 'knowing', label: '了解中',
|
||||
value: matchFunnelStats.value.gettingToKnow || 0,
|
||||
rate: '-', color: token('--color-primary')
|
||||
},
|
||||
{
|
||||
key: 'success', label: '成功牵手',
|
||||
value: matchFunnelStats.value.successPairs || 0,
|
||||
rate: matchFunnelStats.value.successRate || '-', color: token('--color-danger')
|
||||
}
|
||||
])
|
||||
|
||||
const safetyCards = [
|
||||
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
|
||||
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
|
||||
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
|
||||
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
|
||||
]
|
||||
const safetyCards = computed(() => [
|
||||
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
|
||||
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
|
||||
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
|
||||
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
|
||||
])
|
||||
|
||||
async function loadFunnel() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
||||
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
||||
const authToken = getAccessToken()
|
||||
if (dateRange.value[0] && dateRange.value[1]) {
|
||||
const [userRes, matchRes, safetyRes]: any[] = await Promise.all([
|
||||
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
||||
get('/operator/analytics/user-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
}, { Authorization: `Bearer ${authToken}` }),
|
||||
get('/operator/analytics/match-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` })
|
||||
}, { Authorization: `Bearer ${authToken}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
|
||||
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
|
||||
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
|
||||
// 后端已实现却无人调用 —— 本行起补齐。
|
||||
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
|
||||
])
|
||||
const userData = userRes.data || {}
|
||||
const matchData = matchRes.data || {}
|
||||
const safetyData = safetyRes.data || {}
|
||||
funnelStats.value = userData
|
||||
matchFunnelStats.value = matchData
|
||||
safetyStats.value = safetyData
|
||||
|
||||
// 更新漏斗显示
|
||||
funnelStages[0].value = userData.totalRegistered || 0
|
||||
funnelStages[1].value = userData.verifiedCount || 0
|
||||
funnelStages[1].rate = userData.verifyRate || '-'
|
||||
funnelStages[2].value = userData.profileApproved || 0
|
||||
funnelStages[2].rate = userData.approvalRate || '-'
|
||||
funnelStages[3].value = userData.activeUsers || 0
|
||||
funnelStages[3].rate = userData.activeRate || '-'
|
||||
|
||||
matchStages[0].value = matchData.totalRecommended || 0
|
||||
matchStages[1].value = matchData.totalLiked || 0
|
||||
matchStages[1].rate = matchData.likeRate || '-'
|
||||
matchStages[2].value = matchData.totalMatches || 0
|
||||
matchStages[2].rate = matchData.matchRate || '-'
|
||||
matchStages[3].value = matchData.chatsStarted || 0
|
||||
matchStages[3].rate = matchData.chatRate || '-'
|
||||
matchStages[4].value = matchData.gettingToKnow || 0
|
||||
matchStages[5].value = matchData.successPairs || 0
|
||||
matchStages[5].rate = matchData.successRate || '-'
|
||||
|
||||
safetyCards[0].value = safetyData.pendingReports || 0
|
||||
safetyCards[1].value = safetyData.resolvedReports || 0
|
||||
safetyCards[2].value = safetyData.highRiskUsers || 0
|
||||
safetyCards[3].value = safetyData.bannedUsers || 0
|
||||
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
|
||||
funnelStats.value = userRes.data || {}
|
||||
matchFunnelStats.value = matchRes.data || {}
|
||||
safetyStats.value = safetyRes.data || {}
|
||||
v11.value = v11Res.data || {}
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadDailySummary() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
|
||||
const authToken = getAccessToken()
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
||||
dailySummary.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
@@ -182,10 +352,21 @@ onMounted(loadData)
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
|
||||
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
|
||||
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
|
||||
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
|
||||
.mini-stat {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
|
||||
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
|
||||
.unavailable { color: var(--color-text-disabled); font-style: italic; }
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button v-if="row.status==='pending'||row.status==='escalated'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -59,8 +59,8 @@
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="decision" style="width:100%">
|
||||
@@ -84,8 +84,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
@@ -108,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -118,15 +120,15 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
|
||||
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
|
||||
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
|
||||
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
|
||||
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
|
||||
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
|
||||
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
@@ -137,7 +139,7 @@ async function openDetail(id: string) {
|
||||
decision.value = 'approve'
|
||||
decisionNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -147,7 +149,7 @@ async function handleProcess() {
|
||||
if (!decision.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/appeals/${currentId.value}/process`,
|
||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
@@ -168,8 +170,8 @@ onMounted(loadData)
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
|
||||
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.text-muted { color: #BBB; font-size: 13px; }
|
||||
.text-muted { color: var(--color-text-disabled); font-size: var(--font-size-sm); }
|
||||
</style>
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="operatorId" label="操作人ID" width="140" />
|
||||
<el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
|
||||
<el-table-column prop="action" label="操作" width="160">
|
||||
<template #default="{row}">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceType" label="资源类型" width="120">
|
||||
<template #default="{row}">{{ row.resourceType }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
|
||||
<el-table-column prop="ip" label="IP地址" width="140" />
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
@@ -38,6 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/audit/logs', {
|
||||
resourceType: resourceFilter.value || undefined,
|
||||
page: page.value,
|
||||
|
||||
@@ -7,31 +7,42 @@
|
||||
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="Banner 只允许跳转站内。目标值填写站内标识(如活动ID、文章ID、页面标识 home),填写 http(s):// 等外链会被拒绝。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-table :data="banners" v-loading="loading" stripe>
|
||||
<el-table-column prop="title" label="标题" width="150" />
|
||||
<el-table-column prop="targetType" label="跳转类型" width="120">
|
||||
<el-table-column prop="buttonText" label="按钮文案" width="110">
|
||||
<template #default="{ row }">{{ row.buttonText || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="targetType" label="跳转类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ bannerTargetTypeLabel(row.targetType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="audienceScope" label="展示范围" width="120">
|
||||
<el-table-column prop="audienceScope" label="展示范围" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="audienceScopeTag(row.audienceScope)">{{ audienceScopeLabel(row.audienceScope) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-table-column prop="status" label="状态" width="95">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="70" />
|
||||
<el-table-column label="时间范围" width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" width="230">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openPreview(row)">预览</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="toggleStatus(row)">
|
||||
{{ row.status === 'active' ? '下架' : '上架' }}
|
||||
@@ -44,8 +55,9 @@
|
||||
<!-- 新建/编辑 Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
|
||||
<el-form-item label="标题" required><el-input v-model="form.title" maxlength="200" show-word-limit /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" maxlength="300" show-word-limit /></el-form-item>
|
||||
<el-form-item label="按钮文案"><el-input v-model="form.buttonText" maxlength="64" show-word-limit placeholder="如:立即体验(留空则不显示按钮)" /></el-form-item>
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select v-model="form.targetType">
|
||||
<el-option label="公益活动" value="activity" />
|
||||
@@ -58,7 +70,10 @@
|
||||
<el-option label="无" value="none" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input v-model="form.targetValue" placeholder="活动ID/文章ID等" /></el-form-item>
|
||||
<el-form-item label="目标值" :required="needsExplicitTarget">
|
||||
<el-input v-model="form.targetValue" maxlength="200" :placeholder="targetValuePlaceholder" />
|
||||
<div class="field-hint">仅填站内标识,禁止 http(s):// 等站外地址</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="展示范围">
|
||||
<el-select v-model="form.audienceScope">
|
||||
<el-option label="全部用户" value="all" />
|
||||
@@ -68,40 +83,118 @@
|
||||
<el-option label="成功牵手" value="successful" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
|
||||
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="生效中" value="active" />
|
||||
<el-option label="已下架" value="inactive" />
|
||||
</el-select>
|
||||
<div class="field-hint">「定时」「已过期」由排期自动流转,无需手工选择</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sortOrder" :min="0" />
|
||||
<div class="field-hint">数值越小越靠前</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示立即生效" /></el-form-item>
|
||||
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示长期有效" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button @click="previewFromForm">预览</el-button>
|
||||
<el-button type="primary" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览 Dialog:模拟小程序首页 Banner 卡片 -->
|
||||
<el-dialog v-model="previewVisible" title="Banner 预览(模拟小程序首页)" width="420px">
|
||||
<div class="preview-stage">
|
||||
<div class="preview-card">
|
||||
<div class="preview-tag">{{ audienceScopeLabel(previewBanner.audienceScope) }}</div>
|
||||
<div class="preview-title">{{ previewBanner.title || '(未填写标题)' }}</div>
|
||||
<div class="preview-subtitle">{{ previewBanner.subtitle || '' }}</div>
|
||||
<div v-if="previewBanner.buttonText" class="preview-button">{{ previewBanner.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" style="margin-top: 16px">
|
||||
<el-descriptions-item label="跳转去向">{{ previewTargetText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="展示状态">{{ statusLabel(previewBanner.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="展示时间">
|
||||
{{ formatDateTime(previewBanner.startAt) }} ~ {{ formatDateTime(previewBanner.endAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">{{ previewBanner.sortOrder ?? 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const banners = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const form = ref({ title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' })
|
||||
const previewBanner = ref<any>({})
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '', subtitle: '', buttonText: '', targetType: 'none', targetValue: '',
|
||||
audienceScope: 'all', status: 'active', sortOrder: 0, startAt: '', endAt: '',
|
||||
}
|
||||
const form = ref({ ...EMPTY_FORM })
|
||||
|
||||
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
|
||||
const TARGET_TYPE_DEST: Record<string, string> = {
|
||||
activity: '公益活动列表', matchmaker: '公益红娘', counseling: '婚恋咨询',
|
||||
premarital: '婚前辅导', service_location: '善缘服务站',
|
||||
article: '文章详情', internal_page: '站内页面', none: '无跳转(仅展示)',
|
||||
}
|
||||
const NEEDS_EXPLICIT_TARGET = ['internal_page', 'article']
|
||||
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
|
||||
/** 与后端 assertInternalTarget 保持一致的禁外链前缀 */
|
||||
const EXTERNAL_LINK_PREFIXES = ['http://', 'https://', '//', 'javascript:', 'data:text/html']
|
||||
|
||||
const needsExplicitTarget = computed(() => NEEDS_EXPLICIT_TARGET.includes(form.value.targetType))
|
||||
const targetValuePlaceholder = computed(() => (form.value.targetType === 'article'
|
||||
? '填写文章ID'
|
||||
: form.value.targetType === 'internal_page' ? '填写站内页面标识,如 home' : '可留空,默认进入对应列表页'))
|
||||
|
||||
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
|
||||
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
|
||||
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s || '-' }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function isExternalLink(value: string) {
|
||||
const normalized = (value || '').trim().toLowerCase()
|
||||
return EXTERNAL_LINK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
|
||||
}
|
||||
function previewTargetText(banner: any) {
|
||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||
if (!banner.targetValue) return dest
|
||||
return `${dest} · 已配置站内目标`
|
||||
}
|
||||
function assignForm(source: any) {
|
||||
form.value = {
|
||||
title: source.title || '',
|
||||
subtitle: source.subtitle || '',
|
||||
buttonText: source.buttonText || '',
|
||||
targetType: source.targetType || 'none',
|
||||
targetValue: source.targetValue || '',
|
||||
audienceScope: source.audienceScope || 'all',
|
||||
status: source.status || 'active',
|
||||
sortOrder: source.sortOrder ?? 0,
|
||||
startAt: source.startAt || '',
|
||||
endAt: source.endAt || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBanners() {
|
||||
loading.value = true
|
||||
@@ -111,10 +204,60 @@ async function loadBanners() {
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' }); dialogVisible.value = true }
|
||||
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, targetType: row.targetType, targetValue: row.targetValue, audienceScope: row.audienceScope, sortOrder: row.sortOrder, startAt: row.startAt, endAt: row.endAt }); dialogVisible.value = true }
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
assignForm({})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
assignForm(row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端同源的校验。前端拦一道是为了让运营即时看到原因,
|
||||
* 不作为唯一防线(后端 HomeBannerOperatorController.validate 仍是权威)。
|
||||
* @returns 校验通过返回 true
|
||||
*/
|
||||
function validateForm(): boolean {
|
||||
const value = form.value
|
||||
if (!value.title.trim()) { ElMessage.error('请填写 Banner 标题'); return false }
|
||||
if (isExternalLink(value.targetValue)) {
|
||||
ElMessage.error('Banner 不允许跳转站外地址,请填写站内标识')
|
||||
return false
|
||||
}
|
||||
if (NEEDS_EXPLICIT_TARGET.includes(value.targetType) && !value.targetValue.trim()) {
|
||||
ElMessage.error(`跳转类型为「${bannerTargetTypeLabel(value.targetType)}」时必须填写站内目标值`)
|
||||
return false
|
||||
}
|
||||
if (value.startAt && value.endAt && new Date(value.startAt) > new Date(value.endAt)) {
|
||||
ElMessage.error('展示开始时间不能晚于结束时间')
|
||||
return false
|
||||
}
|
||||
if (value.sortOrder != null && value.sortOrder < 0) {
|
||||
ElMessage.error('排序值不能为负数')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function openPreview(row: any) {
|
||||
if (isExternalLink(row.targetValue)) {
|
||||
ElMessage.error('该 Banner 目标值疑似站外地址,已阻止预览')
|
||||
return
|
||||
}
|
||||
previewBanner.value = row
|
||||
previewVisible.value = true
|
||||
}
|
||||
function previewFromForm() {
|
||||
if (!validateForm()) return
|
||||
previewBanner.value = { ...form.value }
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!validateForm()) return
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await put(`/operator/banners/${editingId.value}`, form.value)
|
||||
@@ -125,15 +268,32 @@ async function saveBanner() {
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
} catch (e) {
|
||||
// 错误提示由 api/client 的响应拦截器统一弹出,此处不重复 toast
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const newStatus = row.status === 'active' ? 'inactive' : 'active'
|
||||
await put(`/operator/banners/${row.id}`, { ...row, status: newStatus })
|
||||
// 只回传可编辑字段:id/createdBy/updatedAt 等由服务端维护,避免把服务端字段带回写坏
|
||||
await put(`/operator/banners/${row.id}`, {
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
buttonText: row.buttonText,
|
||||
targetType: row.targetType,
|
||||
targetValue: row.targetValue,
|
||||
audienceScope: row.audienceScope,
|
||||
status: newStatus,
|
||||
sortOrder: row.sortOrder,
|
||||
startAt: row.startAt,
|
||||
endAt: row.endAt,
|
||||
})
|
||||
await loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadBanners)
|
||||
@@ -141,4 +301,22 @@ onMounted(loadBanners)
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
|
||||
.preview-stage { padding: 20px; background: var(--color-bg-subtle); border-radius: 12px; }
|
||||
.preview-card {
|
||||
padding: 20px 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
|
||||
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
|
||||
}
|
||||
.preview-tag {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
|
||||
}
|
||||
.preview-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
|
||||
.preview-button {
|
||||
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
|
||||
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,18 +3,38 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="内容分类" name="categories">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
|
||||
<el-table :data="categories" stripe>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>内容分类</span>
|
||||
<el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="分类不做删除:文章通过分类ID引用它,删除会让历史文章分类悬空。不再使用的分类请「停用」,停用后小程序端不再下发,但后台仍可见并可重新启用。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-table :data="categories" stripe v-loading="categoryLoading">
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="code" label="编码" width="150" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ categoryStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCategory(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||
@click="toggleCategoryStatus(row)"
|
||||
>
|
||||
{{ row.status === 'active' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -22,21 +42,26 @@
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="文章内容" name="articles">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>文章内容</span>
|
||||
<el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="articles" stripe v-loading="loading">
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
|
||||
<template #default="{ row }">{{ categoryLabel(row.categoryId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="view_count" label="阅读量" width="80" />
|
||||
<el-table-column prop="viewCount" label="阅读量" width="80" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editArticle(row)">编辑</el-button>
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||||
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||||
<el-button v-if="row.status !== 'published'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||||
<el-button v-if="row.status === 'published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -47,30 +72,35 @@
|
||||
<!-- 分类Dialog -->
|
||||
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
|
||||
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
|
||||
<el-form-item label="名称" required><el-input v-model="categoryForm.name" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="编码" required>
|
||||
<el-input v-model="categoryForm.code" maxlength="64" show-word-limit placeholder="唯一标识,如 classroom" />
|
||||
<div class="field-hint">编码全局唯一,重复会被拒绝</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="categoryForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 文章Dialog -->
|
||||
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
|
||||
<el-form :model="articleForm" label-width="80px">
|
||||
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
|
||||
<el-form :model="articleForm" label-width="80px" v-loading="articleDetailLoading">
|
||||
<el-form-item label="标题" required><el-input v-model="articleForm.title" maxlength="200" show-word-limit /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="articleForm.categoryId">
|
||||
<el-select v-model="articleForm.categoryId" clearable placeholder="请选择分类">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
|
||||
<el-form-item label="正文"><el-input v-model="articleForm.content" type="textarea" :rows="10" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="articleForm.sortOrder" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="articleDialogVisible=false">取消</el-button>
|
||||
<el-button @click="articleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -79,76 +109,149 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put, del } from '@/api/client'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('categories')
|
||||
const loading = ref(false)
|
||||
const categoryLoading = ref(false)
|
||||
const articleDetailLoading = ref(false)
|
||||
const categories = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const editingCategoryId = ref<string | null>(null)
|
||||
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
|
||||
const categoryForm = ref({ name: '', code: '', description: '', sortOrder: 0 })
|
||||
|
||||
const articleDialogVisible = ref(false)
|
||||
const editingArticleId = ref<string | null>(null)
|
||||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
|
||||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '', sortOrder: 0 })
|
||||
|
||||
const CATEGORY_STATUS_LABEL: Record<string, string> = { active: '启用中', inactive: '已停用' }
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', published: '已发布', offline: '已下架' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', published: 'success', offline: 'danger' }
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function categoryStatusLabel(s: string) { return CATEGORY_STATUS_LABEL[s] || s }
|
||||
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
|
||||
|
||||
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
|
||||
async function loadCategories() {
|
||||
const res: any = await get('/content/categories')
|
||||
categories.value = res.data || []
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
// operator 接口:含已停用分类,否则停用后无法在后台重新启用
|
||||
const res: any = await get('/content/operator/categories')
|
||||
categories.value = res.data || []
|
||||
} catch (e) { console.error(e) } finally { categoryLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/content/articles')
|
||||
// operator 接口:含全部状态。公开接口只给 published,草稿不出现在列表里 → 无法点「发布」
|
||||
const res: any = await get('/content/operator/articles')
|
||||
articles.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function validateCategory(): boolean {
|
||||
if (!categoryForm.value.name.trim()) { ElMessage.error('请填写分类名称'); return false }
|
||||
if (!categoryForm.value.code.trim()) { ElMessage.error('请填写分类编码'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!validateCategory()) return
|
||||
try {
|
||||
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
|
||||
else await post('/content/operator/categories', categoryForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
categoryDialogVisible.value = false
|
||||
loadCategories()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
|
||||
async function deleteCategory(row: any) {
|
||||
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
|
||||
catch (e) { ElMessage.error('删除失败') }
|
||||
|
||||
function editCategory(row: any) {
|
||||
editingCategoryId.value = row.id
|
||||
categoryForm.value = {
|
||||
name: row.name || '', code: row.code || '',
|
||||
description: row.description || '', sortOrder: row.sortOrder ?? 0,
|
||||
}
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function toggleCategoryStatus(row: any) {
|
||||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await put(`/content/operator/categories/${row.id}/status`, { status: next })
|
||||
ElMessage.success(next === 'active' ? '已启用' : '已停用')
|
||||
loadCategories()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function validateArticle(): boolean {
|
||||
if (!articleForm.value.title.trim()) { ElMessage.error('请填写文章标题'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveArticle() {
|
||||
if (!validateArticle()) return
|
||||
try {
|
||||
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
|
||||
else await post('/content/operator/articles', articleForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
articleDialogVisible.value = false
|
||||
loadArticles()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
}
|
||||
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
|
||||
async function publishArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('发布失败') }
|
||||
}
|
||||
async function offlineArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('下架失败') }
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
|
||||
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
|
||||
async function editArticle(row: any) {
|
||||
editingArticleId.value = row.id
|
||||
articleForm.value = {
|
||||
title: row.title || '', categoryId: row.categoryId || '',
|
||||
summary: row.summary || '', content: '', sortOrder: row.sortOrder ?? 0,
|
||||
}
|
||||
articleDialogVisible.value = true
|
||||
// 列表 DTO 不含正文(有意保持轻量),必须拉详情才能回填正文
|
||||
articleDetailLoading.value = true
|
||||
try {
|
||||
const res: any = await get(`/content/operator/articles/${row.id}`)
|
||||
const detail = res.data || {}
|
||||
articleForm.value = {
|
||||
title: detail.title || '', categoryId: detail.categoryId || '',
|
||||
summary: detail.summary || '', content: detail.content || '',
|
||||
sortOrder: detail.sortOrder ?? 0,
|
||||
}
|
||||
} catch (e) { console.error(e) } finally { articleDetailLoading.value = false }
|
||||
}
|
||||
|
||||
async function publishArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function offlineArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||||
catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openCategoryDialog() {
|
||||
editingCategoryId.value = null
|
||||
categoryForm.value = { name: '', code: '', description: '', sortOrder: 0 }
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openArticleDialog() {
|
||||
editingArticleId.value = null
|
||||
articleForm.value = { title: '', categoryId: '', summary: '', content: '', sortOrder: 0 }
|
||||
articleDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => { loadCategories(); loadArticles() })
|
||||
</script>
|
||||
<style
|
||||
scoped></style>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,83 @@
|
||||
<template>
|
||||
<div class="counseling-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益婚恋咨询管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>公益婚恋咨询管理</span>
|
||||
<el-button size="small" @click="reloadAll">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 权限不足时明确提示,而不是静默显示空表 -->
|
||||
<el-alert
|
||||
v-if="!canViewAny"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前账号没有咨询服务权限"
|
||||
description="如需查看,请联系管理员分配 counseling.service.view 权限。"
|
||||
/>
|
||||
|
||||
<el-tabs v-else v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- 待分配队列(仅 counseling.service.assign 可见) -->
|
||||
<el-tab-pane v-if="canAssign" label="待分配队列" name="queue">
|
||||
<el-table :data="pendingQueue" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-skeleton v-if="loadingQueue" :rows="4" animated />
|
||||
<el-empty v-else-if="queueError" :description="queueError">
|
||||
<el-button size="small" @click="loadQueue">重试</el-button>
|
||||
</el-empty>
|
||||
<el-empty v-else-if="!pendingQueue.length" description="暂无待分配的咨询申请" />
|
||||
<el-table v-else :data="pendingQueue" stripe>
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="requestSource" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="申请时间" width="160">
|
||||
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.userRequestSummary || '(无)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openAssign(row)">分配咨询师</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的咨询(Counselor)" name="assigned">
|
||||
<el-table :data="assignedServices" stripe v-loading="loading">
|
||||
|
||||
<!-- 我的服务(Counselor) -->
|
||||
<el-tab-pane label="我的咨询(咨询师)" name="assigned">
|
||||
<el-skeleton v-if="loadingAssigned" :rows="4" animated />
|
||||
<el-empty v-else-if="assignedError" :description="assignedError">
|
||||
<el-button size="small" @click="loadAssigned">重试</el-button>
|
||||
</el-empty>
|
||||
<el-empty v-else-if="!assignedServices.length" description="暂无分配给您的咨询服务" />
|
||||
<el-table v-else :data="assignedServices" stripe>
|
||||
<el-table-column prop="serviceType" label="类型" width="140">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scheduledStartAt" label="预约时间" width="160">
|
||||
<el-table-column prop="scheduledStartAt" label="预约时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.scheduledStartAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canProcess && row.status==='assigned'" size="small" @click="scheduleCounseling(row)">排期</el-button>
|
||||
<el-button v-if="canProcess && row.status==='scheduled'" size="small" type="success" @click="startCounseling(row)">开始</el-button>
|
||||
<el-button v-if="canCreateNote && row.status==='in_progress'" size="small" type="primary" @click="showFollowup(row)">跟进</el-button>
|
||||
<el-button v-if="canProcess && row.status==='in_progress'" size="small" type="warning" @click="completeCounseling(row)">完成</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'assigned'" size="small"
|
||||
@click="doSchedule(row)">排期</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'scheduled'" size="small" type="success"
|
||||
@click="doStart(row)">开始</el-button>
|
||||
<el-button v-if="canCreateNote && row.status === 'in_progress'" size="small" type="primary"
|
||||
@click="showFollowup(row)">跟进</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'in_progress'" size="small" type="warning"
|
||||
@click="doComplete(row)">完成</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -49,29 +87,33 @@
|
||||
|
||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="咨询师"><el-select v-model="assignForm.counselorId" placeholder="选择咨询师">
|
||||
<el-option label="张咨询师" value="counselor01" />
|
||||
<el-option label="李咨询师" value="counselor02" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="咨询师">
|
||||
<el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doAssign">确认分配</el-button>
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="doAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="跟进类型"><el-select v-model="followupForm.type">
|
||||
<el-option label="电话回访" value="phone_call" />
|
||||
<el-option label="面谈" value="face_to_face" />
|
||||
<el-option label="线上沟通" value="online_chat" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="followupForm.content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="跟进类型">
|
||||
<el-select v-model="followupForm.type">
|
||||
<el-option label="电话回访" value="phone_call" />
|
||||
<el-option label="面谈" value="face_to_face" />
|
||||
<el-option label="线上沟通" value="online_chat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容">
|
||||
<el-input v-model="followupForm.content" type="textarea" :rows="4"
|
||||
placeholder="仅参与本次咨询的咨询师与申请人本人可见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doFollowup">保存</el-button>
|
||||
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="doFollowup">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -80,21 +122,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canViewAny = computed(() =>
|
||||
authStore.permissions.some((p) => p.startsWith('counseling.')))
|
||||
const canAssign = computed(() => authStore.permissions.includes('counseling.service.assign'))
|
||||
const canProcess = computed(() => authStore.permissions.includes('counseling.service.process'))
|
||||
const canCreateNote = computed(() => authStore.permissions.includes('counseling.note.create'))
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'queue' : 'assigned')
|
||||
const loading = ref(false)
|
||||
const loadingQueue = ref(false)
|
||||
const loadingAssigned = ref(false)
|
||||
const submitting = ref(false)
|
||||
const queueError = ref('')
|
||||
const assignedError = ref('')
|
||||
const pendingQueue = ref<any[]>([])
|
||||
const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const followupDialogVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const assignForm = ref({ counselorId: '' })
|
||||
const assignForm = ref({ counselorNumber: '' })
|
||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -102,11 +151,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
|
||||
}
|
||||
const REQUEST_SOURCE_LABEL: Record<string, string> = {
|
||||
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助'
|
||||
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助',
|
||||
activity_followup: '活动后续跟进', service_station: '服务站转介'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待分配', assigned: '已分配', scheduled: '已排期',
|
||||
in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
|
||||
in_progress: '服务中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
pending: 'warning', assigned: 'info', scheduled: 'primary',
|
||||
@@ -119,42 +169,144 @@ function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadQueue() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/counseling/services/operator/queue'); pendingQueue.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssigned() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/counseling/services/operator/assigned'); assignedServices.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function openAssign(row: any) { currentId.value = row.id; assignForm.value.counselorId = ''; assignDialogVisible.value = true }
|
||||
async function doAssign() {
|
||||
try { await post(`/counseling/services/operator/${currentId.value}/assign`, { counselorId: assignForm.value.counselorId }); ElMessage.success('分配成功'); assignDialogVisible.value = false; loadQueue() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function scheduleCounseling(row: any) {
|
||||
const now = new Date(); now.setHours(now.getHours() + 2)
|
||||
const end = new Date(now); end.setHours(end.getHours() + 1)
|
||||
try { await post(`/counseling/services/operator/${row.id}/schedule`, { startAt: now.toISOString(), endAt: end.toISOString() }); ElMessage.success('排期成功'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function startCounseling(row: any) {
|
||||
try { await post(`/counseling/services/operator/${row.id}/start`, {}); ElMessage.success('已开始'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
function showFollowup(row: any) { currentId.value = row.id; followupForm.value = { type: 'phone_call', content: '' }; followupDialogVisible.value = true }
|
||||
async function doFollowup() {
|
||||
try { await post(`/counseling/services/operator/${currentId.value}/followup`, { followupType: followupForm.value.type, content: followupForm.value.content }); ElMessage.success('跟进记录已保存'); followupDialogVisible.value = false; loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function completeCounseling(row: any) {
|
||||
try { await post(`/counseling/services/operator/${row.id}/complete`, {}); ElMessage.success('已完成'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
function errText(e: any, fallback: string) {
|
||||
return e?.response?.data?.message || e?.message || fallback
|
||||
}
|
||||
|
||||
onMounted(() => { if (canAssign.value) loadQueue(); loadAssigned() })
|
||||
async function loadQueue() {
|
||||
loadingQueue.value = true
|
||||
queueError.value = ''
|
||||
try {
|
||||
const res: any = await get('/counseling/services/operator/queue')
|
||||
pendingQueue.value = res?.data || []
|
||||
} catch (e: any) {
|
||||
queueError.value = errText(e, '加载待分配队列失败')
|
||||
} finally {
|
||||
loadingQueue.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssigned() {
|
||||
loadingAssigned.value = true
|
||||
assignedError.value = ''
|
||||
try {
|
||||
const res: any = await get('/counseling/services/operator/assigned')
|
||||
assignedServices.value = res?.data || []
|
||||
} catch (e: any) {
|
||||
assignedError.value = errText(e, '加载我的咨询失败')
|
||||
} finally {
|
||||
loadingAssigned.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
if (canAssign.value) loadQueue()
|
||||
loadAssigned()
|
||||
}
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
if (name === 'queue') loadQueue()
|
||||
else if (name === 'assigned') loadAssigned()
|
||||
}
|
||||
|
||||
function openAssign(row: any) {
|
||||
currentId.value = row.id
|
||||
assignForm.value.counselorNumber = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorNumber) {
|
||||
ElMessage.warning('请填写咨询师编号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorNumber })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialogVisible.value = false
|
||||
loadQueue()
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
// 并发分配冲突(条件更新未命中)会带明确 message
|
||||
ElMessage.error(errText(e, '分配失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doSchedule(row: any) {
|
||||
const start = new Date()
|
||||
start.setHours(start.getHours() + 2)
|
||||
const end = new Date(start)
|
||||
end.setHours(end.getHours() + 1)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将为该申请排期:${formatDateTime(start.toISOString())} ~ ${formatDateTime(end.toISOString())}`,
|
||||
'确认排期', { type: 'info' })
|
||||
} catch { return }
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/schedule`,
|
||||
{ startAt: start.toISOString(), endAt: end.toISOString() })
|
||||
ElMessage.success('排期成功')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '排期失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function doStart(row: any) {
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/start`, {})
|
||||
ElMessage.success('已开始服务')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function showFollowup(row: any) {
|
||||
currentId.value = row.id
|
||||
followupForm.value = { type: 'phone_call', content: '' }
|
||||
followupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doFollowup() {
|
||||
if (!followupForm.value.content.trim()) {
|
||||
ElMessage.warning('请填写跟进内容')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/followup`,
|
||||
{ followupType: followupForm.value.type, content: followupForm.value.content })
|
||||
ElMessage.success('跟进记录已保存')
|
||||
followupDialogVisible.value = false
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doComplete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将本次服务标记为已完成?完成后不可再修改。', '确认完成', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/complete`, {})
|
||||
ElMessage.success('已完成')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { reloadAll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="matchmaker-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab" @tab-change="refresh">
|
||||
<el-tab-pane v-if="canAssign" label="服务进展与跟进" name="management">
|
||||
<el-table :data="managedServices" stripe v-loading="loading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="150" />
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" min-width="150" />
|
||||
<el-table-column label="服务状态" width="120"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
|
||||
<el-table-column prop="latestFollowupContent" label="最新跟进" min-width="230" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110"><template #default="{ row }">
|
||||
<el-button link type="primary" @click="openServiceDetail(row.serviceId, true)">查看记录</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
|
||||
<el-table :data="services" stripe v-loading="loading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="需求说明" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="推荐列表" name="recommendations">
|
||||
<el-table :data="recommendations" stripe v-loading="loading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
|
||||
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="130">
|
||||
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||
<el-tab-pane label="服务对象分配" name="assignments">
|
||||
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignedAt" label="分配时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="有效期至" width="170">
|
||||
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.serviceId" size="small" @click="openServiceDetail(row.serviceId)">查看服务</el-button>
|
||||
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
|
||||
<el-table-column prop="channel" label="渠道" width="110">
|
||||
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending_approval' && canApprove">
|
||||
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
|
||||
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
|
||||
size="small" @click="doContactRevoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="红娘">
|
||||
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配理由" required>
|
||||
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="新红娘">
|
||||
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="转分配理由" required>
|
||||
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
|
||||
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="terminateDialogVisible=false">取消</el-button>
|
||||
<el-button type="danger" @click="doTerminate">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
|
||||
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="recDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
|
||||
<el-descriptions v-if="serviceDetail" :column="2" border>
|
||||
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider content-position="left">服务历史</el-divider>
|
||||
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
|
||||
{{ item.content }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<template v-if="serviceDetail && !managementDetail">
|
||||
<el-divider />
|
||||
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
|
||||
<el-button
|
||||
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
|
||||
type="success"
|
||||
plain
|
||||
@click="completeCurrentService"
|
||||
>完成服务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="跟进方式">
|
||||
<el-select v-model="followupForm.followupType" style="width: 100%">
|
||||
<el-option label="电话沟通" value="phone_call" />
|
||||
<el-option label="微信沟通" value="wechat" />
|
||||
<el-option label="面对面" value="face_to_face" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容" required>
|
||||
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const managementDetail = ref(false)
|
||||
const managedServices = ref<any[]>([])
|
||||
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||
const canAssign = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
|
||||
const loading = ref(false)
|
||||
const services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignments = ref<any[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
|
||||
const serviceDetailVisible = ref(false)
|
||||
const serviceDetail = ref<any>(null)
|
||||
const followupDialogVisible = ref(false)
|
||||
const followupForm = ref({ followupType: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
|
||||
processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
|
||||
processing: '', completed: 'success', cancelled: 'danger'
|
||||
}
|
||||
const REC_STATUS_LABEL: Record<string, string> = {
|
||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||
passed: '已跳过', confirmed_mutual: '双向确认', rejected: '已拒绝', expired: '已过期'
|
||||
}
|
||||
const REC_STATUS_TAG: Record<string, string> = {
|
||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
|
||||
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
|
||||
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
|
||||
}
|
||||
const CONTACT_STATUS_LABEL: Record<string, string> = {
|
||||
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
|
||||
}
|
||||
const CONTACT_STATUS_TAG: Record<string, string> = {
|
||||
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
|
||||
rejected: 'danger', expired: 'info', revoked: 'info'
|
||||
}
|
||||
|
||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
||||
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
|
||||
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
|
||||
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||
|
||||
async function openServiceDetail(serviceId: string, management = false) {
|
||||
managementDetail.value = management
|
||||
if (!serviceId) return
|
||||
try {
|
||||
const res: any = await get(`/operator/matchmaker/${management ? 'management/' : ''}services/${serviceId}`)
|
||||
serviceDetail.value = res?.data || null
|
||||
serviceDetailVisible.value = true
|
||||
} catch { serviceDetail.value = null }
|
||||
}
|
||||
|
||||
function openFollowup() {
|
||||
followupForm.value = { followupType: 'phone_call', content: '' }
|
||||
followupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitFollowup() {
|
||||
if (!followupForm.value.content.trim() || !serviceDetail.value?.serviceId) {
|
||||
ElMessage.warning('请填写跟进内容')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/followup`, followupForm.value)
|
||||
ElMessage.success('跟进记录已保存')
|
||||
followupDialogVisible.value = false
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存跟进记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCurrentService() {
|
||||
if (!serviceDetail.value?.serviceId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该红娘服务标记为已完成吗?', '完成服务', { type: 'warning' })
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/complete`, {})
|
||||
ElMessage.success('服务已完成')
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
await loadAssignments()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.response?.data?.message || '完成服务失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||
const canApprove = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
const contactAuths = ref<any[]>([])
|
||||
const contactAuthsLoading = ref(false)
|
||||
|
||||
async function loadContactAuths() {
|
||||
contactAuthsLoading.value = true
|
||||
try {
|
||||
const endpoint = canApprove.value
|
||||
? '/operator/matchmaker/contact-authorizations/pending'
|
||||
: '/operator/matchmaker/contact-authorizations/my'
|
||||
const res: any = await get(endpoint)
|
||||
contactAuths.value = res?.data?.list || []
|
||||
} finally { contactAuthsLoading.value = false }
|
||||
}
|
||||
async function doContactApprove(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
|
||||
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactReject(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
|
||||
ElMessage.success('已拒绝'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactRevoke(row: any) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
|
||||
confirmButtonText: '确认撤销', cancelButtonText: '取消',
|
||||
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
|
||||
})
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
|
||||
ElMessage.success('已撤销'); loadContactAuths()
|
||||
} catch { /* 用户取消 */ }
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
|
||||
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadRecommendations() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/recommendations/operator` 不存在(404)。
|
||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssignments() {
|
||||
assignmentsLoading.value = true
|
||||
// ZC-MM-002:本人名下生效中的服务对象分配
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
|
||||
}
|
||||
async function loadAssignableMatchmakers() {
|
||||
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||
}
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'management') {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/management/services'); managedServices.value = res?.data || [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
else if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||
else await loadRecommendations()
|
||||
}
|
||||
|
||||
async function openAssign(row: any) {
|
||||
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
|
||||
currentServiceId.value = row.serviceId || row.id
|
||||
currentUserId.value = row.userId
|
||||
assignForm.value = { matchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
|
||||
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
|
||||
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
|
||||
try {
|
||||
await post('/operator/matchmaker/assignments', {
|
||||
userId: currentUserId.value,
|
||||
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
|
||||
serviceId: currentServiceId.value,
|
||||
reason: assignForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function openTransfer(row: any) {
|
||||
currentAssignmentId.value = row.id
|
||||
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
async function doTransfer() {
|
||||
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
|
||||
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
|
||||
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
|
||||
reason: transferForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openTerminate(row: any, action: 'revoke' | 'end') {
|
||||
currentAssignmentId.value = row.id
|
||||
terminateAction.value = action
|
||||
terminateForm.value = { reason: '' }
|
||||
terminateDialogVisible.value = true
|
||||
}
|
||||
async function doTerminate() {
|
||||
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
|
||||
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
|
||||
reason: terminateForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
async function doCreateRecommendation() {
|
||||
if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
|
||||
targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
|
||||
})
|
||||
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
if (canAssign.value && typeof route.query.serviceId === 'string') {
|
||||
activeTab.value = 'management'
|
||||
await openServiceDetail(route.query.serviceId, true)
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="displayId" label="人员编号" width="150" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||
@@ -62,6 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
@@ -79,7 +81,7 @@ const orgMap = ref<Record<string, string>>({})
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
@@ -89,7 +91,7 @@ async function loadData() {
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
orgList.value = res.data || []
|
||||
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
||||
@@ -105,7 +107,7 @@ async function save() {
|
||||
if (!form.value.username || !form.value.password) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
@@ -115,7 +117,7 @@ async function save() {
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
||||
loadData()
|
||||
@@ -126,7 +128,7 @@ async function resetPwd(id: string) {
|
||||
const pwd = prompt('请输入新密码(至少8位)')
|
||||
if (!pwd || pwd.length < 8) return
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -101,7 +102,7 @@ async function save() {
|
||||
if (!form.value.name) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (editId.value) {
|
||||
await put(`/operator/organizations/${editId.value}`, form.value, {
|
||||
Authorization: `Bearer ${token}`
|
||||
@@ -119,7 +120,7 @@ async function save() {
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="关联ID" width="120" />
|
||||
<el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
@@ -30,7 +30,9 @@ function typeLabel(t: string) { return t === 'service_station' ? '服务站' : t
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/referrals/my-history'); referrals.value = res.data || [] } finally { loading.value = false }
|
||||
// Phase 13 契约修复:原 `/referrals/my-history` 是 C 端用户端点(UserPrincipal),
|
||||
// 运营端无"我的用户身份"→ 取不到数据。改用运营端端点(按发起红娘过滤)。
|
||||
try { const res: any = await get('/referrals/operator/history'); referrals.value = res.data || [] } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -1,230 +1,290 @@
|
||||
<template>
|
||||
<el-container class="review-page">
|
||||
<el-header class="header">
|
||||
<h2>资料审核</h2>
|
||||
<el-tag type="warning" v-if="stats.pending > 0">待审核 {{ stats.pending }}</el-tag>
|
||||
<el-tag type="info" v-if="stats.processing > 0">审核中 {{ stats.processing }}</el-tag>
|
||||
</el-header>
|
||||
<div class="page-container page-shell review-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="page-kicker">用户与资料审核</span>
|
||||
<h1>资料审核</h1>
|
||||
<p>按提交顺序核验必要资料,审核操作保留清晰意见和审计记录。</p>
|
||||
</div>
|
||||
<div class="stat-tags">
|
||||
<el-tag type="warning">待审核 {{ stats.pending }}</el-tag>
|
||||
<el-tag type="info">审核中 {{ stats.processing }}</el-tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-main>
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
|
||||
<template #default><el-button link type="primary" @click="loadPage">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
|
||||
<section v-else class="surface-card review-panel">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="待审核队列" name="pending">
|
||||
<el-table :data="pendingTasks" v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="reviewType" label="审核类型" width="120" />
|
||||
<el-table-column prop="submittedAt" label="提交时间" width="180">
|
||||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||||
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.submittedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleClaim(row)">领取</el-button>
|
||||
<el-button v-if="canClaim(row)" type="primary" link @click="handleClaim(row)">领取审核</el-button>
|
||||
<span v-else class="muted">只读</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无待审核任务" /></template>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && pendingTasks.length === 0" description="暂无待审核任务" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="我的审核" name="my">
|
||||
<el-table :data="myTasks" v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||||
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.completedAt ? formatTime(row.completedAt) : '-' }}
|
||||
</template>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleView(row)">查看</el-button>
|
||||
</template>
|
||||
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }"><el-button link type="primary" @click="handleView(row)">查看</el-button></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无已领取的审核任务" /></template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-main>
|
||||
</section>
|
||||
|
||||
<!-- 审核详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="审核详情" size="500px">
|
||||
<template v-if="currentTask">
|
||||
<el-drawer v-model="detailVisible" title="审核详情" size="min(560px, 92vw)">
|
||||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||||
<template v-else-if="currentTask">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户ID">{{ currentTask.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核类型">{{ currentTask.reviewType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider>用户资料</el-divider>
|
||||
<el-descriptions :column="2" border v-if="currentTask.userProfile">
|
||||
<el-descriptions-item label="姓名">{{ currentTask.userProfile.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ currentTask.userProfile.gender }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age }}岁</el-descriptions-item>
|
||||
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm }}cm</el-descriptions-item>
|
||||
<el-descriptions-item label="婚姻状况">{{ currentTask.userProfile.maritalStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">
|
||||
{{ currentTask.userProfile.selfIntro || '暂未填写' }}
|
||||
</el-descriptions-item>
|
||||
<h3 class="drawer-heading">用户资料</h3>
|
||||
<el-descriptions v-if="currentTask.userProfile" :column="2" border>
|
||||
<el-descriptions-item label="显示名称">{{ currentTask.userProfile.displayName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderText(currentTask.userProfile.gender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm ? `${currentTask.userProfile.heightCm} cm` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ currentTask.userProfile.regionName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">{{ currentTask.userProfile.selfIntro || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else description="暂无资料快照" />
|
||||
|
||||
<el-divider>审核操作</el-divider>
|
||||
<el-form :model="reviewForm" label-width="80px">
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="reviewForm.comment" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核原因">
|
||||
<el-select v-model="reviewForm.reasonCodes" multiple placeholder="可选" style="width:100%">
|
||||
<el-option
|
||||
v-for="reason in reasonOptions"
|
||||
:key="reason.code"
|
||||
:label="reason.name"
|
||||
:value="reason.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="handleApprove" :loading="submitting">通过</el-button>
|
||||
<el-button type="warning" @click="handleNeedEdit" :loading="submitting">需修改</el-button>
|
||||
<el-button type="danger" @click="handleReject" :loading="submitting">拒绝</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template v-if="currentTask.reviewType === 'local_eligibility'">
|
||||
<h3 class="drawer-heading">本地资格佐证</h3>
|
||||
<el-alert title="佐证属于敏感数据,仅在审核必要时查看;每次访问都会记录目的和结果。" type="warning" :closable="false" show-icon />
|
||||
<el-table :data="currentTask.localEligibilityEvidences || []" class="evidence-table">
|
||||
<el-table-column prop="evidenceType" label="佐证类型" min-width="140" />
|
||||
<el-table-column prop="createdAt" label="上传时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canViewEvidence" link type="primary" @click="viewEvidence(row)">查看</el-button>
|
||||
<span v-else class="muted">无权限</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template v-if="canProcessCurrent">
|
||||
<h3 class="drawer-heading">审核操作</h3>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model.trim="reviewForm.comment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请写明判断依据或需要补充的内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原因标签">
|
||||
<el-select v-model="reviewForm.reasonCodes" multiple class="full-width" placeholder="按需选择">
|
||||
<el-option v-for="reason in reasonOptions" :key="reason.code" :label="reason.name" :value="reason.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="review-actions">
|
||||
<el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button>
|
||||
<el-button type="warning" :loading="submitting" @click="runDecision('need-edit', currentTask.reviewType === 'local_eligibility' ? '要求补充佐证' : '退回修改')">{{ currentTask.reviewType === 'local_eligibility' ? '补充佐证' : '需修改' }}</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const activeTab = ref('pending')
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const stats = ref({ pending: 0, processing: 0 })
|
||||
const pendingTasks = ref<any[]>([])
|
||||
const myTasks = ref<any[]>([])
|
||||
const tasks = ref<any[]>([])
|
||||
const currentTask = ref<any>(null)
|
||||
const reasonOptions = ref<any[]>([])
|
||||
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
|
||||
const canProcess = computed(() => authStore.permissions.includes('review.process'))
|
||||
const canViewEvidence = computed(() => authStore.permissions.includes('local_eligibility.evidence.view'))
|
||||
const canProcessCurrent = computed(() => currentTask.value?.reviewType === 'local_eligibility'
|
||||
? authStore.permissions.includes('local_eligibility.review')
|
||||
: canProcess.value)
|
||||
|
||||
const reviewForm = ref({
|
||||
comment: '',
|
||||
reasonCodes: [] as string[]
|
||||
})
|
||||
function canClaim(task: any) {
|
||||
return task.reviewType === 'local_eligibility'
|
||||
? authStore.permissions.includes('local_eligibility.review')
|
||||
: canProcess.value
|
||||
}
|
||||
const pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
|
||||
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId))
|
||||
|
||||
async function loadStats() {
|
||||
// TODO: Phase 05 实现真实API调用
|
||||
stats.value = { pending: 12, processing: 3 }
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [statsResponse, tasksResponse, reasonsResponse]: any[] = await Promise.all([
|
||||
get('/operator/reviews/stats'),
|
||||
get('/operator/reviews/pending', { page: 1, pageSize: 20 }),
|
||||
get('/operator/reviews/reasons'),
|
||||
])
|
||||
stats.value = statsResponse.data || { pending: 0, processing: 0 }
|
||||
tasks.value = tasksResponse.data || []
|
||||
reasonOptions.value = reasonsResponse.data || []
|
||||
} catch (error: any) {
|
||||
tasks.value = []
|
||||
errorMessage.value = error.response?.status === 403
|
||||
? '您没有查看资料审核的权限'
|
||||
: '审核任务加载失败,请检查服务状态后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts?: string) {
|
||||
if (!ts) return '-'
|
||||
return new Date(ts).toLocaleString('zh-CN')
|
||||
async function handleClaim(row: any) {
|
||||
try {
|
||||
const response: any = await post(`/operator/reviews/${row.taskId || row.id}/claim`)
|
||||
if (!response.success) throw new Error(response.message || '领取失败')
|
||||
ElMessage.success('审核任务领取成功')
|
||||
await loadPage()
|
||||
await openDetail(row.taskId || row.id)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '领取失败,任务可能已被其他审核员领取')
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(taskId: string) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentTask.value = null
|
||||
reviewForm.value = { comment: '', reasonCodes: [] }
|
||||
try {
|
||||
const response: any = await get(`/operator/reviews/${taskId}`)
|
||||
currentTask.value = response.data
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleView(row: any) {
|
||||
await openDetail(row.taskId || row.id)
|
||||
}
|
||||
|
||||
async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: string) {
|
||||
if (!currentTask.value || submitting.value) return
|
||||
if (action !== 'approve' && !reviewForm.value.comment) {
|
||||
ElMessage.warning('请填写审核意见,说明需要修改或拒绝的原因')
|
||||
return
|
||||
}
|
||||
if (action === 'reject') {
|
||||
await ElMessageBox.confirm('拒绝后用户需要重新处理资料,确认继续吗?', '确认拒绝资料', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认拒绝',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const localActions = {
|
||||
approve: 'approve-local-eligibility',
|
||||
'need-edit': 'need-evidence',
|
||||
reject: 'reject-local-eligibility',
|
||||
}
|
||||
const endpointAction = currentTask.value.reviewType === 'local_eligibility' ? localActions[action] : action
|
||||
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${endpointAction}`, {
|
||||
comment: reviewForm.value.comment,
|
||||
reasonCodes: reviewForm.value.reasonCodes,
|
||||
})
|
||||
if (!response.success) throw new Error(response.message || `${label}失败`)
|
||||
ElMessage.success(label)
|
||||
detailVisible.value = false
|
||||
await loadPage()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || `${label}失败,请重试`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewEvidence(row: any) {
|
||||
if (!currentTask.value) return
|
||||
try {
|
||||
const { value: purpose } = await ElMessageBox.prompt('请填写本次查看佐证的业务目的', '敏感数据访问确认', {
|
||||
confirmButtonText: '记录并查看',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '访问目的不能为空',
|
||||
})
|
||||
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/evidences/${row.evidenceId}/access`, { purpose })
|
||||
window.open(response.data.url, '_blank', 'noopener,noreferrer')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '佐证访问失败')
|
||||
}
|
||||
}
|
||||
|
||||
function reviewTypeText(value?: string) {
|
||||
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
|
||||
}
|
||||
|
||||
function genderText(value?: string) {
|
||||
return value === 'male' ? '男' : value === 'female' ? '女' : '-'
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
approved: 'success',
|
||||
need_edit: 'warning',
|
||||
rejected: 'danger'
|
||||
}
|
||||
return map[status] || ''
|
||||
return ({ pending: 'warning', processing: 'info', approved: 'success', need_edit: 'warning', rejected: 'danger' } as Record<string, string>)[status] || 'info'
|
||||
}
|
||||
|
||||
function statusText(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
processing: '审核中',
|
||||
approved: '已通过',
|
||||
need_edit: '需修改',
|
||||
rejected: '已拒绝'
|
||||
}
|
||||
return map[status] || status
|
||||
return ({ pending: '待审核', processing: '审核中', approved: '已通过', need_edit: '需修改', rejected: '已拒绝' } as Record<string, string>)[status] || status || '-'
|
||||
}
|
||||
|
||||
function handleClaim(_row: any) {
|
||||
// TODO: 调用 API 领取
|
||||
ElMessage.success('领取成功')
|
||||
function formatTime(time?: string) {
|
||||
return time ? new Date(time).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
currentTask.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
submitting.value = true
|
||||
try {
|
||||
// TODO: 调用 API
|
||||
ElMessage.success('审核通过')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNeedEdit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
ElMessage.success('已标记需修改')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
submitting.value = true
|
||||
try {
|
||||
ElMessage.success('已拒绝')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
onMounted(loadPage)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
||||
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
|
||||
<el-table-column prop="displayId" label="举报编号" min-width="180" />
|
||||
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
|
||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
@@ -60,7 +61,7 @@
|
||||
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
|
||||
<el-descriptions :column="2" border v-if="detail.riskProfile">
|
||||
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
|
||||
@@ -72,7 +73,7 @@
|
||||
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="processResult" style="width:100%">
|
||||
@@ -98,8 +99,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
@@ -123,7 +126,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -133,14 +136,14 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
|
||||
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
|
||||
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
|
||||
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
|
||||
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
@@ -151,7 +154,7 @@ async function openDetail(reportId: string) {
|
||||
processResult.value = 'no_action'
|
||||
processNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -161,7 +164,7 @@ async function handleProcess() {
|
||||
if (!processResult.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
||||
{ processResult: processResult.value, processNote: processNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
@@ -188,7 +191,7 @@ onMounted(loadData)
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 36px; font-weight: bold; }
|
||||
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</el-radio-group>
|
||||
</div></template>
|
||||
<el-table :data="appointments" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="displayId" label="预约编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
||||
<el-table-column prop="source" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="preferredDate" label="预约日期" width="110" />
|
||||
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
|
||||
<el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -1,41 +1,96 @@
|
||||
<template>
|
||||
<div class="service-program">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>专项公益服务项目</span><el-button type="primary" size="small" disabled>新建项目(待开放)</el-button></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>专项公益服务项目</span>
|
||||
<el-button type="primary" size="small" @click="openCreate">新建项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="programs" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="项目名称" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='draft'?'info':'danger'">{{ row.status }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="programStatusTag(row.status)">{{ programStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间范围" width="220">
|
||||
<template #default="{ row }">{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</template>
|
||||
<template #default="{ row }">{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员数" width="80">
|
||||
<template #default="{ row }">{{ row.memberCount || 0 }}</template>
|
||||
<el-table-column label="已通过" width="80">
|
||||
<template #default="{ row }">{{ row.memberCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<el-table-column label="待审批" width="85">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
|
||||
<el-button size="small" @click="viewMembers(row)">查看成员</el-button>
|
||||
<el-tag v-if="row.pendingCount" type="warning" size="small">{{ row.pendingCount }}</el-tag>
|
||||
<span v-else>0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
|
||||
<el-button size="small" @click="viewMembers(row)">成员审批</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 成员Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" title="项目成员" width="700px">
|
||||
<!-- 项目新建/编辑 Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑项目' : '新建项目'" width="620px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="项目名称" required><el-input v-model="form.name" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="项目说明"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="开始时间"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
|
||||
<el-form-item label="结束时间"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
|
||||
<el-form-item v-if="editingId" label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="进行中" value="active" />
|
||||
<el-option label="已结束" value="ended" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="新建项目为草稿状态,保存后需在列表中「激活」才会对用户展示。"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProgram">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 成员审批 Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" :title="`成员审批 · ${currentProgramName}`" width="820px">
|
||||
<el-descriptions v-if="stats" :column="5" border size="small" style="margin-bottom: 14px">
|
||||
<el-descriptions-item label="待审批">{{ stats.pending }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已通过">{{ stats.approved }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已拒绝">{{ stats.rejected }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已退出">{{ stats.withdrawn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="members" stripe v-loading="memberLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='approved'?'success':row.status==='pending'?'warning':'info'">{{ row.status }}</el-tag></template>
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appliedAt" label="申请时间" width="160">
|
||||
<el-table-column prop="appliedAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.appliedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column prop="rejectedReason" label="原因" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.rejectedReason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
|
||||
<el-button v-if="row.status==='pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
|
||||
<el-button v-if="row.status === 'pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
|
||||
<el-button v-if="row.status === 'pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
|
||||
<el-button v-if="row.status === 'approved'" size="small" type="warning" @click="removeMember(row.id)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -45,48 +100,161 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const programs = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const form = ref({
|
||||
name: '', description: '', startAt: '', endAt: '', status: 'draft',
|
||||
})
|
||||
|
||||
const memberDialogVisible = ref(false)
|
||||
const currentProgramId = ref('')
|
||||
const currentProgramName = ref('')
|
||||
const members = ref<any[]>([])
|
||||
const memberLoading = ref(false)
|
||||
const stats = ref<any>(null)
|
||||
|
||||
const PROGRAM_STATUS_LABEL: Record<string, string> = { draft: '草稿', active: '进行中', ended: '已结束', cancelled: '已取消' }
|
||||
const PROGRAM_STATUS_TAG: Record<string, string> = { draft: 'info', active: 'success', ended: 'warning', cancelled: 'danger' }
|
||||
const MEMBER_STATUS_LABEL: Record<string, string> = { pending: '待审批', approved: '已通过', rejected: '已拒绝', withdrawn: '已退出' }
|
||||
const MEMBER_STATUS_TAG: Record<string, string> = { pending: 'warning', approved: 'success', rejected: 'danger', withdrawn: 'info' }
|
||||
|
||||
function programStatusLabel(s: string) { return PROGRAM_STATUS_LABEL[s] || s }
|
||||
function programStatusTag(s: string) { return PROGRAM_STATUS_TAG[s] || 'info' }
|
||||
function memberStatusLabel(s: string) { return MEMBER_STATUS_LABEL[s] || s }
|
||||
function memberStatusTag(s: string) { return MEMBER_STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
|
||||
async function loadPrograms() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/service-programs')
|
||||
// operator 接口:含草稿/已结束项目。用户端接口只返回 active,草稿在后台会看不见。
|
||||
const res: any = await get('/service-programs/operator/list')
|
||||
programs.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', description: '', startAt: '', endAt: '', status: 'draft' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
name: row.name || '', description: row.description || '',
|
||||
startAt: row.startAt || '', endAt: row.endAt || '',
|
||||
status: row.status || 'draft',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!form.value.name.trim()) { ElMessage.error('请填写项目名称'); return false }
|
||||
if (form.value.startAt && form.value.endAt && new Date(form.value.startAt) > new Date(form.value.endAt)) {
|
||||
ElMessage.error('开始时间不能晚于结束时间')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveProgram() {
|
||||
if (!validate()) return
|
||||
const payload: any = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
// 后端按 ISO-8601 Instant 解析;空字符串需转 null,否则 400
|
||||
startAt: form.value.startAt ? new Date(form.value.startAt).toISOString() : null,
|
||||
endAt: form.value.endAt ? new Date(form.value.endAt).toISOString() : null,
|
||||
}
|
||||
if (editingId.value) payload.status = form.value.status
|
||||
try {
|
||||
if (editingId.value) await put(`/service-programs/operator/${editingId.value}`, payload)
|
||||
else await post('/service-programs/operator', payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadPrograms()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function viewMembers(row: any) {
|
||||
currentProgramId.value = row.id
|
||||
currentProgramName.value = row.name
|
||||
memberDialogVisible.value = true
|
||||
await Promise.all([loadMembers(), loadStats()])
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
memberLoading.value = true
|
||||
try {
|
||||
const res: any = await get(`/service-programs/operator/${row.id}/members`)
|
||||
// operator 接口返回全部状态(含 pending);此前复用只返回 approved 的接口,
|
||||
// 导致待审批申请不显示、审批按钮永远不出现。
|
||||
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/members`)
|
||||
members.value = res.data || []
|
||||
} finally { memberLoading.value = false }
|
||||
} catch (e) { console.error(e) } finally { memberLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/stats`)
|
||||
stats.value = res.data || null
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function refreshAfterAction() {
|
||||
await Promise.all([loadMembers(), loadStats()])
|
||||
loadPrograms()
|
||||
}
|
||||
|
||||
async function activateProgram(row: any) {
|
||||
try { await post(`/service-programs/operator/${row.id}/activate`, {}); ElMessage.success('激活成功'); loadPrograms() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function approveMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/approve`, {}); ElMessage.success('已通过'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function rejectMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/reject`, { reason: '不符合条件' }); ElMessage.success('已拒绝'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
try {
|
||||
await post(`/service-programs/operator/${row.id}/activate`, {})
|
||||
ElMessage.success('激活成功')
|
||||
loadPrograms()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
async function approveMember(memberId: string) {
|
||||
try {
|
||||
await post(`/service-programs/operator/members/${memberId}/approve`, {})
|
||||
ElMessage.success('已通过')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function rejectMember(memberId: string) {
|
||||
try {
|
||||
// 后端要求拒绝必须填写原因,这里不再硬编码"不符合条件"
|
||||
const { value } = await ElMessageBox.prompt('请填写拒绝原因(用户可见)', '拒绝申请', {
|
||||
confirmButtonText: '确认拒绝',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '例如:名额已满 / 不符合本期招募条件',
|
||||
inputValidator: (text: string) => (text && text.trim() ? true : '必须填写拒绝原因'),
|
||||
})
|
||||
await post(`/service-programs/operator/members/${memberId}/reject`, { reason: value })
|
||||
ElMessage.success('已拒绝')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('移除后该成员状态变为「已退出」,记录保留可追溯。确认移除?', '移除成员', {
|
||||
confirmButtonText: '确认移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await post(`/service-programs/operator/members/${memberId}/remove`, { reason: '运营移除' })
|
||||
ElMessage.success('已移除')
|
||||
refreshAfterAction()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(loadPrograms)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="service-station">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>善缘服务站管理</span><el-button type="primary" size="small" @click="openCreate">新建服务站</el-button></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>善缘服务站管理</span>
|
||||
<el-button v-if="canManage" type="primary" size="small" @click="openCreate">新建服务站</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="stations" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="shortName" label="简称" width="120" />
|
||||
@@ -11,23 +16,35 @@
|
||||
<template #default="{ row }">{{ row.openingHoursText || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='closed'?'danger':'info'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'closed' ? 'danger' : 'info'">
|
||||
{{ STATUS_LABELS[row.status] || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="210">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" v-if="canManage" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="primary" plain @click="openAnnouncements(row)">公告管理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !stations.length" description="暂无服务站" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="编辑服务站" width="600px">
|
||||
<!-- 服务站信息 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务站' : '新建服务站'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="简称"><el-input v-model="form.shortName" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="电话显示"><el-input v-model="form.contactPhoneDisplay" /></el-form-item>
|
||||
<el-form-item label="地址" required><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="电话显示">
|
||||
<el-input v-model="form.contactPhoneDisplay" placeholder="展示给用户的脱敏号码,如 0511-****8888" />
|
||||
</el-form-item>
|
||||
<el-form-item label="营业时间"><el-input v-model="form.openingHoursText" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="服务说明">
|
||||
<el-input v-model="form.serviceDescription" type="textarea" :rows="2" placeholder="服务站提供的公益服务内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="营业中" value="active" />
|
||||
@@ -37,44 +54,300 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveStation">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 临时公告管理 -->
|
||||
<el-drawer v-model="drawerVisible" :title="`临时公告 · ${currentStation?.name || ''}`" size="70%">
|
||||
<div class="drawer-toolbar">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="公告仅「已发布」且未过期时对小程序用户可见;过期后自动隐藏,无需手工下架。"
|
||||
/>
|
||||
<el-button v-if="canManage" type="primary" size="small" @click="openAnnouncementCreate">新建公告</el-button>
|
||||
</div>
|
||||
<el-table :data="announcements" stripe v-loading="announcementLoading">
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="ANNOUNCEMENT_STATUS_TYPE[row.status] || 'info'">
|
||||
{{ ANNOUNCEMENT_STATUS_LABELS[row.status] || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||
<el-table-column label="发布时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.publishedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="170">
|
||||
<template #default="{ row }">{{ row.expiresAt ? formatTime(row.expiresAt) : '长期有效' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openAnnouncementEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status !== 'published'"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="publishAnnouncement(row)"
|
||||
>发布</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'published'"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="archiveAnnouncement(row)"
|
||||
>下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!announcementLoading && !announcements.length" description="暂无公告" />
|
||||
</el-drawer>
|
||||
|
||||
<!-- 公告编辑 -->
|
||||
<el-dialog
|
||||
v-model="announcementDialogVisible"
|
||||
:title="announcementForm.id ? '编辑公告' : '新建公告'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form :model="announcementForm" label-width="100px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="announcementForm.title" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="正文">
|
||||
<el-input v-model="announcementForm.content" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间">
|
||||
<el-date-picker
|
||||
v-model="announcementForm.expiresAt"
|
||||
type="datetime"
|
||||
placeholder="留空表示长期有效"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="announcementForm.sortOrder" :min="0" />
|
||||
<span class="form-hint">数值越小越靠前</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="announcementDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAnnouncement">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { active: '营业中', inactive: '休息中', closed: '关闭' }
|
||||
const ANNOUNCEMENT_STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已下架',
|
||||
}
|
||||
const ANNOUNCEMENT_STATUS_TYPE: Record<string, 'info' | 'success' | 'warning'> = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning',
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
// 后端写端点由 service_location.manage 保护;此处按同一权限显隐,避免点了才 403。
|
||||
const canManage = computed(() => auth.permissions.includes('service_location.manage'))
|
||||
|
||||
const loading = ref(false)
|
||||
const stations = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref<any>({})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const currentStation = ref<any>(null)
|
||||
const announcements = ref<any[]>([])
|
||||
const announcementLoading = ref(false)
|
||||
const announcementDialogVisible = ref(false)
|
||||
const announcementForm = ref<any>({})
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function loadStations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/operator/service-locations')
|
||||
stations.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: any) { Object.assign(form.value, row); dialogVisible.value = true }
|
||||
function openCreate() { Object.assign(form.value, { id: undefined, name: '', shortName: '', address: '', contactPhoneDisplay: '', openingHoursText: '', status: 'active' }); dialogVisible.value = true }
|
||||
function openEdit(row: any) {
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
shortName: '',
|
||||
address: '',
|
||||
contactPhoneDisplay: '',
|
||||
openingHoursText: '',
|
||||
serviceDescription: '',
|
||||
status: 'active',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveStation() {
|
||||
const payload = { ...form.value }
|
||||
try {
|
||||
if (form.value.id) await put(`/operator/service-locations/${form.value.id}`, form.value)
|
||||
else await post('/operator/service-locations', form.value)
|
||||
// 失败提示由响应拦截器统一处理(避免重复 toast)
|
||||
if (payload.id) await put(`/operator/service-locations/${payload.id}`, payload)
|
||||
else await post('/operator/service-locations', payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadStations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
} catch {
|
||||
/* 已由拦截器提示 */
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公告 ====================
|
||||
|
||||
async function openAnnouncements(row: any) {
|
||||
currentStation.value = row
|
||||
drawerVisible.value = true
|
||||
await loadAnnouncements()
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
if (!currentStation.value) return
|
||||
announcementLoading.value = true
|
||||
try {
|
||||
const res: any = await get(
|
||||
`/operator/service-locations/${currentStation.value.id}/announcements`
|
||||
)
|
||||
announcements.value = res.data || []
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAnnouncementCreate() {
|
||||
announcementForm.value = { id: undefined, title: '', content: '', expiresAt: null, sortOrder: 0 }
|
||||
announcementDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openAnnouncementEdit(row: any) {
|
||||
announcementForm.value = {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content || '',
|
||||
// el-date-picker 需要 Date 对象;后端为 ISO-8601 瞬时
|
||||
expiresAt: row.expiresAt ? new Date(row.expiresAt) : null,
|
||||
sortOrder: row.sortOrder ?? 0,
|
||||
}
|
||||
announcementDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveAnnouncement() {
|
||||
const payload: any = {
|
||||
title: announcementForm.value.title,
|
||||
content: announcementForm.value.content,
|
||||
sortOrder: announcementForm.value.sortOrder,
|
||||
expiresAt: announcementForm.value.expiresAt
|
||||
? new Date(announcementForm.value.expiresAt).toISOString()
|
||||
: null,
|
||||
}
|
||||
try {
|
||||
if (announcementForm.value.id) {
|
||||
await put(`/operator/service-locations/announcements/${announcementForm.value.id}`, payload)
|
||||
} else {
|
||||
await post(
|
||||
`/operator/service-locations/${currentStation.value.id}/announcements`,
|
||||
payload
|
||||
)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
announcementDialogVisible.value = false
|
||||
loadAnnouncements()
|
||||
} catch {
|
||||
/* 已由拦截器提示 */
|
||||
}
|
||||
}
|
||||
|
||||
async function publishAnnouncement(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`发布后小程序用户将立即可见「${row.title}」。确认发布?`,
|
||||
'发布公告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/service-locations/announcements/${row.id}/publish`)
|
||||
ElMessage.success('已发布')
|
||||
loadAnnouncements()
|
||||
} catch {
|
||||
/* 已由拦截器提示 */
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveAnnouncement(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`下架后用户端立即不可见,记录保留可追溯。确认下架「${row.title}」?`,
|
||||
'下架公告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/service-locations/announcements/${row.id}/archive`)
|
||||
ElMessage.success('已下架')
|
||||
loadAnnouncements()
|
||||
} catch {
|
||||
/* 已由拦截器提示 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStations)
|
||||
</script>
|
||||
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.drawer-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.drawer-toolbar :deep(.el-alert) {
|
||||
flex: 1;
|
||||
}
|
||||
.form-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,176 +1,133 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<el-card shadow="never" v-if="user">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户详情</span>
|
||||
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
|
||||
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 基础信息 -->
|
||||
<div class="page-container page-shell" v-loading="loading">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<el-button class="back-button" link @click="router.push('/users')">
|
||||
<el-icon><ArrowLeft /></el-icon>返回用户列表
|
||||
</el-button>
|
||||
<h1>用户详情</h1>
|
||||
<p>敏感信息遵循最小权限原则,查看与处置操作均需审计。</p>
|
||||
</div>
|
||||
<div v-if="user" class="heading-tags">
|
||||
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
|
||||
<el-tag :type="riskLevelType(user.riskLevel)" effect="plain">{{ riskLevelText(user.riskLevel) }}</el-tag>
|
||||
</div>
|
||||
</header>
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
|
||||
<template #default><el-button v-if="accountId" link type="primary" @click="loadDetail">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<section v-else-if="user" class="surface-card detail-card">
|
||||
<div class="identity-summary">
|
||||
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
|
||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
<el-tab-pane label="基础信息" name="basic">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户ID">{{ user.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="显示名称">{{ user.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="显示名称">{{ user.displayName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实名认证">{{ user.idVerified ? '已认证' : '未认证' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderText(user.gender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
<template v-if="user.sensitiveMasked">
|
||||
<span class="masked">{{ user.phoneMasked }}</span>
|
||||
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
|
||||
</template>
|
||||
<template v-else>{{ user.phone }}</template>
|
||||
<el-descriptions-item label="手机号">
|
||||
<template v-if="user.sensitiveMasked"><span class="masked">{{ user.phoneMasked || '已脱敏' }}</span><el-button link type="primary" @click="requestSensitive">权限说明</el-button></template>
|
||||
<template v-else>{{ user.phone || '-' }}</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatTime(user.registeredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 婚恋资料 -->
|
||||
<el-tab-pane label="婚恋资料" name="profile">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ eduText(user.educationCode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资料完整度">{{ user.profileCompletion }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ user.educationCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职业">{{ user.occupationCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资料状态">{{ profileStatusText(user.profileStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关系状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 风险记录 -->
|
||||
<el-tab-pane label="风险记录" name="risk">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top:16px">
|
||||
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
|
||||
<el-tab-pane label="安全与账号" name="risk">
|
||||
<div class="risk-summary">
|
||||
<article><span>风险分数</span><strong>{{ user.totalRiskScore ?? 0 }}</strong></article>
|
||||
<article><span>历史举报</span><strong>{{ user.totalReportsCount ?? 0 }}</strong></article>
|
||||
<article><span>当前处置</span><strong>{{ user.activeActionsCount ?? 0 }}</strong></article>
|
||||
</div>
|
||||
<div class="danger-zone">
|
||||
<div><h3><el-icon><WarningFilled /></el-icon>账号状态处置</h3><p>状态变更会影响用户登录和平台功能,提交前请确认处置依据。</p></div>
|
||||
<el-button v-if="canChangeStatus" type="danger" plain @click="openStatusDialog">修改账号状态</el-button>
|
||||
<el-tag v-else type="info">无状态修改权限</el-tag>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 状态记录 -->
|
||||
<el-tab-pane label="状态记录" name="status">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="账号状态">
|
||||
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="婚恋状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 修改状态对话框 -->
|
||||
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px">
|
||||
<el-form label-width="100px">
|
||||
</section>
|
||||
<el-empty v-else-if="!loading" description="未找到用户信息" />
|
||||
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="420px">
|
||||
<el-alert title="该操作将被写入审计日志,请谨慎处理。" type="warning" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="status-form">
|
||||
<el-form-item label="新状态">
|
||||
<el-select v-model="newStatus" style="width:100%">
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="风险审核中" value="reviewing_risk" />
|
||||
<el-option label="功能受限" value="limited" />
|
||||
<el-option label="临时暂停" value="suspended" />
|
||||
<el-option label="封禁" value="banned" />
|
||||
<el-option label="已注销" value="closed" />
|
||||
<el-select v-model="newStatus" class="full-width">
|
||||
<el-option label="正常" value="normal" /><el-option label="风险审核中" value="reviewing_risk" />
|
||||
<el-option label="功能受限" value="limited" /><el-option label="临时暂停" value="suspended" />
|
||||
<el-option label="封禁" value="banned" /><el-option label="已注销" value="closed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showStatusDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
|
||||
</template>
|
||||
<template #footer><el-button @click="showStatusDialog = false">取消</el-button><el-button type="danger" :loading="updating" @click="updateStatus">确认变更</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router' // eslint-disable-next-line
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const user = ref<any>(null)
|
||||
const activeTab = ref('basic')
|
||||
const showStatusDialog = ref(false)
|
||||
const newStatus = ref('normal')
|
||||
const statusNote = ref('')
|
||||
const updating = ref(false)
|
||||
|
||||
const accountId = route.query.id as string
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
const route = useRoute(); const router = useRouter(); const authStore = useAuthStore()
|
||||
const loading = ref(true); const errorMessage = ref(''); const user = ref<any>(null); const activeTab = ref('basic')
|
||||
const showStatusDialog = ref(false); const newStatus = ref('normal'); const updating = ref(false)
|
||||
const accountId = String(route.query.id || '')
|
||||
const canChangeStatus = computed(() => authStore.permissions.includes('user.status.change'))
|
||||
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'success' },
|
||||
banned: { text: '封禁', type: 'danger' },
|
||||
suspended: { text: '暂停', type: 'warning' },
|
||||
reviewing_risk: { text: '风险审核', type: 'danger' },
|
||||
limited: { text: '受限', type: 'info' }
|
||||
normal: { text: '正常', type: 'success' }, banned: { text: '封禁', type: 'danger' },
|
||||
suspended: { text: '暂停', type: 'warning' }, reviewing_risk: { text: '风险审核', type: 'danger' },
|
||||
limited: { text: '受限', type: 'info' }, closed: { text: '已注销', type: 'info' },
|
||||
}
|
||||
|
||||
const RISK_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'info' },
|
||||
watch: { text: '关注', type: '' },
|
||||
high: { text: '高风险', type: 'warning' },
|
||||
critical: { text: '极高风险', type: 'danger' }
|
||||
normal: { text: '正常', type: 'info' }, watch: { text: '关注', type: 'warning' },
|
||||
high: { text: '高风险', type: 'warning' }, critical: { text: '极高风险', type: 'danger' },
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!accountId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
user.value = res
|
||||
} catch (e) { console.error(e) }
|
||||
if (!accountId) { loading.value = false; errorMessage.value = '缺少用户编号,请从用户列表重新进入'; return }
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { const response: any = await get(`/operator/users/${accountId}`); user.value = response.data }
|
||||
catch (error: any) { user.value = null; errorMessage.value = error.response?.status === 403 ? '您没有查看该用户详情的权限' : '用户详情加载失败,请检查服务状态后重试' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openStatusDialog() { newStatus.value = user.value?.accountStatus || 'normal'; showStatusDialog.value = true }
|
||||
async function updateStatus() {
|
||||
if (!newStatus.value) return
|
||||
if (!newStatus.value || updating.value) return
|
||||
await ElMessageBox.confirm(`确认将该用户账号状态修改为“${accountStatusText(newStatus.value)}”吗?`, '确认账号状态变更', { type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '取消' })
|
||||
updating.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
showStatusDialog.value = false
|
||||
loadDetail()
|
||||
} catch (e) { console.error(e) }
|
||||
try { await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }); ElMessage.success('账号状态已更新'); showStatusDialog.value = false; await loadDetail() }
|
||||
finally { updating.value = false }
|
||||
}
|
||||
|
||||
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ }
|
||||
|
||||
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' }
|
||||
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' }
|
||||
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' }
|
||||
function eduText(e?: string) { return e || '-' }
|
||||
function occText(o?: string) { return o || '-' }
|
||||
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s }
|
||||
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' }
|
||||
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
|
||||
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
|
||||
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
|
||||
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
function requestSensitive() { ElMessage.info('当前内容已脱敏;只有具备敏感数据权限的角色可以查看明文。') }
|
||||
function genderText(value?: string) { return value === 'male' ? '男' : value === 'female' ? '女' : '-' }
|
||||
function maritalText(value?: string) { return value ? ({ single: '未婚', divorced: '离异', widowed: '丧偶' }[value] || value) : '-' }
|
||||
function childrenText(value?: string) { return value ? ({ none: '无子女', with_children: '有子女' }[value] || value) : '-' }
|
||||
function profileStatusText(value?: string) { return value ? ({ approved: '已审核', pending: '待审核', need_edit: '需修改' }[value] || value) : '-' }
|
||||
function relationshipText(value?: string) { return value ? ({ searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[value] || value) : '-' }
|
||||
function accountStatusType(status: string) { return ACCOUNT_MAP[status]?.type || 'info' }
|
||||
function accountStatusText(status: string) { return ACCOUNT_MAP[status]?.text || status || '-' }
|
||||
function riskLevelType(level: string) { return RISK_MAP[level]?.type || 'info' }
|
||||
function riskLevelText(level: string) { return RISK_MAP[level]?.text || level || '-' }
|
||||
function formatTime(time?: string) { return time ? new Date(time).toLocaleString('zh-CN') : '-' }
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; align-items: center; gap: 12px; }
|
||||
.masked { color: #999; font-style: italic; }
|
||||
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:var(--color-text-inverse);background:var(--admin-coral-solid)}.identity-summary h2{font-size:var(--font-size-2xl)}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:var(--font-size-4xl)}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid var(--color-border-danger);border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger-strong)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +1,172 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户管理</span>
|
||||
<el-input v-model="keyword" placeholder="搜索用户ID" clearable style="width:200px" />
|
||||
<el-select v-model="accountStatus" placeholder="账号状态" clearable style="width:140px">
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="封禁" value="banned" />
|
||||
<el-option label="暂停" value="suspended" />
|
||||
<el-option label="风险审核中" value="reviewing_risk" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="page-container page-shell">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="page-kicker">用户与资料审核</span>
|
||||
<h1>用户管理</h1>
|
||||
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
|
||||
</header>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="用户ID" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="displayName" label="姓名" width="120" />
|
||||
<el-table-column prop="gender" label="性别" width="60">
|
||||
<template #default="{row}">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template>
|
||||
<section class="surface-card filter-panel" aria-label="用户筛选">
|
||||
<el-input
|
||||
v-model.trim="keyword"
|
||||
class="keyword-input"
|
||||
clearable
|
||||
aria-label="用户编号"
|
||||
placeholder="输入用户编号"
|
||||
@keyup.enter="search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="accountStatus" clearable placeholder="全部账号状态" aria-label="账号状态">
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="封禁" value="banned" />
|
||||
<el-option label="暂停" value="suspended" />
|
||||
<el-option label="风险审核中" value="reviewing_risk" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</section>
|
||||
|
||||
<section class="surface-card table-panel">
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default><el-button link type="primary" @click="loadData">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
|
||||
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
|
||||
<el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="age" label="年龄" width="60" />
|
||||
<el-table-column prop="phoneMasked" label="手机尾号" width="120" />
|
||||
<el-table-column prop="regionName" label="区域" width="100" />
|
||||
<el-table-column prop="accountStatus" label="账号状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="accountStatusType(row.accountStatus)">{{ accountStatusText(row.accountStatus) }}</el-tag>
|
||||
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="gender" label="性别" width="70">
|
||||
<template #default="{ row }">{{ genderText(row.gender) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="age" label="年龄" width="70">
|
||||
<template #default="{ row }">{{ row.age || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phoneMasked" label="手机号" min-width="130">
|
||||
<template #default="{ row }">{{ row.phoneMasked || '未绑定' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="regionName" label="区域" min-width="100">
|
||||
<template #default="{ row }">{{ row.regionName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="accountStatus" label="账号状态" min-width="105">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="accountStatusType(row.accountStatus)" effect="light">
|
||||
{{ accountStatusText(row.accountStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="riskLevel" label="风险等级" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="riskLevelType(row.riskLevel)">{{ riskLevelText(row.riskLevel) }}</el-tag>
|
||||
<el-table-column prop="riskLevel" label="风险等级" min-width="105">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="riskLevelType(row.riskLevel)" effect="plain">
|
||||
{{ riskLevelText(row.riskLevel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="registeredAt" label="注册时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.registeredAt) }}</template>
|
||||
<el-table-column prop="registeredAt" label="注册时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.registeredAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="goDetail(row.id)">查看</el-button>
|
||||
<el-table-column label="操作" width="86" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goDetail(row.id)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="没有找到符合条件的用户" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
|
||||
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
|
||||
:current-page="page" @current-change="handlePageChange" />
|
||||
</el-card>
|
||||
<el-pagination
|
||||
v-if="!errorMessage && total > 0"
|
||||
class="pagination"
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page + 1"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const keyword = ref('')
|
||||
const accountStatus = ref('')
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const pageSize = 20
|
||||
|
||||
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'success' },
|
||||
banned: { text: '封禁', type: 'danger' },
|
||||
suspended: { text: '暂停', type: 'warning' },
|
||||
reviewing_risk: { text: '风险审核', type: 'danger' },
|
||||
limited: { text: '受限', type: 'info' }
|
||||
limited: { text: '受限', type: 'info' },
|
||||
}
|
||||
|
||||
const RISK_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'info' },
|
||||
watch: { text: '关注', type: '' },
|
||||
watch: { text: '关注', type: 'warning' },
|
||||
high: { text: '高风险', type: 'warning' },
|
||||
critical: { text: '极高风险', type: 'danger' }
|
||||
critical: { text: '极高风险', type: 'danger' },
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/users', {
|
||||
const response: any = await get('/operator/users', {
|
||||
keyword: keyword.value || undefined,
|
||||
accountStatus: accountStatus.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
pageSize,
|
||||
})
|
||||
tableData.value = response.data?.list || []
|
||||
total.value = response.data?.total || 0
|
||||
} catch (error: any) {
|
||||
tableData.value = []
|
||||
total.value = 0
|
||||
errorMessage.value = error.response?.status === 403
|
||||
? '您没有查看用户列表的权限'
|
||||
: '用户数据加载失败,请检查服务状态后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(p: number) {
|
||||
page.value = p - 1
|
||||
function search() {
|
||||
page.value = 0
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
accountStatus.value = ''
|
||||
search()
|
||||
}
|
||||
|
||||
function handlePageChange(current: number) {
|
||||
page.value = current - 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
@@ -105,16 +174,82 @@ function goDetail(id: string) {
|
||||
router.push({ path: '/users/detail', query: { id } })
|
||||
}
|
||||
|
||||
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
|
||||
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
|
||||
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
|
||||
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
function genderText(gender?: string) {
|
||||
return gender === 'male' ? '男' : gender === 'female' ? '女' : '-'
|
||||
}
|
||||
|
||||
function accountStatusType(status: string) {
|
||||
return ACCOUNT_MAP[status]?.type || 'info'
|
||||
}
|
||||
|
||||
function accountStatusText(status: string) {
|
||||
return ACCOUNT_MAP[status]?.text || status || '-'
|
||||
}
|
||||
|
||||
function riskLevelType(level: string) {
|
||||
return RISK_MAP[level]?.type || 'info'
|
||||
}
|
||||
|
||||
function riskLevelText(level: string) {
|
||||
return RISK_MAP[level]?.text || level || '-'
|
||||
}
|
||||
|
||||
function formatTime(time?: string) {
|
||||
return time ? new Date(time).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; align-items: center; gap: 12px; }
|
||||
.page-container {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
color: var(--admin-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.filter-panel .el-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.table-panel .el-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-container {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.keyword-input,
|
||||
.filter-panel .el-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -18,6 +18,9 @@ const routes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
|
||||
// V1.1 Phase 14:活动管理(类型化活动 + 动态表单 + 多组织 + 容量分组)
|
||||
{ path: 'activities', name: 'ActivityManagement', component: () => import('@/modules/activity/ActivityManagementView.vue'), meta: { title: '活动管理', permissions: ['activity.view'] } },
|
||||
{ path: 'activities/checkin', name: 'ActivityCheckin', component: () => import('@/modules/activity/ActivityCheckinView.vue'), meta: { title: '活动签到', permissions: ['activity.checkin'] } },
|
||||
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
|
||||
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
||||
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
||||
@@ -34,17 +37,24 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
||||
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
||||
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
|
||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
|
||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
|
||||
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
|
||||
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
// history 的 base 必须与 vite 的 base 保持一致(生产构建为 /admin/)。
|
||||
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
|
||||
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
const requiredPerms = (to.meta.permissions as string[]) || []
|
||||
|
||||
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
|
||||
await authStore.initializeSession()
|
||||
|
||||
// 1. 登录检查
|
||||
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import client from '@/api/client'
|
||||
import type {
|
||||
DashboardActivity,
|
||||
DashboardEffectivenessItem,
|
||||
DashboardMetric,
|
||||
DashboardModel,
|
||||
DashboardOverviewItem,
|
||||
DashboardTodo,
|
||||
} from '@/types/dashboard'
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT = 8000
|
||||
|
||||
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
|
||||
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
const today = day.format(new Date())
|
||||
return {
|
||||
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
|
||||
completed: rows.filter(row => row.status === 'completed').length,
|
||||
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
|
||||
if (!row[key]) return false
|
||||
const date = new Date(String(row[key]))
|
||||
return !Number.isNaN(date.getTime()) && day.format(date) === today
|
||||
})).length,
|
||||
}
|
||||
}
|
||||
|
||||
const numberOf = (source: JsonRecord | undefined, key: string) => {
|
||||
const value = source?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
const recordOf = (source: JsonRecord | undefined, key: string): JsonRecord => {
|
||||
const value = source?.[key]
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
|
||||
}
|
||||
|
||||
const dataOf = <T>(response: unknown): T | null => {
|
||||
const envelope = response as ApiEnvelope<T> | null
|
||||
return envelope?.success && envelope.data != null ? envelope.data : null
|
||||
}
|
||||
|
||||
const fulfilledData = <T>(result: PromiseSettledResult<unknown>): T | null =>
|
||||
result.status === 'fulfilled' && result.value ? dataOf<T>(result.value) : null
|
||||
|
||||
const emptyMetric = (
|
||||
key: string,
|
||||
label: string,
|
||||
tone: DashboardMetric['tone'],
|
||||
route: string,
|
||||
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
|
||||
|
||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||
return {
|
||||
followups: [],
|
||||
metrics: [
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
|
||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
||||
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
|
||||
],
|
||||
todos: [],
|
||||
activities: [],
|
||||
serviceOverview: [],
|
||||
effectiveness: [],
|
||||
activitiesAvailable: false,
|
||||
notice,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
||||
const canViewAnalytics = permissions.includes('analytics.view')
|
||||
const canViewActivities = permissions.includes('activity.view')
|
||||
const canViewMatchmaker = permissions.includes('matchmaker.view')
|
||||
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
||||
const canViewReviews = permissions.includes('review.view')
|
||||
|
||||
const [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||
canViewAnalytics
|
||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewAnalytics
|
||||
? client.get('/operator/analytics/v11-dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewActivities
|
||||
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewMatchmaker
|
||||
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
|
||||
const reviews = fulfilledData<JsonRecord>(reviewResult)
|
||||
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||
const matchmakerPending = canManageMatchmaker
|
||||
? managedStats.pending
|
||||
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
|
||||
const matchmakerCompleted = canManageMatchmaker
|
||||
? managedStats.completed
|
||||
: numberOf(matchmaker || undefined, 'completedCount')
|
||||
const followups = (managedServices || []).filter(row => row.latestFollowupContent)
|
||||
.sort((a, b) => String(b.latestFollowupAt).localeCompare(String(a.latestFollowupAt)))
|
||||
.slice(0, 5).map(row => ({ serviceId: String(row.serviceId), userDisplayNumber: String(row.userDisplayNumber || '未生成编号'),
|
||||
content: String(row.latestFollowupContent), createdAt: String(row.latestFollowupAt || '') }))
|
||||
const analyticsAvailable = Boolean(core || service)
|
||||
|
||||
const counseling = recordOf(service || undefined, 'counseling')
|
||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
||||
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
||||
|
||||
const metrics: DashboardMetric[] = [
|
||||
{
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
|
||||
trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||
value: matchmakerPending,
|
||||
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
|
||||
},
|
||||
{
|
||||
key: 'counseling_todos', label: '咨询服务待办',
|
||||
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
|
||||
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
|
||||
},
|
||||
{
|
||||
key: 'station_appointments', label: '服务站预约',
|
||||
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
|
||||
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
|
||||
},
|
||||
{
|
||||
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
|
||||
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
|
||||
},
|
||||
{
|
||||
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
|
||||
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
|
||||
},
|
||||
]
|
||||
|
||||
const todos = ([
|
||||
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
||||
{ key: 'matchmaker', title: '红娘服务', description: metrics[1].trendText, count: metrics[1].value, actionText: '查看服务', route: '/matchmaker', tone: 'amber', available: metrics[1].available },
|
||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||
] as DashboardTodo[]).filter((item) => item.available)
|
||||
|
||||
const activities: DashboardActivity[] = (activityRows || []).slice(0, 4).map((item) => ({
|
||||
id: String(item.id || ''),
|
||||
title: String(item.title || '未命名活动'),
|
||||
coverUrl: String(item.coverUrl || ''),
|
||||
startAt: String(item.startAt || ''),
|
||||
locationName: String(item.locationName || '地点待确认'),
|
||||
registered: typeof item.registered === 'number' ? item.registered : null,
|
||||
capacity: typeof item.totalLimit === 'number' ? item.totalLimit : null,
|
||||
statusText: String(item.status || '状态待确认'),
|
||||
}))
|
||||
|
||||
const serviceOverview = ([
|
||||
{ key: 'registered', label: '平台注册用户', value: numberOf(core || undefined, 'totalUsers'), compareText: '累计注册', tone: 'rose', available: Boolean(core) },
|
||||
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
|
||||
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
|
||||
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
|
||||
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
|
||||
] as DashboardOverviewItem[]).filter((item) => item.available)
|
||||
|
||||
const effectiveness = ([
|
||||
{ key: 'profile_review', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), max: numberOf(core || undefined, 'totalUsers'), unit: '人', available: Boolean(core) },
|
||||
{ key: 'successful_match', label: '成功匹配用户', value: numberOf(core || undefined, 'totalMatches'), max: numberOf(core || undefined, 'totalLikes'), unit: '对', available: Boolean(core) },
|
||||
{ key: 'counseling_completed', label: '咨询服务完成', value: numberOf(counseling, 'completed'), max: numberOf(counseling, 'total'), unit: '人次', available: Boolean(service) },
|
||||
{ key: 'activity_participation', label: '活动参与人数', value: numberOf(registrationSources, 'totalRegistrations'), max: 0, unit: '人次', available: Boolean(service) },
|
||||
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||
const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||
const notice = !anyData
|
||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||
|
||||
return {
|
||||
followups,
|
||||
metrics,
|
||||
todos,
|
||||
activities,
|
||||
serviceOverview,
|
||||
effectiveness,
|
||||
activitiesAvailable: Boolean(activityRows),
|
||||
notice,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { post } from '@/api/client'
|
||||
import { setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
interface OperatorInfo {
|
||||
operatorId: string
|
||||
@@ -12,9 +13,9 @@ interface OperatorInfo {
|
||||
|
||||
const previewRolePermissions: Record<string, string[]> = {
|
||||
reviewer: ['review.view', 'user.view'],
|
||||
counselor: ['counseling.service.view', 'referral.manage'],
|
||||
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||||
station: ['service_location.view', 'service_appointment.view'],
|
||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'],
|
||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -23,38 +24,70 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
||||
: null
|
||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
|
||||
const operator = ref<OperatorInfo | null>(
|
||||
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
|
||||
)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||||
const operator = ref<OperatorInfo | null>(previewOperator)
|
||||
let sessionInitialized = false
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||||
const permissions = computed(() => operator.value?.permissions || [])
|
||||
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||||
const hasPermission = (code: string) => permissions.value.includes(code)
|
||||
|
||||
function applyAuthData(data: any) {
|
||||
token.value = data.accessToken || ''
|
||||
operator.value = {
|
||||
operatorId: data.operatorId,
|
||||
name: data.name,
|
||||
username: data.username,
|
||||
permissions: data.permissions || [],
|
||||
organizationId: data.organizationId ?? null,
|
||||
}
|
||||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||
setAccessToken(token.value)
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res: any = await post('/auth/operator/login', { username, password })
|
||||
if (res.success) {
|
||||
token.value = res.data.accessToken
|
||||
operator.value = {
|
||||
operatorId: res.data.operatorId,
|
||||
name: res.data.name,
|
||||
username: res.data.username,
|
||||
permissions: res.data.permissions || [],
|
||||
organizationId: res.data.organizationId ?? null,
|
||||
}
|
||||
localStorage.setItem('accessToken', res.data.accessToken)
|
||||
localStorage.setItem('operator', JSON.stringify(operator.value))
|
||||
applyAuthData(res.data)
|
||||
return res.data
|
||||
}
|
||||
throw new Error(res.message || '登录失败')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('operator')
|
||||
/**
|
||||
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
|
||||
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
|
||||
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
|
||||
*/
|
||||
async function initializeSession(): Promise<boolean> {
|
||||
if (sessionInitialized) return isLoggedIn.value
|
||||
sessionInitialized = true
|
||||
if (token.value) return true
|
||||
try {
|
||||
const res: any = await post('/auth/operator/refresh', {})
|
||||
if (res.success) {
|
||||
applyAuthData(res.data)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 未登录或 refresh 失效,交由路由守卫跳转登录页
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, login, logout }
|
||||
function logout() {
|
||||
// 尽力通知后端注销并清除 Cookie(best-effort)
|
||||
try {
|
||||
post('/auth/operator/logout', {}).catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
clearAccessToken()
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
|
||||
})
|
||||
|
||||
@@ -1,67 +1,422 @@
|
||||
/* ============================================================================
|
||||
* 管理端设计系统 · v2
|
||||
*
|
||||
* 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 <style> 只允许通过
|
||||
* var(--token) 取色 / 取字号 / 取间距,不应出现字面色值(由门控脚本
|
||||
* scripts/lint-admin-tokens.mjs 拦截)。
|
||||
*
|
||||
* 立此版本的原因(2026-09-22 审计实测,不是主观判断):
|
||||
* · 97 处硬编码颜色 / 67 种色值:6 种近似粉底(#FFF0F1 #FFF3F2 #FFF6F6
|
||||
* #FFF7F7 #FFF8F7 #FFFAFA)、6 种近似浅灰边框、大量灰阶碎片。
|
||||
* · 88 处硬编码字号 / 23 种档位,其中 20 处 ≤11px(9px 见于 .metric-copy em、
|
||||
* .activity-status、.todo-copy small),低于可读下限。
|
||||
* · --color-text-tertiary(#888888) 白底 3.54:1、--color-warning(#E59A32) 2.33:1、
|
||||
* --admin-coral(#F45F68) 3.14:1 —— 在各自用法下均不达 WCAG AA 的 4.5:1。
|
||||
* · Element Plus 的 --el-color-*-light-N / -dark-2 / -rgb 是**编译期静态生成**的,
|
||||
* 项目只覆盖了 --el-color-primary,于是开关、滑块、焦点环、分页、标签等
|
||||
* 组件仍是默认蓝 —— 品牌色与 EP 蓝混用。
|
||||
*
|
||||
* 三条硬规则:
|
||||
* 1) 品牌色三级分化:基础色只做色块/渐变/图形(不承载文字);
|
||||
* -strong 做白底文字;-solid 做实心底压白字;两者均实算 ≥4.5:1。
|
||||
* 2) 字重只用 400 / 500 / 600 / 700 四档(跨平台缺字重会回退,导致渲染不一致)。
|
||||
* 3) 字号有下限:正文最小 12px,角标/计数不得低于 11px。
|
||||
*
|
||||
* 所有对比度数值均由脚本按 WCAG 相对亮度公式实算(非估算),标注在各 token 后。
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
--color-primary: #E96B6B;
|
||||
--color-primary-hover: #D95F5F;
|
||||
--color-primary-light: #FFF3F2;
|
||||
--color-primary-lighter: #FFF8F7;
|
||||
--color-success: #2E9B68;
|
||||
--color-warning: #E59A32;
|
||||
--color-danger: #D94A4A;
|
||||
--color-info: #4A7CD4;
|
||||
--color-text-primary: #222222;
|
||||
--color-text-secondary: #555555;
|
||||
--color-text-tertiary: #888888;
|
||||
/* ---------------- 品牌色 ---------------- */
|
||||
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
|
||||
--admin-coral: #F45F68;
|
||||
--admin-coral-hover: #E5535D;
|
||||
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
|
||||
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
|
||||
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
|
||||
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
|
||||
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
||||
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
||||
--admin-scene-bg-start: #FFEDEE;
|
||||
--admin-scene-bg-mid: #FFF6F4;
|
||||
--admin-scene-bg-end: #FBF5F4;
|
||||
--admin-scene-glow: rgba(244, 95, 104, 0.28);
|
||||
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
|
||||
--admin-scene-surface: rgba(255, 255, 255, 0.65);
|
||||
--admin-scene-border: rgba(244, 95, 104, 0.18);
|
||||
--admin-scene-divider: rgba(244, 95, 104, 0.30);
|
||||
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
||||
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
||||
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
|
||||
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
|
||||
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||
/* 水平进度条填充(对轨道 --color-primary-light):原 #F56B75→#EF5A65 仅 2.53:1,
|
||||
条与轨道难以区分;改达标渐变后 4.01:1。 */
|
||||
--gradient-brand-bar: linear-gradient(90deg, #C84A53 0%, #B8434A 100%);
|
||||
|
||||
/* 兼容既有引用名(全站仍在用) */
|
||||
--admin-primary: var(--admin-coral);
|
||||
--admin-primary-hover: var(--admin-coral-hover);
|
||||
--admin-primary-strong: var(--admin-coral-strong);
|
||||
--admin-primary-solid: var(--admin-coral-solid);
|
||||
--color-primary: var(--admin-coral);
|
||||
--color-primary-hover: var(--admin-coral-hover);
|
||||
--color-primary-strong: var(--admin-coral-strong);
|
||||
--color-primary-solid: var(--admin-coral-solid);
|
||||
--color-primary-light: var(--admin-coral-soft);
|
||||
--color-primary-lighter: var(--admin-coral-lighter);
|
||||
|
||||
/* ---------------- 语义色五组 ----------------
|
||||
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
|
||||
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
|
||||
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
|
||||
--color-success-soft: #EDF8F1;
|
||||
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
|
||||
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
|
||||
--color-warning-soft: #FFF6E6;
|
||||
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
|
||||
--color-danger-strong: #B8353A; /* 白底 5.82:1 */
|
||||
--color-danger-soft: #FFF6F6;
|
||||
--color-info: #4A7CD4; /* 白底 4.10:1 —— 图形 */
|
||||
--color-info-strong: #3A63B0; /* 白底 5.85:1 */
|
||||
--color-info-soft: #EEF5FF;
|
||||
--color-purple: #8B63BC;
|
||||
--color-purple-strong: #6B47A0; /* 白底 6.92:1 */
|
||||
--color-purple-soft: #F4EFFF;
|
||||
--color-neutral: #8A9099;
|
||||
--color-neutral-strong: #656C79; /* 白底 5.28:1 */
|
||||
--color-neutral-soft: #F4F5F7;
|
||||
|
||||
/* ---------------- 文字 ---------------- */
|
||||
--color-text-primary: #1F2328; /* 白底 15.80:1 */
|
||||
--color-text-secondary: #555555; /* 白底 7.46:1 */
|
||||
--color-text-tertiary: #676E7B; /* 白底 5.13:1;浅底 #F5F6F8 上 4.74:1(原 #888 仅 3.54:1) */
|
||||
--color-text-placeholder: #8A9099; /* 3.25:1:占位符按惯例弱于正文一档(EP 默认 2.5:1) */
|
||||
--color-text-disabled: #B9BEC7;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
--color-text-on-dark: #D9E1E6; /* 侧栏深底 10.62:1 */
|
||||
--color-text-on-dark-muted: #8FA1AC; /* 侧栏深底 5.26:1 */
|
||||
--color-icon-muted: #D7DCE0; /* 空态大图标等纯装饰 */
|
||||
|
||||
/* ---------------- 背景 ---------------- */
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #FFF8F7;
|
||||
--color-bg-page: #FAFAF8;
|
||||
--color-border: #E8E5E2;
|
||||
--color-border-light: #F0EDE9;
|
||||
--color-bg-page: #F7F9FB;
|
||||
--color-bg-secondary: #FAFAFB;
|
||||
--color-bg-subtle: #F5F6F8;
|
||||
--color-bg-input: #F6F7F8;
|
||||
--color-bg-overlay: rgba(255, 255, 255, 0.86);
|
||||
--color-bg-overlay-strong: rgba(255, 255, 255, 0.96);
|
||||
|
||||
/* ---------------- 边框 ---------------- */
|
||||
--color-border: #E4E7EB;
|
||||
--color-border-light: #EDF0F2;
|
||||
--color-border-hairline: #F2F3F4;
|
||||
--color-border-strong: #D7DCE0;
|
||||
--color-border-danger: #F3CCCC;
|
||||
|
||||
/* ---------------- 侧栏(深底) ---------------- */
|
||||
--admin-sidebar-bg: #202D38;
|
||||
--admin-sidebar-bg-deep: #192733;
|
||||
--admin-sidebar-gradient: linear-gradient(180deg, #202D38 0%, #192733 100%);
|
||||
--admin-sidebar-surface: #2B3A46;
|
||||
--admin-sidebar-text: #D9E1E6;
|
||||
--admin-sidebar-muted: #8FA1AC;
|
||||
--admin-sidebar-hairline: rgba(255, 255, 255, 0.08);
|
||||
--admin-sidebar-hairline-soft: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* ---------------- 壳与栅格 ---------------- */
|
||||
--admin-shell-bg: rgba(255, 255, 255, 0.96);
|
||||
--admin-header-bg: #FFFFFF;
|
||||
--admin-shell-radius: 22px;
|
||||
--admin-card-radius: 14px;
|
||||
--admin-shell-max-width: 1840px;
|
||||
--admin-page-max-width: 1440px;
|
||||
--admin-content-max-width: 1500px;
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-width-collapsed: 76px;
|
||||
--header-height: 64px;
|
||||
|
||||
/* ---------------- 圆角 ---------------- */
|
||||
--border-radius-xs: 6px;
|
||||
--border-radius-sm: 8px;
|
||||
--border-radius-md: 12px;
|
||||
--border-radius-md: 14px;
|
||||
--border-radius-lg: 16px;
|
||||
--border-radius-xl: 18px;
|
||||
--border-radius-pill: 999px;
|
||||
|
||||
/* ---------------- 阴影 ---------------- */
|
||||
--shadow-panel: 0 8px 20px rgba(48, 58, 67, 0.035);
|
||||
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
|
||||
--el-color-primary: var(--color-primary);
|
||||
--shadow-raise: 0 10px 24px rgba(68, 48, 55, 0.1);
|
||||
--shadow-brand: 0 8px 18px rgba(200, 74, 83, 0.26);
|
||||
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
|
||||
|
||||
/* ---------------- 排版刻度 ----------------
|
||||
正文 14px 起;角标/计数下限 11px;元信息下限 12px。 */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-family-brand: KaiTi, STKaiti, serif; /* 品牌书法体:仅装饰性标语 */
|
||||
--font-size-micro: 11px;
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 15px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 18px;
|
||||
--font-size-2xl: 20px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
--font-size-display: 38px;
|
||||
--font-line-tight: 1.2;
|
||||
--font-line-snug: 1.35;
|
||||
--font-line-base: 1.6;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* ---------------- 间距 ---------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* ---------------- 动效 ---------------- */
|
||||
--motion-fast: 140ms;
|
||||
--motion-normal: 200ms;
|
||||
--motion-slow: 320ms;
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--press-scale: 0.985;
|
||||
--press-opacity: 0.88;
|
||||
|
||||
/* ---------------- 交互热区 ----------------
|
||||
桌面端按下限为 WCAG 2.5.8(AA) 的 24×24px;本项目常规控件取 32px(舒适),
|
||||
主导航行取 44px(兼顾触屏笔记本)。不套用移动端 44px 全量规则,
|
||||
否则会把管理端的单屏信息密度牺牲掉。 */
|
||||
--tap-min: 44px;
|
||||
--tap-control: 32px;
|
||||
--tap-icon: 36px;
|
||||
|
||||
/* ---------------- tone 色调组(工作台指标卡 / 待办图标) ----------------
|
||||
每组三档:--tone-x 用于图标与图形;--tone-x-ink 用于文字(压 --tone-x-soft
|
||||
实算均 ≥4.6:1);--tone-x-soft 为浅底。 */
|
||||
--tone-rose: #F45F68; --tone-rose-ink: var(--admin-coral-strong); --tone-rose-soft: #FFF0F1;
|
||||
--tone-amber: #E59A32; --tone-amber-ink: var(--color-warning-strong); --tone-amber-soft: #FFF6E6;
|
||||
--tone-blue: #4A7CD4; --tone-blue-ink: var(--color-info-strong); --tone-blue-soft: #EEF5FF;
|
||||
--tone-green: #2E9B68; --tone-green-ink: var(--color-success-strong); --tone-green-soft: #EDF8F1;
|
||||
--tone-purple: #8B63BC; --tone-purple-ink: var(--color-purple-strong); --tone-purple-soft: #F4EFFF;
|
||||
--tone-slate: #8A9099; --tone-slate-ink: var(--color-neutral-strong); --tone-slate-soft: #F4F5F7;
|
||||
--tone-red: #D94A4A; --tone-red-ink: var(--color-danger-strong); --tone-red-soft: #FFF6F6;
|
||||
|
||||
/* 兼容既有引用名 */
|
||||
--admin-soft-pink: var(--admin-coral-soft);
|
||||
--admin-soft-blue: var(--color-info-soft);
|
||||
--admin-soft-green: var(--color-success-soft);
|
||||
--admin-soft-purple: var(--color-purple-soft);
|
||||
|
||||
/* ==========================================================================
|
||||
Element Plus 联动
|
||||
EP 的 --el-color-{t}-light-{i} / -dark-2 / -rgb 由 SCSS 在**编译期**按默认色
|
||||
静态生成,运行时改 --el-color-primary 不会带动它们(开关、滑块、焦点环、
|
||||
分页、标签因此仍是默认蓝)。这里按 EP 的派生公式补全:
|
||||
light-{i} = mix(#FFFFFF, base, i*10%) i ∈ 1..9
|
||||
dark-2 = mix(#000000, base, 20%)
|
||||
基色取 -strong / -solid 档 —— 既能当文字色也能做实心底,使 EP 全组件与品牌一致。
|
||||
info 保持中性灰:EP 的 info 语义是「中性/未知」,不应变成品牌红。
|
||||
========================================================================== */
|
||||
--el-color-primary: #C84A53;
|
||||
--el-color-primary-rgb: 200, 74, 83;
|
||||
--el-color-primary-light-3: #d98087;
|
||||
--el-color-primary-light-5: #e4a5a9;
|
||||
--el-color-primary-light-7: #efc9cb;
|
||||
--el-color-primary-light-8: #f4dbdd;
|
||||
--el-color-primary-light-9: #faedee;
|
||||
--el-color-primary-dark-2: #a03b42;
|
||||
|
||||
--el-color-success: #1F7A4F;
|
||||
--el-color-success-rgb: 31, 122, 79;
|
||||
--el-color-success-light-3: #62a284;
|
||||
--el-color-success-light-5: #8fbda7;
|
||||
--el-color-success-light-7: #bcd7ca;
|
||||
--el-color-success-light-8: #d2e4dc;
|
||||
--el-color-success-light-9: #e9f2ed;
|
||||
--el-color-success-dark-2: #19623f;
|
||||
|
||||
--el-color-warning: #8F5D0F;
|
||||
--el-color-warning-rgb: 143, 93, 15;
|
||||
--el-color-warning-light-3: #b18e57;
|
||||
--el-color-warning-light-5: #c7ae87;
|
||||
--el-color-warning-light-7: #ddceb7;
|
||||
--el-color-warning-light-8: #e9dfcf;
|
||||
--el-color-warning-light-9: #f4efe7;
|
||||
--el-color-warning-dark-2: #724a0c;
|
||||
|
||||
--el-color-danger: #B8353A;
|
||||
--el-color-danger-rgb: 184, 53, 58;
|
||||
--el-color-danger-light-3: #cd7275;
|
||||
--el-color-danger-light-5: #dc9a9d;
|
||||
--el-color-danger-light-7: #eac2c4;
|
||||
--el-color-danger-light-8: #f1d7d8;
|
||||
--el-color-danger-light-9: #f8ebeb;
|
||||
--el-color-danger-dark-2: #932a2e;
|
||||
|
||||
--el-color-info: #656C79;
|
||||
--el-color-info-rgb: 101, 108, 121;
|
||||
--el-color-info-light-3: #9398a1;
|
||||
--el-color-info-light-5: #b2b6bc;
|
||||
--el-color-info-light-7: #d1d3d7;
|
||||
--el-color-info-light-8: #e0e2e4;
|
||||
--el-color-info-light-9: #f0f0f2;
|
||||
--el-color-info-dark-2: #515661;
|
||||
|
||||
/* EP 中性色(文字 / 边框 / 填充)接到同一套 token,避免表格、输入框另成一套灰 */
|
||||
--el-text-color-primary: var(--color-text-primary);
|
||||
--el-text-color-regular: var(--color-text-secondary);
|
||||
--el-text-color-secondary: var(--color-text-tertiary);
|
||||
--el-text-color-placeholder: var(--color-text-placeholder);
|
||||
--el-text-color-disabled: var(--color-text-disabled);
|
||||
--el-border-color: var(--color-border);
|
||||
--el-border-color-light: var(--color-border-light);
|
||||
--el-border-color-lighter: var(--color-border-light);
|
||||
--el-border-color-extra-light: var(--color-border-hairline);
|
||||
--el-fill-color: var(--color-bg-subtle);
|
||||
--el-fill-color-light: var(--color-bg-secondary);
|
||||
--el-fill-color-lighter: var(--color-bg-secondary);
|
||||
--el-bg-color: var(--color-bg-primary);
|
||||
--el-bg-color-page: var(--color-bg-page);
|
||||
--el-border-radius-base: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
|
||||
body { min-width: 1100px; }
|
||||
|
||||
/* <small> 的浏览器默认样式是 font-size: smaller(0.833em),会按父级字号再缩一档:
|
||||
父级 12px 时实际渲染 10px、11px 时渲染 9.17px —— 静默跌破 11px 下限,
|
||||
且源码里看不到任何非法字号,静态检查抓不到(2026-09-22 由渲染态实测发现)。
|
||||
这里统一改为继承父级字号,需要更小的场景必须显式声明 token 字号(守卫会校验)。 */
|
||||
small { font-size: inherit; }
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid var(--admin-coral-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------------- Element Plus 组件级覆盖 ---------------- */
|
||||
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
|
||||
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--color-primary);
|
||||
--el-button-border-color: var(--color-primary);
|
||||
--el-button-hover-bg-color: var(--color-primary-hover);
|
||||
--el-button-hover-border-color: var(--color-primary-hover);
|
||||
--el-button-bg-color: var(--admin-coral-solid);
|
||||
--el-button-border-color: var(--admin-coral-solid);
|
||||
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
|
||||
--el-button-hover-border-color: var(--admin-coral-solid-hover);
|
||||
--el-button-active-bg-color: var(--el-color-primary-dark-2);
|
||||
--el-button-active-border-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
|
||||
.el-radio-group .el-radio-button__inner:checked {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--admin-coral-solid);
|
||||
border-color: var(--admin-coral-solid);
|
||||
}
|
||||
.el-card { --el-card-border-radius: var(--border-radius-md); --el-card-border-color: var(--color-border-light); }
|
||||
.el-table { --el-table-header-bg-color: #FCFAF8; --el-table-border-color: var(--color-border-light); }
|
||||
.el-tag { border-radius: 999px; }
|
||||
.page-container { padding: 24px; }
|
||||
.page-shell { max-width: 1440px; margin: 0 auto; }
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.page-heading h1 { font-size: 24px; line-height: 1.35; font-weight: 650; }
|
||||
|
||||
.el-card {
|
||||
--el-card-border-radius: var(--border-radius-md);
|
||||
--el-card-border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-header-bg-color: var(--color-bg-secondary);
|
||||
--el-table-border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.el-tag { border-radius: var(--border-radius-pill); }
|
||||
|
||||
/* ---------------- 通用版式 ---------------- */
|
||||
.page-container { padding: var(--space-6); }
|
||||
|
||||
.page-shell { max-width: var(--admin-page-max-width); margin: 0 auto; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: var(--font-line-snug);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.page-heading p { margin-top: 6px; color: var(--color-text-tertiary); }
|
||||
.surface-card { background: var(--color-bg-primary); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-md); box-shadow: var(--shadow-card); }
|
||||
|
||||
.surface-card {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* 语义文字色工具类(比在页面里各写一遍 color: #xxx 更不容易漂移) */
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-tertiary { color: var(--color-text-tertiary); }
|
||||
.mt-sm { margin-top: 8px; }
|
||||
.mt-md { margin-top: 16px; }
|
||||
.mt-lg { margin-top: 24px; }
|
||||
.mb-sm { margin-bottom: 8px; }
|
||||
.mb-md { margin-bottom: 16px; }
|
||||
.mb-lg { margin-bottom: 24px; }
|
||||
.text-muted { color: var(--color-text-tertiary); }
|
||||
.text-brand { color: var(--admin-coral-strong); }
|
||||
.text-success { color: var(--color-success-strong); }
|
||||
.text-warning { color: var(--color-warning-strong); }
|
||||
.text-danger { color: var(--color-danger-strong); }
|
||||
|
||||
/* 语义底色工具类 */
|
||||
.bg-card { background: var(--color-bg-primary); }
|
||||
.bg-subtle { background: var(--color-bg-subtle); }
|
||||
.bg-brand-soft { background: var(--admin-coral-soft); }
|
||||
|
||||
/* 间距工具类 */
|
||||
.mt-sm { margin-top: var(--space-2); }
|
||||
.mt-md { margin-top: var(--space-4); }
|
||||
.mt-lg { margin-top: var(--space-6); }
|
||||
.mb-sm { margin-bottom: var(--space-2); }
|
||||
.mb-md { margin-bottom: var(--space-4); }
|
||||
.mb-lg { margin-bottom: var(--space-6); }
|
||||
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
@media (max-width: 1200px) { body { min-width: 0; } }
|
||||
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
/* ---------------- 交互反馈 ---------------- */
|
||||
/* 桌面端控件热区下限:32px(WCAG 2.5.8 AA 为 24px,留出舒适余量) */
|
||||
.tap-control { min-height: var(--tap-control); min-width: var(--tap-control); }
|
||||
|
||||
/* 按压反馈:统一走 token,避免各页面各写一套 scale/opacity */
|
||||
.pressable { transition: transform var(--motion-fast) var(--ease-standard), opacity var(--motion-fast) var(--ease-standard); }
|
||||
.pressable:active { transform: scale(var(--press-scale)); opacity: var(--press-opacity); }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
body { min-width: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-container { padding: var(--space-4); }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 管理端调色板常量(TS 侧)。
|
||||
*
|
||||
* 为什么需要这个文件:
|
||||
* 部分模板用 `:style="{ backgroundColor: stage.color + '15' }"` 把 6 位 hex 拼成
|
||||
* 8 位带 alpha 的颜色('#409EFF' + '15' → '#409EFF15',即 8% 不透明度)。
|
||||
* 这类位置**不能**用 CSS var —— `'var(--x)' + '15'` 是无效值,浏览器会整条丢弃。
|
||||
* 于是「色值只有一处事实源」这条规则在 TS 侧会漏。本文件补上这个缺口。
|
||||
*
|
||||
* 单一事实源约定:
|
||||
* 键名就是 src/theme/index.css 里的 var 名,值必须与之一致。
|
||||
* 门控脚本 scripts/lint-admin-tokens.mjs 会逐项比对,任一侧改了而另一侧没改即失败。
|
||||
*/
|
||||
|
||||
export const TOKEN_HEX: Record<string, string> = {
|
||||
'--color-primary': '#F45F68',
|
||||
'--color-primary-strong': '#C0414A',
|
||||
'--color-primary-solid': '#C84A53',
|
||||
'--color-success': '#2E9B68',
|
||||
'--color-success-strong': '#1F7A4F',
|
||||
'--color-warning': '#E59A32',
|
||||
'--color-warning-strong': '#8F5D0F',
|
||||
'--color-danger': '#D94A4A',
|
||||
'--color-danger-strong': '#B8353A',
|
||||
'--color-info': '#4A7CD4',
|
||||
'--color-info-strong': '#3A63B0',
|
||||
'--color-purple': '#8B63BC',
|
||||
'--color-purple-strong': '#6B47A0',
|
||||
'--color-neutral': '#8A9099',
|
||||
'--color-neutral-strong': '#656C79',
|
||||
'--color-text-primary': '#1F2328',
|
||||
'--color-text-secondary': '#555555',
|
||||
'--color-text-tertiary': '#676E7B',
|
||||
'--color-bg-page': '#F7F9FB',
|
||||
'--color-border': '#E4E7EB',
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 token 的 hex 字面量。用于 :style 绑定(含 hex 拼接透明度的场景)。
|
||||
* @example token('--color-success') // '#2E9B68'
|
||||
*/
|
||||
export function token(name: string): string {
|
||||
const value = TOKEN_HEX[name]
|
||||
if (!value) throw new Error(`未登记的 token: ${name}(请先加入 TOKEN_HEX 并同步 theme/index.css)`)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带 alpha 的 8 位 hex。alpha 传 2 位十六进制('15' = 21/255 ≈ 8%)。
|
||||
* 与 `color + '15'` 等价,但把「必须 6 位 hex」这个约束显式化,避免误把 var() 传进来。
|
||||
*/
|
||||
export function tint(hex: string, alphaHex: string): string {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
throw new Error(`tint() 需要 6 位 hex(不能是 var() 或 3/8 位 hex):${hex}`)
|
||||
}
|
||||
return `${hex}${alphaHex}`
|
||||
}
|
||||
|
||||
/** 指标卡 / 待办项的色调组,与 theme/index.css 的 --tone-* 一一对应。 */
|
||||
export interface Tone {
|
||||
/** 图标与图形用的鲜亮色 */
|
||||
vivid: string
|
||||
/** 文字用的可读色(压 soft 底 ≥4.5:1) */
|
||||
ink: string
|
||||
/** 浅底 */
|
||||
soft: string
|
||||
}
|
||||
|
||||
export const TONES: Record<string, Tone> = {
|
||||
rose: { vivid: '#F45F68', ink: '#C0414A', soft: '#FFF0F1' },
|
||||
amber: { vivid: '#E59A32', ink: '#8F5D0F', soft: '#FFF6E6' },
|
||||
blue: { vivid: '#4A7CD4', ink: '#3A63B0', soft: '#EEF5FF' },
|
||||
green: { vivid: '#2E9B68', ink: '#1F7A4F', soft: '#EDF8F1' },
|
||||
purple: { vivid: '#8B63BC', ink: '#6B47A0', soft: '#F4EFFF' },
|
||||
slate: { vivid: '#8A9099', ink: '#656C79', soft: '#F4F5F7' },
|
||||
red: { vivid: '#D94A4A', ink: '#B8353A', soft: '#FFF6F6' },
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
||||
|
||||
export interface DashboardMetric {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
trendText: string
|
||||
tone: DashboardTone
|
||||
available: boolean
|
||||
route: string
|
||||
}
|
||||
|
||||
export interface DashboardTodo {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
count: number
|
||||
actionText: string
|
||||
route: string
|
||||
tone: DashboardTone
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export interface DashboardActivity {
|
||||
id: string
|
||||
title: string
|
||||
coverUrl: string
|
||||
startAt: string
|
||||
locationName: string
|
||||
registered: number | null
|
||||
capacity: number | null
|
||||
statusText: string
|
||||
}
|
||||
|
||||
export interface DashboardOverviewItem {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
compareText: string
|
||||
tone: DashboardTone
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export interface DashboardEffectivenessItem {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
max: number
|
||||
unit: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export interface DashboardModel {
|
||||
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
|
||||
metrics: DashboardMetric[]
|
||||
todos: DashboardTodo[]
|
||||
activities: DashboardActivity[]
|
||||
serviceOverview: DashboardOverviewItem[]
|
||||
effectiveness: DashboardEffectivenessItem[]
|
||||
activitiesAvailable: boolean
|
||||
notice: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 内存态访问令牌。
|
||||
//
|
||||
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
|
||||
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
|
||||
// Authorization 头承载。两者并存,互不冲突。
|
||||
|
||||
let currentToken = ''
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return currentToken
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string): void {
|
||||
currentToken = token || ''
|
||||
}
|
||||
|
||||
export function clearAccessToken(): void {
|
||||
currentToken = ''
|
||||
}
|
||||
@@ -1,34 +1,784 @@
|
||||
<template>
|
||||
<div class="page-container page-shell dashboard" v-loading="loading">
|
||||
<section class="welcome">
|
||||
<div><div class="eyebrow">{{ profile.badge }}</div><h1>{{ greeting }},{{ operatorName }}</h1><p>{{ profile.subtitle }}</p></div>
|
||||
<div class="date-card"><span>{{ today }}</span><strong>今日服务</strong></div>
|
||||
<div class="dashboard-page">
|
||||
<section class="dashboard-welcome">
|
||||
<div>
|
||||
<h1>你好,{{ operatorName }}</h1>
|
||||
<p>用专业的服务,帮助更多人遇见对的人。今天也是传递幸福的一天!</p>
|
||||
</div>
|
||||
<div class="dashboard-date">
|
||||
<span>{{ today }}</span>
|
||||
<small>丹阳 · 公益服务</small>
|
||||
</div>
|
||||
</section>
|
||||
<section class="metrics">
|
||||
<article v-for="item in metrics" :key="item.label" class="metric-card surface-card"><div class="metric-top"><span>{{item.label}}</span><span class="metric-icon" :class="item.tone">{{item.icon}}</span></div><strong>{{item.value}}</strong><small>{{item.hint}}</small></article>
|
||||
</section>
|
||||
<section class="dashboard-grid">
|
||||
<article class="surface-card focus-card"><header><div><span class="section-kicker">今日优先</span><h2>{{profile.focusTitle}}</h2></div><el-button type="primary" plain @click="go(profile.primaryPath)">{{profile.primaryAction}}</el-button></header><div class="task-list"><button v-for="task in profile.tasks" :key="task.title" type="button" class="task-row" @click="go(task.path)"><span class="task-dot" :class="task.tone"></span><span class="task-copy"><strong>{{task.title}}</strong><small>{{task.desc}}</small></span><span class="task-count">{{task.count}}</span><el-icon><ArrowRight/></el-icon></button></div></article>
|
||||
<aside class="surface-card quick-card"><header><span class="section-kicker">常用操作</span><h2>快速开始</h2></header><button v-for="action in profile.actions" :key="action.label" type="button" class="quick-action" @click="go(action.path)"><span class="quick-icon">{{action.icon}}</span><span><strong>{{action.label}}</strong><small>{{action.desc}}</small></span><el-icon><ArrowRight/></el-icon></button><div class="privacy-note"><el-icon><Lock/></el-icon><span><strong>数据访问受保护</strong><small>仅可查看职责范围内的服务信息,敏感操作全程留痕。</small></span></div></aside>
|
||||
</section>
|
||||
<section class="surface-card service-overview"><header><div><span class="section-kicker">公益服务概况</span><h2>本周服务进展</h2></div><span class="preview-label" v-if="preview">界面预览数据</span></header><div class="progress-grid"><div v-for="item in progress" :key="item.label" class="progress-item"><div><span>{{item.label}}</span><strong>{{item.value}}</strong></div><el-progress :percentage="item.percent" :stroke-width="8" :show-text="false" :color="item.color"/><small>{{item.note}}</small></div></div></section>
|
||||
|
||||
<div v-if="loading" class="dashboard-loading" aria-label="工作台加载中">
|
||||
<el-skeleton v-for="index in 6" :key="index" animated>
|
||||
<template #template><el-skeleton-item variant="rect" class="metric-skeleton" /></template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="fatalError" class="dashboard-error" role="alert">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span><strong>工作台数据加载失败</strong><small>{{ fatalError }}</small></span>
|
||||
<el-button type="primary" plain @click="retryLoad">重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="isForbidden" class="dashboard-forbidden">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span><strong>当前账号暂无统计权限</strong><small>仅展示您职责范围内可访问的业务入口。</small></span>
|
||||
</div>
|
||||
<div v-else-if="dashboard.notice" class="dashboard-notice">
|
||||
<el-icon><InfoFilled /></el-icon><span>{{ dashboard.notice }}</span>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-metrics" aria-label="工作台核心指标">
|
||||
<button
|
||||
v-for="metric in dashboard.metrics"
|
||||
:key="metric.key"
|
||||
type="button"
|
||||
class="metric-card"
|
||||
:class="[`tone-${metric.tone}`, { unavailable: !metric.available }]"
|
||||
@click="goTo(metric.route)"
|
||||
>
|
||||
<span class="metric-icon"><el-icon><component :is="metricIcon(metric.key)" /></el-icon></span>
|
||||
<span class="metric-copy">
|
||||
<small>{{ metric.label }}</small>
|
||||
<strong>{{ metric.available ? formatNumber(metric.value) : '—' }}</strong>
|
||||
<em>{{ metric.trendText }}</em>
|
||||
</span>
|
||||
<el-icon class="metric-arrow"><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid dashboard-grid-top">
|
||||
<article class="dashboard-panel todo-panel">
|
||||
<header class="panel-header">
|
||||
<div><h2>今日待办事项 <span>{{ todoCount }}</span></h2></div>
|
||||
<button type="button" @click="goTo('/review')">查看更多 <el-icon><ArrowRight /></el-icon></button>
|
||||
</header>
|
||||
<div v-if="dashboard.todos.length" class="todo-list">
|
||||
<button
|
||||
v-for="item in dashboard.todos"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="todo-row"
|
||||
@click="goTo(item.route)"
|
||||
>
|
||||
<span class="todo-icon" :class="`tone-${item.tone}`"><el-icon><component :is="todoIcon(item.key)" /></el-icon></span>
|
||||
<span class="todo-copy"><strong>{{ item.title }}</strong><small>{{ item.description }}</small></span>
|
||||
<b>{{ item.count }}</b>
|
||||
<em>{{ item.actionText }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="dashboard-empty"><el-icon><CircleCheck /></el-icon><span>当前没有授权范围内的待办事项</span></div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel activity-panel">
|
||||
<header class="panel-header">
|
||||
<div><h2>近期公益活动</h2></div>
|
||||
<button type="button" @click="goTo('/activities/interests')">查看更多 <el-icon><ArrowRight /></el-icon></button>
|
||||
</header>
|
||||
<div v-if="dashboard.activities.length" class="activity-list">
|
||||
<button
|
||||
v-for="activity in dashboard.activities"
|
||||
:key="activity.id"
|
||||
type="button"
|
||||
class="activity-row"
|
||||
@click="goTo('/activities/interests')"
|
||||
>
|
||||
<img
|
||||
:src="activity.coverUrl || activityFallback"
|
||||
:alt="activity.title"
|
||||
@error="useActivityFallback"
|
||||
/>
|
||||
<span class="activity-copy">
|
||||
<strong>{{ activity.title }}</strong>
|
||||
<small>{{ formatDateTime(activity.startAt) }} · {{ activity.locationName }}</small>
|
||||
</span>
|
||||
<span class="activity-status">{{ statusLabel(activity.statusText) }}</span>
|
||||
<em>{{ activityRegistration(activity) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="dashboard-empty">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>{{ dashboard.activitiesAvailable ? '近期暂无公益活动' : '当前账号无活动列表权限' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article v-if="dashboard.followups.length" class="dashboard-panel">
|
||||
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
|
||||
<div class="todo-list">
|
||||
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
|
||||
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
|
||||
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
|
||||
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="dashboard-grid dashboard-grid-bottom">
|
||||
<article class="dashboard-panel service-overview-panel">
|
||||
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
||||
<div v-if="dashboard.serviceOverview.length" class="overview-grid">
|
||||
<div v-for="item in dashboard.serviceOverview" :key="item.key" :class="`tone-${item.tone}`">
|
||||
<small>{{ item.label }}</small>
|
||||
<strong>{{ formatNumber(item.value) }}</strong>
|
||||
<em>{{ item.compareText }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="dashboard-empty"><el-icon><DataAnalysis /></el-icon><span>暂无可查看的服务统计</span></div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel effectiveness-panel">
|
||||
<header class="panel-header"><div><h2>项目服务成效</h2></div><span>实时数据</span></header>
|
||||
<div v-if="dashboard.effectiveness.length" class="effectiveness-list">
|
||||
<div v-for="item in dashboard.effectiveness" :key="item.key" class="effectiveness-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<div><i :style="{ width: `${item.max > 0 ? Math.min(100, item.value / item.max * 100) : 0}%` }"></i></div>
|
||||
<strong>{{ formatNumber(item.value) }} {{ item.unit }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="dashboard-empty"><el-icon><TrendCharts /></el-icon><span>暂无可查看的成效数据</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed,onMounted,ref} from 'vue';import {useRouter} from 'vue-router';import {ArrowRight,Lock} from '@element-plus/icons-vue';import {get} from '@/api/client';import {useAuthStore} from '@/stores/auth'
|
||||
type Task={title:string;desc:string;count:number;tone:string;path:string};type Action={label:string;desc:string;icon:string;path:string};type Profile={badge:string;subtitle:string;focusTitle:string;primaryAction:string;primaryPath:string;tasks:Task[];actions:Action[]}
|
||||
const router=useRouter();const auth=useAuthStore();const loading=ref(false);const preview=ref(false);const stats=ref<Record<string,number>>({})
|
||||
const operatorName=computed(()=>auth.operator?.name||'工作人员');const today=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'long'}).format(new Date());const greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好')
|
||||
const p=computed(()=>auth.permissions||[]);const has=(code:string)=>p.value.includes(code)
|
||||
const profile=computed<Profile>(()=>{if(has('counseling.service.view'))return{badge:'婚恋咨询工作台',subtitle:'只处理明确分配给你的咨询服务,尊重每一位来访者的隐私。',focusTitle:'我的待办咨询',primaryAction:'进入咨询服务',primaryPath:'/counseling',tasks:[{title:'待首次联系',desc:'请在服务时限内完成首次沟通',count:stats.value.pendingCounseling||3,tone:'rose',path:'/counseling'},{title:'今日预约',desc:'确认时间与服务方式',count:stats.value.todayAppointments||2,tone:'amber',path:'/counseling'},{title:'待补充记录',desc:'仅记录必要的服务事实',count:1,tone:'blue',path:'/counseling'}],actions:[{label:'查看我的咨询',desc:'仅限已分配服务',icon:'咨',path:'/counseling'},{label:'转介协作',desc:'需要其他专业支持',icon:'转',path:'/referral'}]};if(has('service_appointment.view'))return{badge:'服务站工作台',subtitle:'聚焦今天的现场预约与指定服务,不浏览全平台用户库。',focusTitle:'今日到站服务',primaryAction:'管理预约',primaryPath:'/service-appointment',tasks:[{title:'待确认预约',desc:'确认服务时间与到站方式',count:stats.value.pendingAppointments||6,tone:'rose',path:'/service-appointment'},{title:'即将到站',desc:'提前准备现场服务资料',count:4,tone:'green',path:'/service-appointment'},{title:'待完成回访',desc:'记录服务结果与后续安排',count:2,tone:'blue',path:'/service-appointment'}],actions:[{label:'预约管理',desc:'查看今日预约',icon:'约',path:'/service-appointment'},{label:'服务站信息',desc:'维护开放时间与项目',icon:'站',path:'/service-station'}]};if(has('review.view'))return{badge:'资料审核工作台',subtitle:'核验真实信息,帮助平台建立安全、可信的相遇环境。',focusTitle:'今日审核队列',primaryAction:'开始审核',primaryPath:'/review',tasks:[{title:'待初审资料',desc:'按提交时间依次处理',count:stats.value.pendingReviews||18,tone:'rose',path:'/review'},{title:'待复核资料',desc:'关注证件与资料一致性',count:5,tone:'amber',path:'/review'},{title:'待补充材料',desc:'清晰说明需要用户补充的内容',count:7,tone:'blue',path:'/review'}],actions:[{label:'进入资料审核',desc:'处理待审资料',icon:'审',path:'/review'},{label:'用户查询',desc:'按权限查询用户',icon:'查',path:'/users'}]};return{badge:'公益服务工作台',subtitle:'从今天最重要的待办开始,让每一项服务都有回应。',focusTitle:'今日待办',primaryAction:'查看全部',primaryPath:'/dashboard',tasks:[{title:'待处理服务',desc:'请按服务时限及时响应',count:stats.value.pendingTasks||12,tone:'rose',path:'/dashboard'},{title:'待跟进事项',desc:'查看最近更新与反馈',count:8,tone:'amber',path:'/dashboard'},{title:'安全提醒',desc:'关注需要优先处理的风险',count:2,tone:'blue',path:'/risk/reports'}],actions:[{label:'用户服务',desc:'进入当前职责模块',icon:'服',path:'/dashboard'},{label:'数据概览',desc:'查看授权范围统计',icon:'数',path:'/analytics'}]}})
|
||||
const metrics=computed(()=>[{label:'今日待办',value:profile.value.tasks.reduce((n,t)=>n+t.count,0),hint:'按优先级及时处理',icon:'待',tone:'rose'},{label:'本周已完成',value:stats.value.weekCompleted||46,hint:'比上周多 8 项',icon:'成',tone:'green'},{label:'服务中',value:stats.value.activeServices||23,hint:'均在正常服务时限内',icon:'服',tone:'blue'},{label:'需关注',value:stats.value.attention||2,hint:'包含临期与风险事项',icon:'醒',tone:'amber'}])
|
||||
const progress=[{label:'资料审核及时率',value:'96%',percent:96,note:'目标 95%',color:'#2E9B68'},{label:'预约按时完成率',value:'91%',percent:91,note:'本周 31 次服务',color:'#4A7CD4'},{label:'服务回访完成率',value:'84%',percent:84,note:'还有 5 项待回访',color:'#E59A32'}]
|
||||
function go(path:string){if(path)router.push(path)}
|
||||
async function load(){if(auth.token==='ui-preview-only'){preview.value=true;return}loading.value=true;try{stats.value=(await get('/operator/analytics/dashboard')) as unknown as Record<string,number>}catch{preview.value=true}finally{loading.value=false}}
|
||||
onMounted(load)
|
||||
import { computed, onActivated, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
ChatDotRound,
|
||||
CircleCheck,
|
||||
DataAnalysis,
|
||||
DocumentChecked,
|
||||
House,
|
||||
InfoFilled,
|
||||
Link,
|
||||
Lock,
|
||||
StarFilled,
|
||||
TrendCharts,
|
||||
UserFilled,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
|
||||
import { BRAND_ACTIVITY_FALLBACK } from '@/brand'
|
||||
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const activityFallback = BRAND_ACTIVITY_FALLBACK
|
||||
const dashboard = ref<DashboardModel>(createEmptyDashboard())
|
||||
const loading = ref(true)
|
||||
const fatalError = ref('')
|
||||
|
||||
const permissions = computed(() => authStore.permissions || [])
|
||||
const operatorName = computed(() => authStore.operator?.name || '管理员')
|
||||
const isPreviewData = computed(() => authStore.token === 'ui-preview-only')
|
||||
const hasAnyData = computed(() => dashboard.value.metrics.some((item) => item.available) || dashboard.value.activitiesAvailable)
|
||||
const isForbidden = computed(() => !hasAnyData.value && !fatalError.value && !isPreviewData.value)
|
||||
const todoCount = computed(() => dashboard.value.todos.reduce((total, item) => total + item.count, 0))
|
||||
const today = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date())
|
||||
|
||||
const metricIcons: Record<string, unknown> = {
|
||||
pending_reviews: DocumentChecked,
|
||||
matchmaker_todos: StarFilled,
|
||||
counseling_todos: ChatDotRound,
|
||||
station_appointments: House,
|
||||
effective_matches: Link,
|
||||
activity_registrations: UserFilled,
|
||||
}
|
||||
|
||||
const todoIcons: Record<string, unknown> = {
|
||||
review: DocumentChecked,
|
||||
counseling: ChatDotRound,
|
||||
appointment: Calendar,
|
||||
risk: Warning,
|
||||
}
|
||||
|
||||
function metricIcon(key: string) {
|
||||
return metricIcons[key] || DataAnalysis
|
||||
}
|
||||
|
||||
function todoIcon(key: string) {
|
||||
return todoIcons[key] || CircleCheck
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return '时间待确认'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
registration_open: '报名中',
|
||||
published: '报名中',
|
||||
ongoing: '进行中',
|
||||
completed: '已结束',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function activityRegistration(activity: DashboardActivity) {
|
||||
if (activity.registered == null) return activity.capacity == null ? '报名数据未提供' : `名额 ${activity.capacity}`
|
||||
return activity.capacity == null ? `已报名 ${activity.registered}` : `已报名 ${activity.registered}/${activity.capacity}`
|
||||
}
|
||||
|
||||
function useActivityFallback(event: Event) {
|
||||
const image = event.currentTarget as HTMLImageElement
|
||||
if (!image.src.endsWith(activityFallback)) image.src = activityFallback
|
||||
}
|
||||
|
||||
function canAccess(path: string) {
|
||||
const matched = router.resolve(path).matched
|
||||
const required = matched.flatMap((route) => (route.meta.permissions as string[] | undefined) || [])
|
||||
return required.every((permission) => permissions.value.includes(permission))
|
||||
}
|
||||
|
||||
function goTo(path: string) {
|
||||
if (!path) return
|
||||
if (!canAccess(path)) {
|
||||
ElMessage.info('当前账号无权访问该模块')
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function retryLoad() {
|
||||
loading.value = true
|
||||
fatalError.value = ''
|
||||
try {
|
||||
if (authStore.token === 'ui-preview-only') {
|
||||
dashboard.value = createEmptyDashboard('界面预览模式不展示虚构业务数字')
|
||||
return
|
||||
}
|
||||
dashboard.value = await loadDashboard(permissions.value)
|
||||
if (!hasAnyData.value && dashboard.value.notice.includes('加载失败')) {
|
||||
fatalError.value = dashboard.value.notice
|
||||
}
|
||||
} catch (error) {
|
||||
fatalError.value = error instanceof Error ? error.message : '服务暂时不可用,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(retryLoad)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard{padding-top:28px;padding-bottom:40px}.welcome{min-height:150px;padding:30px 34px;display:flex;align-items:center;justify-content:space-between;border-radius:18px;background:linear-gradient(120deg,#FFF1EE,#FFF9F2 56%,#F4F8F6);border:1px solid #F6E4DE;position:relative;overflow:hidden}.welcome:after{content:'缘';position:absolute;right:130px;top:-54px;font-size:210px;font-weight:700;color:rgba(233,107,107,.07)}.eyebrow,.section-kicker{font-size:12px;color:var(--color-primary);font-weight:650;letter-spacing:1px}.welcome h1{margin-top:9px;font-size:28px}.welcome p{margin-top:10px;color:var(--color-text-secondary)}.date-card{z-index:1;padding:14px 18px;border-radius:13px;background:rgba(255,255,255,.75);border:1px solid rgba(255,255,255,.9);display:flex;flex-direction:column;text-align:right}.date-card span{font-size:12px;color:var(--color-text-tertiary)}.date-card strong{margin-top:4px;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:18px}.metric-card{padding:20px}.metric-top{display:flex;justify-content:space-between;color:var(--color-text-secondary)}.metric-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;font-weight:650}.metric-icon.rose{background:var(--color-primary-light);color:var(--color-primary)}.metric-icon.green{background:#ECF8F2;color:var(--color-success)}.metric-icon.blue{background:#EEF4FF;color:var(--color-info)}.metric-icon.amber{background:#FFF7E9;color:var(--color-warning)}.metric-card>strong{display:block;margin-top:8px;font-size:30px}.metric-card>small{display:block;margin-top:6px;color:var(--color-text-tertiary)}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(300px,.8fr);gap:18px;margin-top:18px}.focus-card,.quick-card,.service-overview{padding:24px}.focus-card header,.quick-card header,.service-overview header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.focus-card h2,.quick-card h2,.service-overview h2{margin-top:5px;font-size:18px}.task-row,.quick-action{width:100%;border:0;background:transparent;display:flex;align-items:center;text-align:left;cursor:pointer}.task-row{min-height:76px;border-top:1px solid var(--color-border-light);gap:14px}.task-row:hover,.quick-action:hover{background:var(--color-primary-lighter)}.task-dot{width:9px;height:9px;border-radius:50%;background:var(--color-primary)}.task-dot.green{background:var(--color-success)}.task-dot.blue{background:var(--color-info)}.task-dot.amber{background:var(--color-warning)}.task-copy{flex:1;display:flex;flex-direction:column}.task-copy small,.quick-action small{margin-top:5px;color:var(--color-text-tertiary)}.task-count{font-size:22px;font-weight:650;color:var(--color-primary)}.quick-action{padding:15px 8px;gap:12px;border-top:1px solid var(--color-border-light)}.quick-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:var(--color-primary-light);color:var(--color-primary);font-weight:650}.quick-action>span:nth-child(2){flex:1;display:flex;flex-direction:column}.privacy-note{margin-top:18px;padding:16px;display:flex;gap:10px;background:#F4F8F6;color:var(--color-success);border-radius:12px}.privacy-note span{display:flex;flex-direction:column}.privacy-note small{margin-top:4px;color:var(--color-text-secondary);line-height:1.5}.service-overview{margin-top:18px}.preview-label{font-size:11px;color:var(--color-warning);background:#FFF7E9;padding:5px 10px;border-radius:99px}.progress-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}.progress-item>div{display:flex;justify-content:space-between;margin-bottom:12px}.progress-item strong{font-size:18px}.progress-item small{display:block;margin-top:8px;color:var(--color-text-tertiary)}
|
||||
@media(max-width:1100px){.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.progress-grid{grid-template-columns:1fr}.welcome:after{right:20px}}
|
||||
@media(max-width:680px){.metrics{grid-template-columns:1fr}.welcome{padding:24px}.date-card{display:none}}
|
||||
</style>
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
max-width: var(--admin-content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 26px 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
display: flex;
|
||||
min-height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.dashboard-welcome h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: var(--font-line-snug);
|
||||
}
|
||||
|
||||
.dashboard-welcome p {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.dashboard-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.dashboard-date small {
|
||||
padding: 6px 10px;
|
||||
color: var(--admin-coral-strong);
|
||||
background: var(--admin-coral-soft);
|
||||
border-radius: var(--border-radius-pill);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.dashboard-loading,
|
||||
.dashboard-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-loading {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-skeleton {
|
||||
height: 105px;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.dashboard-error,
|
||||
.dashboard-forbidden,
|
||||
.dashboard-notice {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
padding: 10px 14px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-danger-strong);
|
||||
background: var(--color-danger-soft);
|
||||
border: 1px solid var(--color-border-danger);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.dashboard-error > span,
|
||||
.dashboard-forbidden > span {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-error small,
|
||||
.dashboard-forbidden small {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 108px;
|
||||
padding: 14px var(--space-3);
|
||||
grid-template-columns: 38px 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: transform var(--motion-normal) var(--ease-standard),
|
||||
box-shadow var(--motion-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: var(--shadow-raise);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-card:active {
|
||||
transform: translateY(0) scale(var(--press-scale));
|
||||
}
|
||||
|
||||
.metric-card.unavailable {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
|
||||
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
|
||||
.metric-icon,
|
||||
.todo-icon {
|
||||
display: grid;
|
||||
color: var(--tone-ink);
|
||||
background: var(--tone-soft);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.metric-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-copy strong {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 原为 9px(低于可读下限),提到 11px */
|
||||
.metric-copy em {
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-arrow {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
|
||||
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
|
||||
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
|
||||
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
|
||||
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
|
||||
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
|
||||
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
|
||||
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dashboard-grid-top {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom .dashboard-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
padding: 14px var(--space-4);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--admin-card-radius);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border-hairline);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
padding-bottom: 10px;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.panel-header h2 span {
|
||||
display: inline-grid;
|
||||
min-width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-micro);
|
||||
line-height: var(--space-5);
|
||||
background: var(--admin-coral-solid);
|
||||
border-radius: var(--border-radius-pill);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
display: flex;
|
||||
min-height: var(--tap-control);
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.panel-header button:hover {
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.panel-header > span {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.todo-list,
|
||||
.activity-list {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.todo-row,
|
||||
.activity-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 47px;
|
||||
padding: 6px 5px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border-hairline);
|
||||
transition: background var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.todo-row:hover,
|
||||
.activity-row:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.todo-icon {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.todo-copy,
|
||||
.activity-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.todo-copy strong,
|
||||
.activity-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-copy small,
|
||||
.activity-copy small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-row > b {
|
||||
min-width: 29px;
|
||||
color: var(--admin-coral-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-row > em {
|
||||
color: var(--admin-coral-strong);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.activity-row img {
|
||||
width: 72px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
padding: 3px 6px;
|
||||
color: var(--color-success-strong);
|
||||
font-size: var(--font-size-micro);
|
||||
background: var(--color-success-soft);
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.activity-row > em {
|
||||
min-width: 76px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
display: flex;
|
||||
min-height: 160px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.dashboard-empty .el-icon {
|
||||
color: var(--color-icon-muted);
|
||||
font-size: var(--font-size-5xl);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
min-height: 118px;
|
||||
padding-top: var(--space-3);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.overview-grid > div {
|
||||
display: flex;
|
||||
padding: var(--space-3) 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
background: var(--tone-soft);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.overview-grid small {
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.overview-grid strong {
|
||||
margin-top: 7px;
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.overview-grid em {
|
||||
margin-top: 7px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.effectiveness-list {
|
||||
min-height: 118px;
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.effectiveness-row {
|
||||
display: grid;
|
||||
min-height: 23px;
|
||||
grid-template-columns: 92px 1fr 76px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.effectiveness-row > span {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
|
||||
.effectiveness-row > div {
|
||||
height: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: var(--color-primary-light);
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.effectiveness-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--gradient-brand-bar);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.effectiveness-row strong {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px), (max-height: 820px) {
|
||||
.dashboard-page { padding: 12px 18px 20px; }
|
||||
.dashboard-welcome { min-height: 54px; }
|
||||
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
|
||||
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
|
||||
.dashboard-metrics { margin-top: 6px; gap: 7px; }
|
||||
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
|
||||
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
|
||||
.metric-copy strong { font-size: var(--font-size-2xl); }
|
||||
.dashboard-grid { margin-top: 9px; gap: 9px; }
|
||||
.dashboard-panel { padding: 10px var(--space-3); }
|
||||
.todo-list, .activity-list { min-height: 152px; }
|
||||
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
|
||||
.activity-row img { width: 58px; height: 31px; }
|
||||
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
|
||||
.overview-grid, .effectiveness-list { min-height: 98px; }
|
||||
.overview-grid > div { padding: var(--space-2) 6px; }
|
||||
.overview-grid strong { font-size: var(--font-size-2xl); }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-loading,
|
||||
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-date { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,78 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1>丹阳公益婚恋服务平台</h1>
|
||||
<p>工作人员登录</p>
|
||||
<main class="login-shell">
|
||||
<section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
|
||||
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img :src="BRAND_LOGO" alt="" />
|
||||
<div>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-message">
|
||||
<p>公益 · 真实 · 安全 · 温暖</p>
|
||||
<h1>让每一份真诚,都在丹阳得到回应</h1>
|
||||
<span>用专业服务守护真实相识,以公益初心陪伴幸福成长。</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock"
|
||||
size="large" show-password @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" style="width:100%;background:#E96B6B;border-color:#E96B6B"
|
||||
@click="handleLogin">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-footer">
|
||||
<p>丹阳公益婚恋服务平台 · 运营管理后台</p>
|
||||
</section>
|
||||
|
||||
<section class="login-panel">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="eyebrow">管理端 · 运营后台</span>
|
||||
<h2>欢迎回来</h2>
|
||||
<p>请使用工作人员账号登录</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model.trim="form.username" autocomplete="username" placeholder="请输入用户名" size="large">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
autocomplete="current-password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button class="submit-button" type="primary" size="large" :loading="loading" @click="handleLogin">
|
||||
登录管理后台
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="safety-note">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { BRAND_LOGIN_SCENE, BRAND_LOGO } from '@/brand'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { CircleCheck, Lock, User } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
@@ -44,30 +80,194 @@ const rules = {
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
if (!formRef.value || loading.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container { width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #FFF3F2 0%, #FFF8F7 100%); }
|
||||
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(233,107,107,0.1); }
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-header h1 { font-size: 24px; color: #E96B6B; margin-bottom: 8px; }
|
||||
.login-header p { font-size: 14px; color: #999; }
|
||||
.login-form { margin-top: 24px; }
|
||||
.login-footer { text-align: center; margin-top: 24px; font-size: 12px; color: #bbb; }
|
||||
.login-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--admin-soft-pink);
|
||||
}
|
||||
|
||||
.brand-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 48px 56px 70px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-title img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-title div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-title strong {
|
||||
color: var(--admin-primary);
|
||||
font-size: var(--font-size-3xl);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.brand-title span {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.brand-message {
|
||||
max-width: 590px;
|
||||
padding: 26px 30px;
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-bg-overlay-strong);
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand-message p {
|
||||
color: var(--admin-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.brand-message h1 {
|
||||
margin-top: 12px;
|
||||
font-size: clamp(28px, 3vw, 42px);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.brand-message span {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--admin-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin-top: 10px;
|
||||
font-size: var(--font-size-5xl);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.safety-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 14px 16px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
background: var(--admin-soft-green);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.safety-note .el-icon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.brand-overlay {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
min-height: calc(100vh - 280px);
|
||||
padding: 36px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,15 +2,25 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [vue()],
|
||||
// 管理端产物部署在 nginx 的 /admin/ 子路径下(见 infrastructure/nginx-production.conf
|
||||
// 与 admin-web/nginx-admin.conf)。构建时必须带 /admin/ 前缀,否则 index.html 引用的
|
||||
// /assets/*.js 会落到站点根路径,被生产 nginx 的其它 location 兜成 404 或错误页面。
|
||||
// 开发服务器保持根路径:http://localhost:13520/ 的既有开发与排查习惯不变
|
||||
// (vite 的 base 对 dev 与 build 各自生效,故此处按 command 分叉)。
|
||||
base: command === 'build' ? '/admin/' : '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
||||
port: 13520,
|
||||
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
||||
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8085',
|
||||
@@ -18,4 +28,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>善缘丹阳 · 小程序设计系统 v2 改造对照</title>
|
||||
<style>
|
||||
:root{
|
||||
/* ── 与 miniapp/app.wxss 完全一致的 token(此处 1rpx = 0.5px,按 375pt 机型还原) ── */
|
||||
--primary:#E96B6B; --primary-strong:#B8434A; --primary-solid:#C24C50; --primary-light:#FFF3F2;
|
||||
--night-start:#172A72; --night-end:#25104F; --night-soft:#3445A1;
|
||||
--accent-pink:#FF4F91; --accent-coral:#FF6A78;
|
||||
--text-strong:#1A1F36; --text-primary:#2A3050; --text-secondary:#4A5169; --text-tertiary:#646C85;
|
||||
--text-placeholder:#9AA1B4; --text-inverse:#FFFFFF;
|
||||
--bg-page:#F6F5F3; --bg-card:#FFFFFF; --bg-subtle:#F5F6FA;
|
||||
--border:#E6E8EF; --divider:#EFF0F5;
|
||||
--accent-purple-strong:#7C60BC; --accent-purple-light:#F3EEFF;
|
||||
--success-strong:#17805A; --success-light:#ECF8F2; --success-on-night:#A6F5D7;
|
||||
--warning-strong:#9C5F14; --warning-light:#FFF7E9; --danger-strong:#C62828; --danger-light:#FFF0F0;
|
||||
--info-strong:#2A5DB0; --info-light:#EEF4FF;
|
||||
--glow-coral:rgba(255,79,145,.40); --glow-indigo:rgba(94,124,255,.32);
|
||||
--star:rgba(255,255,255,.70); --glass:rgba(255,255,255,.10); --hairline:rgba(255,255,255,.16);
|
||||
--glass-success:rgba(90,226,184,.16);
|
||||
--shadow-card:0 4px 14px rgba(35,35,95,.07); --shadow-raised:0 6px 18px rgba(35,35,95,.10);
|
||||
--shadow-night:0 8px 20px rgba(28,30,88,.18);
|
||||
--grad-smoke:linear-gradient(135deg,#FF6A78 0%,#E96B6B 100%);
|
||||
--grad-solid:linear-gradient(135deg,#C24C50 0%,#B8434A 100%);
|
||||
--scrim:linear-gradient(90deg,rgba(23,42,114,.84) 0%,rgba(37,16,79,.40) 56%,rgba(255,79,145,.12) 100%);
|
||||
/* 文档层配色 */
|
||||
--doc-bg:#F1F3F8; --doc-panel:#FFFFFF; --doc-line:#E2E6EF; --doc-ink:#1A1F36; --doc-mute:#5C6480;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;background:var(--doc-bg);color:var(--doc-ink);
|
||||
font-family:-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
|
||||
line-height:1.6;-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:40px 24px 80px;}
|
||||
h1{font-size:28px;margin:0 0 8px;letter-spacing:-.2px;}
|
||||
h2{font-size:20px;margin:0 0 4px;}
|
||||
h3{font-size:15px;margin:0 0 10px;}
|
||||
.sub{color:var(--doc-mute);font-size:13.5px;margin:0;}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;}
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
|
||||
background:#fff;border:1px solid var(--doc-line);font-size:12.5px;color:var(--doc-mute);
|
||||
}
|
||||
.chip b{color:var(--doc-ink);font-weight:600;}
|
||||
.panel{background:var(--doc-panel);border:1px solid var(--doc-line);border-radius:14px;padding:22px 24px;margin-top:22px;}
|
||||
.panel > p.lead{margin:6px 0 18px;color:var(--doc-mute);font-size:13.5px;}
|
||||
|
||||
/* 指标卡 */
|
||||
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:14px;}
|
||||
.metric{background:#fff;border:1px solid var(--doc-line);border-radius:12px;padding:16px 18px;}
|
||||
.metric .k{font-size:12.5px;color:var(--doc-mute);}
|
||||
.metric .v{font-size:24px;font-weight:700;margin:6px 0 2px;font-variant-numeric:tabular-nums;}
|
||||
.metric .d{font-size:12.5px;color:var(--doc-mute);}
|
||||
.up{color:var(--success-strong);} .flat{color:var(--doc-mute);}
|
||||
.metric .from{color:var(--doc-mute);text-decoration:line-through;font-weight:500;font-size:16px;margin-left:6px;}
|
||||
|
||||
/* 手机模拟 */
|
||||
.phones{display:flex;flex-wrap:wrap;gap:34px;justify-content:center;margin-top:8px;}
|
||||
.phone-col{display:flex;flex-direction:column;align-items:center;gap:12px;}
|
||||
.phone-tag{font-size:12.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--doc-line);background:#fff;color:var(--doc-mute);}
|
||||
.phone-tag.new{border-color:#C9E6D8;background:var(--success-light);color:var(--success-strong);font-weight:600;}
|
||||
.phone{
|
||||
width:375px;border-radius:30px;padding:9px;background:#15182B;
|
||||
box-shadow:0 18px 44px rgba(20,24,50,.22),0 2px 6px rgba(20,24,50,.14);
|
||||
}
|
||||
.screen{width:100%;border-radius:22px;overflow:hidden;background:var(--bg-page);position:relative;}
|
||||
.statusbar{height:22px;display:flex;align-items:center;justify-content:center;font-size:10px;color:rgba(255,255,255,.86);letter-spacing:.4px;}
|
||||
|
||||
/* ── 夜空头部:改造前(纯渐变) ── */
|
||||
.night-a{
|
||||
padding:0 12px 14px;
|
||||
background:linear-gradient(160deg,var(--night-start),var(--night-end));
|
||||
border-radius:0 0 21px 21px;box-shadow:var(--shadow-night);
|
||||
}
|
||||
/* ── 夜空头部:改造后(星点 + 双光晕 + 叠层) ── */
|
||||
.night-b{
|
||||
position:relative;overflow:hidden;padding:0 12px 16px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 18%,rgba(255,255,255,.70) 0,transparent 1.25px),
|
||||
radial-gradient(circle at 31% 66%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
radial-gradient(circle at 57% 12%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
radial-gradient(circle at 79% 52%,rgba(255,255,255,.70) 0,transparent 1.5px),
|
||||
radial-gradient(circle at 93% 84%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
linear-gradient(160deg,var(--night-start),var(--night-end));
|
||||
border-radius:0 0 24px 24px;box-shadow:var(--shadow-night);
|
||||
}
|
||||
.night-b::before,.night-b::after{content:'';position:absolute;border-radius:50%;pointer-events:none;}
|
||||
.night-b::before{top:-100px;right:-70px;width:280px;height:280px;
|
||||
background:radial-gradient(circle,var(--glow-coral) 0%,transparent 68%);}
|
||||
.night-b::after{bottom:-110px;left:-80px;width:310px;height:310px;
|
||||
background:radial-gradient(circle,var(--glow-indigo) 0%,transparent 70%);}
|
||||
.night-b > *{position:relative;z-index:1;}
|
||||
|
||||
.brand{min-height:56px;display:flex;align-items:center;justify-content:space-between;color:#fff;}
|
||||
.brand-l{display:flex;align-items:center;}
|
||||
.logo{width:33px;height:33px;border-radius:9px;background:linear-gradient(135deg,#FF7A88,#E96B6B);
|
||||
display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex:none;}
|
||||
.logo.b{width:34px;height:34px;}
|
||||
.brand-name-a{font-size:17px;font-weight:700;letter-spacing:1px;line-height:1.25;}
|
||||
.brand-name-b{font-size:18px;font-weight:700;letter-spacing:2px;line-height:1.25;}
|
||||
.brand-slo-a{font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.72);}
|
||||
.brand-slo-b{font-size:12px;letter-spacing:3px;color:rgba(255,255,255,.72);}
|
||||
.msg{font-size:12.5px;}
|
||||
|
||||
/* 状态条 */
|
||||
.strip-a{margin:2px 0 8px;padding:9px 10px;border:1px solid rgba(255,255,255,.16);
|
||||
background:rgba(255,255,255,.09);border-radius:10px;color:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.strip-b{margin:4px 0 8px;padding:12px;border:1px solid var(--hairline);
|
||||
background:var(--glass);border-radius:12px;color:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.st-copy{flex:1;min-width:0;}
|
||||
.st-title-a{font-size:12.5px;font-weight:600;}
|
||||
.st-title-b{font-size:14px;font-weight:600;line-height:1.25;}
|
||||
.st-desc{font-size:12px;color:rgba(255,255,255,.72);line-height:1.4;margin-top:2px;}
|
||||
.cta-a{flex:none;min-width:54px;padding:0 9px;height:24px;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:14px;background:var(--grad-smoke);font-size:12px;font-weight:600;}
|
||||
.cta-b{flex:none;min-width:58px;padding:0 12px;height:44px;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:999px;background:var(--grad-solid);font-size:13px;font-weight:600;}
|
||||
.checks{display:flex;gap:5px;overflow:hidden;}
|
||||
.check-a{padding:4px 6px;border-radius:10px;background:rgba(255,255,255,.08);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
|
||||
.check-b{padding:4px 7px;border-radius:999px;background:var(--glass);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
|
||||
.check-b.done{background:var(--glass-success);color:var(--success-on-night);font-weight:600;}
|
||||
|
||||
/* 横幅 */
|
||||
.banner-a{height:136px;border-radius:13px;overflow:hidden;position:relative;
|
||||
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
|
||||
.banner-b{height:150px;border-radius:16px;overflow:hidden;position:relative;
|
||||
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
|
||||
.banner-a .scrim,.banner-b .scrim{position:absolute;inset:0;}
|
||||
.banner-a .scrim{background:linear-gradient(90deg,rgba(25,22,86,.78) 0%,rgba(43,22,101,.25) 58%,rgba(255,79,145,.08) 100%);}
|
||||
.banner-b .scrim{background:var(--scrim);}
|
||||
.banner-copy{position:absolute;left:12px;width:68%;display:flex;flex-direction:column;color:#fff;}
|
||||
.banner-a .banner-copy{bottom:13px;}
|
||||
.banner-b .banner-copy{bottom:20px;}
|
||||
.kick{font-size:12px;letter-spacing:1.5px;color:rgba(255,255,255,.78);}
|
||||
.bt-a{margin-top:4px;font-size:17.5px;font-weight:700;line-height:1.2;text-shadow:0 1.5px 5px rgba(0,0,0,.2);}
|
||||
.bt-b{margin-top:5px;font-size:24px;font-weight:700;line-height:1.25;letter-spacing:.5px;text-shadow:0 2px 7px rgba(0,0,0,.22);}
|
||||
.bs-a,.bs-b{margin-top:4px;font-size:12px;color:rgba(255,255,255,.84);}
|
||||
.bs-b{font-size:13px;color:rgba(255,255,255,.72);margin-top:5px;}
|
||||
|
||||
/* 快捷入口 */
|
||||
.entries{position:relative;z-index:2;background:#fff;margin:-7px 12px 0;}
|
||||
.entries.a{border-radius:15px;padding:11px 5px 9px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.entries.b{border-radius:16px;padding:12px 4px 8px;box-shadow:var(--shadow-raised);}
|
||||
.egrid{display:flex;flex-wrap:wrap;row-gap:8px;}
|
||||
.eitem{width:25%;display:flex;flex-direction:column;align-items:center;justify-content:center;}
|
||||
.eitem.a{min-height:52px;} .eitem.b{min-height:58px;}
|
||||
.eicon{display:flex;align-items:center;justify-content:center;overflow:hidden;}
|
||||
.eicon.a{width:33px;height:33px;border-radius:10px;background:var(--primary-light);font-size:15px;}
|
||||
.eicon.b{width:36px;height:36px;border-radius:12px;background:var(--primary-light);font-size:16px;}
|
||||
.elabel{margin-top:4px;color:var(--text-strong);}
|
||||
.elabel.a{font-size:12px;font-weight:500;}
|
||||
.elabel.b{font-size:13px;font-weight:500;}
|
||||
|
||||
/* 区块标题 */
|
||||
.sec{padding:0 12px;}
|
||||
.sec.a{margin-top:16px;} .sec.b{margin-top:20px;}
|
||||
.shead{display:flex;align-items:center;justify-content:space-between;}
|
||||
.shead.a{padding:11px 12px 11px 0;} .shead.b{padding:8px 0;}
|
||||
.st-a{font-size:18px;font-weight:600;color:var(--text-primary);}
|
||||
.st-b{font-size:20px;font-weight:600;color:var(--text-strong);}
|
||||
.ssub{margin-left:8px;color:var(--text-tertiary);}
|
||||
.ssub.a{font-size:13px;} .ssub.b{font-size:12px;}
|
||||
.sact{display:flex;align-items:center;}
|
||||
.sact.a{font-size:13px;color:var(--primary);height:18px;}
|
||||
.sact.b{font-size:13px;color:var(--primary-strong);min-height:44px;padding-left:12px;font-weight:500;}
|
||||
.caret{font-size:11px;margin-left:2px;}
|
||||
|
||||
/* 卡片 */
|
||||
.card{background:#fff;display:flex;align-items:center;overflow:hidden;}
|
||||
.card.a{border-radius:13px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.card.b{border-radius:16px;box-shadow:var(--shadow-card);}
|
||||
.rec{display:flex;align-items:center;gap:8px;padding:7px 8px;}
|
||||
.rec .av{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#D9D3F0,#BFC8EE);flex:none;}
|
||||
.rec .info{flex:1;min-width:0;}
|
||||
.rec .nm-a{font-size:13px;font-weight:700;color:var(--text-strong);}
|
||||
.rec .nm-b{font-size:14px;font-weight:700;color:var(--text-strong);}
|
||||
.rec .meta{font-size:12px;color:var(--text-tertiary);margin-top:2px;}
|
||||
.rec .tags{display:flex;gap:4px;margin-top:4px;}
|
||||
.rec .tag{padding:2px 5px;border-radius:6px;background:var(--accent-purple-light);color:var(--accent-purple-strong);font-size:12px;}
|
||||
.heart-a{width:31px;height:31px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:14px;}
|
||||
.heart-b{width:44px;height:44px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:16px;}
|
||||
.divider{height:1px;background:var(--divider);}
|
||||
|
||||
/* 空态 */
|
||||
.empty{background:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.empty.a{border-radius:13px;padding:11px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.empty.b{border-radius:16px;padding:12px;box-shadow:var(--shadow-card);}
|
||||
.logo-s{width:38px;height:38px;border-radius:10px;background:var(--grad-smoke);flex:none;
|
||||
display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700;}
|
||||
.et-a{font-size:12.5px;font-weight:600;color:var(--text-strong);}
|
||||
.et-b{font-size:14px;font-weight:600;color:var(--text-strong);}
|
||||
.ed{font-size:12px;color:var(--text-tertiary);line-height:1.4;margin-top:2px;}
|
||||
.ed.b{font-size:13px;}
|
||||
.eacts{display:flex;flex-direction:column;gap:4px;}
|
||||
.ebtn{display:flex;align-items:center;justify-content:center;border-radius:12px;}
|
||||
.ebtn.a{min-width:56px;height:24px;padding:0 6px;font-size:12px;}
|
||||
.ebtn.b{min-width:60px;height:44px;padding:0 8px;font-size:13px;font-weight:500;border-radius:999px;}
|
||||
.ebtn.sec-a{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
|
||||
.ebtn.sec-b{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
|
||||
.ebtn.pri-a{color:#fff;background:var(--grad-smoke);}
|
||||
.ebtn.pri-b{color:#fff;background:var(--grad-solid);}
|
||||
.hidden-note{margin-top:8px;font-size:11.5px;color:var(--danger-strong);
|
||||
background:var(--danger-light);border-radius:8px;padding:6px 8px;}
|
||||
.fixed-note{margin-top:8px;font-size:11.5px;color:var(--success-strong);
|
||||
background:var(--success-light);border-radius:8px;padding:6px 8px;}
|
||||
|
||||
/* 注释气泡 */
|
||||
.callout{display:flex;gap:10px;align-items:flex-start;margin-top:14px;max-width:375px;}
|
||||
.callout .bullet{flex:none;width:20px;height:20px;border-radius:50%;background:var(--primary-solid);color:#fff;
|
||||
font-size:11.5px;display:flex;align-items:center;justify-content:center;font-weight:700;margin-top:1px;}
|
||||
.callout .txt{font-size:12.5px;color:var(--doc-mute);}
|
||||
.callout .txt b{color:var(--doc-ink);}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--divider);vertical-align:top;}
|
||||
th{color:var(--doc-mute);font-weight:600;font-size:12.5px;background:var(--bg-subtle);}
|
||||
td code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
|
||||
.bad{color:var(--danger-strong);font-weight:600;}
|
||||
.good{color:var(--success-strong);font-weight:600;}
|
||||
.sw{display:inline-flex;align-items:center;gap:7px;}
|
||||
.dot{width:14px;height:14px;border-radius:4px;border:1px solid rgba(0,0,0,.08);flex:none;}
|
||||
ul.clean{margin:8px 0 0;padding-left:18px;font-size:13.5px;color:var(--text-secondary);}
|
||||
ul.clean li{margin:5px 0;}
|
||||
ul.clean code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
|
||||
.gate{display:flex;flex-wrap:wrap;gap:10px;}
|
||||
.gate .g{flex:1 1 210px;border:1px solid #CFE9DC;background:var(--success-light);border-radius:11px;padding:12px 14px;}
|
||||
.gate .g .n{font-size:19px;font-weight:700;color:var(--success-strong);}
|
||||
.gate .g .l{font-size:12.5px;color:var(--text-secondary);margin-top:2px;}
|
||||
.warnbox{border:1px solid #F2E3C4;background:var(--warning-light);border-radius:11px;padding:14px 16px;font-size:13px;color:#6B4E12;}
|
||||
.warnbox b{color:var(--warning-strong);}
|
||||
footer{margin-top:34px;font-size:12.5px;color:var(--doc-mute);text-align:center;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>善缘丹阳 · 小程序设计系统 v2 改造对照</h1>
|
||||
<p class="sub">以首页为样板页,展示「设计底座落地 + 氛围层 + 排版层级 + 可访问性」四类改动的实际效果。</p>
|
||||
<div class="meta">
|
||||
<span class="chip">基线 <b>main @ 825fd41</b></span>
|
||||
<span class="chip">改动文件 <b>80</b></span>
|
||||
<span class="chip">wxss 覆盖 <b>88 / 88</b></span>
|
||||
<span class="chip">UI 契约门禁 <b>18 / 18 PASS</b></span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>一、量化指标</h2>
|
||||
<p class="lead">改动前的问题不是「不够好看」,而是设计底座没落地 —— 88 个样式文件里 78% 绕过了 token。</p>
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="k">页面层硬编码颜色</div>
|
||||
<div class="v flat">0<span class="from">770</span></div>
|
||||
<div class="d">仅 app.wxss 保留 56 个 token 定义色</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">不可读字号(<12px 非装饰)</div>
|
||||
<div class="v flat">0<span class="from">140</span></div>
|
||||
<div class="d">元信息下限统一到 24rpx(12px)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">非法字重(550/650/750)</div>
|
||||
<div class="v flat">0<span class="from">30</span></div>
|
||||
<div class="d">只保留 400/500/600/700 四档</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">未定义的 token 引用</div>
|
||||
<div class="v flat">0<span class="from">2</span></div>
|
||||
<div class="d">此前静默失效(背景/过渡丢失)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">未达标文字对比度</div>
|
||||
<div class="v flat">0<span class="from">41</span></div>
|
||||
<div class="d">3.10:1 → 5.33:1</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">白字压在浅渐变上的 CTA</div>
|
||||
<div class="v flat">0<span class="from">10</span></div>
|
||||
<div class="d">2.77:1 → 4.73:1</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">品牌色实心底 + 白字</div>
|
||||
<div class="v flat">0<span class="from">32</span></div>
|
||||
<div class="d">按钮/标签/徽标统一换 solid 变体</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">低于 44px 的触摸目标</div>
|
||||
<div class="v flat">0<span class="from">42</span></div>
|
||||
<div class="d">组件库按钮 40px、喜欢键 31px</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>二、首页对照</h2>
|
||||
<p class="lead">左侧是改造前(HEAD 版本的真实取值),右侧是改造后。两侧均用 app.wxss 里的真实 token 渲染,1rpx = 0.5px。</p>
|
||||
<div class="phones">
|
||||
|
||||
<!-- ══════════ 改造前 ══════════ -->
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag">改造前</span>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="night-a">
|
||||
<div class="statusbar">9:41</div>
|
||||
<div class="brand">
|
||||
<div class="brand-l">
|
||||
<div class="logo">善</div>
|
||||
<div style="margin-left:6px">
|
||||
<div class="brand-name-a">善缘丹阳</div>
|
||||
<div class="brand-slo-a">公益婚恋服务平台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg">消息</div>
|
||||
</div>
|
||||
<div class="strip-a">
|
||||
<div class="st-copy">
|
||||
<div class="st-title-a">完善资料,开启安心相遇</div>
|
||||
<div class="st-desc">补全 3 项信息即可进入推荐</div>
|
||||
</div>
|
||||
<div class="cta-a">去完善</div>
|
||||
</div>
|
||||
<div class="banner-a">
|
||||
<div class="scrim"></div>
|
||||
<div class="banner-copy">
|
||||
<div class="kick">公益 · 本地 · 真实 · 安全</div>
|
||||
<div class="bt-a">在丹阳,遇见更好的你</div>
|
||||
<div class="bs-a">认真认识,温暖相伴</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entries a">
|
||||
<div class="egrid">
|
||||
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
|
||||
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
|
||||
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
|
||||
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec a">
|
||||
<div class="shead a">
|
||||
<div><span class="st-a">今日推荐</span><span class="ssub a">为你认真筛选</span></div>
|
||||
<div class="sact a">查看更多<span class="caret">›</span></div>
|
||||
</div>
|
||||
<div class="card a">
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-a">林晓 · 28</div>
|
||||
<div class="meta">丹阳 · 教师 · 本科</div>
|
||||
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
|
||||
</div>
|
||||
<div class="heart-a">❤</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-a">周敏 · 30</div>
|
||||
<div class="meta">丹阳 · 医护 · 硕士</div>
|
||||
<div class="tags"><span class="tag">同城</span></div>
|
||||
</div>
|
||||
<div class="heart-a">❤</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="callout"><div class="bullet">1</div><div class="txt">
|
||||
<b>夜空只有一条平渐变</b>:参考图里的暖粉光晕、星点、玻璃质感全部缺失,头部是「一块紫布」。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">2</div><div class="txt">
|
||||
<b>层级读不出来</b>:hero 标题 17.5px、区块标题 18px、卡片标题 12.5px —— hero 与区块标题几乎同号。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">3</div><div class="txt">
|
||||
<b>触摸目标不足</b>:「去完善」按钮高 24px、「查看更多」18px,远低于 44px 无障碍下限。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">4</div><div class="txt">
|
||||
<b>对比度不达标</b>:CTA 白字压在珊瑚粉渐变上仅 <span class="bad">2.77:1</span>;文字链接 <code>#FF4F91</code> 仅 <span class="bad">3.10:1</span>。
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ 改造后 ══════════ -->
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag new">改造后</span>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="night-b">
|
||||
<div class="statusbar">9:41</div>
|
||||
<div class="brand">
|
||||
<div class="brand-l">
|
||||
<div class="logo b">善</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="brand-name-b">善缘丹阳</div>
|
||||
<div class="brand-slo-b">公益婚恋服务平台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg">消息</div>
|
||||
</div>
|
||||
<div class="strip-b">
|
||||
<div class="st-copy">
|
||||
<div class="st-title-b">完善资料,开启安心相遇</div>
|
||||
<div class="st-desc">补全 3 项信息即可进入推荐</div>
|
||||
</div>
|
||||
<div class="cta-b">去完善</div>
|
||||
</div>
|
||||
<div class="banner-b">
|
||||
<div class="scrim"></div>
|
||||
<div class="banner-copy">
|
||||
<div class="kick">公益 · 本地 · 真实 · 安全</div>
|
||||
<div class="bt-b">在丹阳,遇见更好的你</div>
|
||||
<div class="bs-b">认真认识,温暖相伴</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entries b">
|
||||
<div class="egrid">
|
||||
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
|
||||
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
|
||||
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
|
||||
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec b">
|
||||
<div class="shead b">
|
||||
<div><span class="st-b">今日推荐</span><span class="ssub b">为你认真筛选</span></div>
|
||||
<div class="sact b">查看更多<span class="caret">›</span></div>
|
||||
</div>
|
||||
<div class="card b">
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-b">林晓 · 28</div>
|
||||
<div class="meta">丹阳 · 教师 · 本科</div>
|
||||
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
|
||||
</div>
|
||||
<div class="heart-b">❤</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-b">周敏 · 30</div>
|
||||
<div class="meta">丹阳 · 医护 · 硕士</div>
|
||||
<div class="tags"><span class="tag">同城</span></div>
|
||||
</div>
|
||||
<div class="heart-b">❤</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="callout"><div class="bullet">1</div><div class="txt">
|
||||
<b>三层叠加做成氛围</b>:<code>--mini-starfield</code> 星点 + 夜空渐变 + 两个定位光晕(右上珊瑚粉、左下靛蓝)。全部用 CSS 渐变,零图片、零额外请求。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">2</div><div class="txt">
|
||||
<b>四级排版阶梯</b>:hero <b>24px/700</b> > 区块标题 <b>20px/600</b> > 卡片标题 <b>14px/600</b> > 元信息 12px。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">3</div><div class="txt">
|
||||
<b>触摸目标全部达标</b>:CTA 44px;区块标题右侧「查看更多」由 18px 提到 44px 热区,且热区向左补白不挤压标题。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">4</div><div class="txt">
|
||||
<b>对比度达标</b>:CTA 换 <code>--gradient-brand-solid</code> → <span class="good">4.73:1</span>;文字链接换 <code>--color-primary-strong</code> → <span class="good">5.33:1</span>。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">5</div><div class="txt">
|
||||
<b>标题与卡片对齐</b>:旧版标题比卡片多缩进 32rpx(.section 的 24rpx 与组件自带 32rpx 叠加),安全提醒卡还因 <code>padding:0</code> 顶到屏幕边缘。
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>三、小屏(≤340px)的行为差异</h2>
|
||||
<p class="lead">这是本轮改动里唯一一处「功能级」缺陷修复,值得单独看。</p>
|
||||
<div style="display:flex;gap:34px;flex-wrap:wrap;justify-content:center">
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag">改造前</span>
|
||||
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
|
||||
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 21px 21px"></div>
|
||||
<div class="entries a" style="margin-top:-7px">
|
||||
<div class="egrid">
|
||||
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
|
||||
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
|
||||
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
|
||||
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec a">
|
||||
<div class="empty a">
|
||||
<div class="logo-s">善</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="et-a">暂时没有完全符合的人</div>
|
||||
<div class="ed">我们坚持 Required 条件</div>
|
||||
</div>
|
||||
<div style="width:56px"></div>
|
||||
</div>
|
||||
<div class="hidden-note">@media (max-width:340px){ .empty-actions{display:none} } —— 两个操作入口整块消失</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag new">改造后</span>
|
||||
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
|
||||
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 24px 24px"></div>
|
||||
<div class="entries b" style="margin-top:-7px">
|
||||
<div class="egrid">
|
||||
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
|
||||
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
|
||||
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
|
||||
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec b">
|
||||
<div class="empty b">
|
||||
<div class="logo-s">善</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="et-b">暂时没有完全符合的人</div>
|
||||
<div class="ed b">我们坚持 Required 条件</div>
|
||||
</div>
|
||||
<div class="eacts">
|
||||
<div class="ebtn sec-b">调整条件</div>
|
||||
<div class="ebtn pri-b">公益红娘</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed-note">小屏只收紧尺寸与字号,不再砍掉任何入口</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>四、设计 token 变更要点</h2>
|
||||
<p class="lead">品牌色分级是这一版最重要的结构性决定:基础品牌色 <code>#E96B6B</code> 无论做背景还是做文字都不满足 4.5:1,因此拆成三个语义层。</p>
|
||||
<table>
|
||||
<thead><tr><th style="width:230px">Token</th><th style="width:130px">取值</th><th style="width:110px">对比度</th><th>用途限制</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#E96B6B"></span><code>--color-primary</code></span></td>
|
||||
<td><code>#E96B6B</code></td><td class="bad">3.10:1</td>
|
||||
<td>仅大色块、渐变、光晕、图标底。禁止承载文字。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#B8434A"></span><code>--color-primary-strong</code></span></td>
|
||||
<td><code>#B8434A</code></td><td class="good">5.33:1</td>
|
||||
<td>所有品牌色文字、小图标、纯文字链接。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#C24C50"></span><code>--color-primary-solid</code></span></td>
|
||||
<td><code>#C24C50</code></td><td class="good">4.73:1</td>
|
||||
<td>实心按钮底(配白字)、小徽标底色。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--gradient-brand</code></td>
|
||||
<td>#FF6A78 → #E96B6B</td><td class="bad">2.77:1</td>
|
||||
<td>明亮活泼,仅装饰填充,不得压白字。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--gradient-brand-solid</code></td>
|
||||
<td>#C24C50 → #B8434A</td><td class="good">4.73 ~ 5.33</td>
|
||||
<td>所有承载白字的 CTA 渐变(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--color-text-tertiary</code></td>
|
||||
<td>#777 → <code>#646C85</code></td><td class="good">5.22:1</td>
|
||||
<td>说明文字。旧值 4.48:1 卡在阈值下沿。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-glow-coral</code> / <code>--mini-glow-indigo</code></td>
|
||||
<td>rgba 粉 / 蓝</td><td class="flat">—</td>
|
||||
<td>夜空光晕层(本轮新增,参考图关键特征)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-starfield</code></td>
|
||||
<td>5 个径向渐变</td><td class="flat">—</td>
|
||||
<td>星点层(本轮新增),叠在夜空渐变之上。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-glass</code> / <code>--mini-hairline</code></td>
|
||||
<td>rgba(255,255,255,.10/.16)</td><td class="flat">—</td>
|
||||
<td>深底玻璃面与分隔线(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--tap-min</code> / <code>--tap-gap</code></td>
|
||||
<td>88rpx / 16rpx</td><td class="flat">44px</td>
|
||||
<td>触摸目标下限与最小间距(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ease-spring</code> / <code>--press-scale</code></td>
|
||||
<td>cubic-bezier(.34,1.4,.64,1) / 0.97</td><td class="flat">—</td>
|
||||
<td>按压回弹与缩放(本轮新增)。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>五、本轮修掉的实质缺陷</h2>
|
||||
<p class="lead">不只是样式替换 —— 其中三条是会被静默吞掉的语法级错误。</p>
|
||||
<ul class="clean">
|
||||
<li><b>3 处声明粘连(脚本副作用)</b>:<code>recommend-card</code>、<code>discover</code>、<code>service-station</code> 里出现
|
||||
<code>box-shadow:…rgba(255,79,145,.18)min-width:var(--tap-min);</code> 这类<b>缺少分号</b>的合并声明。
|
||||
浏览器会把整条声明判为非法并丢弃 —— 于是「喜欢」按钮的 44px 热区与阴影双双静默失效,
|
||||
而花括号平衡、hex 配额等所有静态检查仍然全绿。</li>
|
||||
<li><b>2 处引用了不存在的 token</b>:<code>--button-press-feedback</code>、<code>--color-bg-input</code> 此前从未定义,
|
||||
导致过渡动画与输入框背景直接失效(无报错、无告警)。</li>
|
||||
<li><b>小屏砍掉操作入口</b>:<code>@media (max-width:340px)</code> 里 <code>.empty-actions{display:none}</code>,
|
||||
在窄屏机型上把「调整条件 / 公益红娘」两个入口整块移除,属功能级缺陷。</li>
|
||||
<li><b>安全提醒卡错位</b>:旧版 <code>.safety-card{padding:0}</code> 覆盖了 <code>.section</code> 的左右内边距,
|
||||
该卡片比其它区块宽 48rpx、直接贴到屏幕边缘。</li>
|
||||
<li><b>触摸目标普遍不足</b>:组件库主按钮 40px、小号 28px、纯文字变体 <code>min-height:auto</code>、
|
||||
「喜欢」键 31px、区块标题右侧 action 18px —— 全站已统一到 44px 下限。</li>
|
||||
<li><b>对比度系统性不达标</b>:41 处把品牌基础色当文字用(3.10:1)、32 处把基础色做实心底压白字、
|
||||
10 处白字压在明亮渐变上(2.77:1)。已按「文字用 strong / 实心底用 solid / 渐变按是否压字分流」三类语义收敛。</li>
|
||||
<li><b>字重机型不一致</b>:30 处 550/650/750 在 Android 无对应字重会回退,同一设计在不同机型上粗细不一致。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>六、验证结果</h2>
|
||||
<p class="lead">以下为实际执行结果,非声明性描述。</p>
|
||||
<div class="gate">
|
||||
<div class="g"><div class="n">18 / 18</div><div class="l">UI 契约门禁全部通过(源码结构 + 关键行为断言)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">未定义的 token 引用(扫描 88 个 wxss / 131 个 token)</div></div>
|
||||
<div class="g"><div class="n">88 / 88</div><div class="l">wxss 文件通过声明级体检(无粘连、无空规则、括号平衡)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">app.wxss 之外的硬编码颜色(56 个 hex 全部是 token 定义)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">非百位字重 / 非装饰类不可读字号</div></div>
|
||||
<div class="g"><div class="n">88 / 88</div><div class="l">文件编码统一为 UTF-8 无 BOM(与仓库既有约定一致)</div></div>
|
||||
</div>
|
||||
<div class="warnbox" style="margin-top:16px">
|
||||
<b>尚未验证的部分(不作已验收)</b><br />
|
||||
· 本页是<b>静态还原</b>,用真实 token 值复刻布局,不是小程序真机截图;动态横幅轮播、真实数据、字号在真实设备上的渲染需要在小程序开发者工具里目视确认。<br />
|
||||
· 氛围层用到的 <code>background</code> 多层渐变与 <code>radial-gradient</code> 需要在真机(iOS / Android 各一台)复验,尤其确认低端机上不出现色带。<br />
|
||||
· 布局类改动(首页重做、标题热区加高、触摸目标统一为 44px)尚无自动化视觉回归,需要人工过一遍主要页面。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
善缘丹阳公益婚恋服务平台 · 小程序设计系统 v2 · 生成于 2026-09-22 · 基线 main @ 825fd41
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 538 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 134 KiB |