Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -79,15 +79,130 @@ 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
|
||||
|
||||
e2e:
|
||||
name: E2E (live stack)
|
||||
runs-on: ubuntu-latest
|
||||
# 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
|
||||
|
||||
- name: 起真实栈(postgres + redis + backend)
|
||||
working-directory: infrastructure
|
||||
run: docker compose up -d --build postgres redis backend
|
||||
|
||||
- name: 等待 backend 就绪(超时即失败,不静默跳过)
|
||||
run: |
|
||||
for i in $(seq 1 60); 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 未在 5 分钟内就绪"
|
||||
docker compose -f infrastructure/docker-compose.yml logs 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
|
||||
run: pytest -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
|
||||
|
||||
@@ -41,6 +41,7 @@ tests/fixtures/*.json
|
||||
# Local workspace and developer settings
|
||||
.worktrees/
|
||||
.zcode/
|
||||
.workbuddy/
|
||||
project.private.config.json
|
||||
/nul
|
||||
/infrastructure/nul
|
||||
@@ -54,3 +55,8 @@ docker-compose.prod.yml
|
||||
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 契约门禁 13/13 全绿。管理端桌面端目视复验仍待用户执行。
|
||||
|
||||
---
|
||||
|
||||
@@ -152,13 +154,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`
|
||||
|
||||
## 最高原则
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,7 +1,7 @@
|
||||
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
|
||||
|
||||
> 本文件为上线前必须完成的工作清单,按优先级排序。
|
||||
> 交由 Codex/zcode 执行。
|
||||
> 交由 WorkBuddy 执行。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# 丹阳公益婚恋服务平台
|
||||
# V1.0 Codex开发执行总指令、项目规则与任务拆解
|
||||
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
|
||||
|
||||
版本:V1.0
|
||||
文档级别:最高优先级开发执行文件
|
||||
适用对象:Codex、研发人员、测试人员、后续维护人员
|
||||
适用对象:WorkBuddy、研发人员、测试人员、后续维护人员
|
||||
执行原则:严格继承既有设计,不擅自扩展,不擅自删减,不擅自重新定义产品
|
||||
|
||||
---
|
||||
|
||||
# 第一章 文档定位
|
||||
|
||||
本文件用于指导Codex完整开发:
|
||||
本文件用于指导WorkBuddy完整开发:
|
||||
|
||||
> 丹阳公益婚恋服务平台 V1.0
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
任务拆解
|
||||
```
|
||||
|
||||
Codex在整个项目开发周期内,应将本文件视为:
|
||||
WorkBuddy在整个项目开发周期内,应将本文件视为:
|
||||
|
||||
> **项目最高执行规则。**
|
||||
|
||||
@@ -114,7 +114,7 @@ Codex在整个项目开发周期内,应将本文件视为:
|
||||
|
||||
# 第五章 明确禁止偏离产品方向
|
||||
|
||||
Codex不得将平台开发成:
|
||||
WorkBuddy不得将平台开发成:
|
||||
|
||||
```text
|
||||
商业婚恋VIP平台
|
||||
@@ -382,7 +382,7 @@ Preferred:
|
||||
|
||||
> 可参与评分。
|
||||
|
||||
Codex不得把Required自动降级。
|
||||
WorkBuddy不得把Required自动降级。
|
||||
|
||||
---
|
||||
|
||||
@@ -1308,7 +1308,7 @@ Node.js + NestJS
|
||||
|
||||
尚未锁死。
|
||||
|
||||
Codex如果当前仓库没有既有技术栈:
|
||||
WorkBuddy如果当前仓库没有既有技术栈:
|
||||
|
||||
优先建议:
|
||||
|
||||
@@ -1632,7 +1632,7 @@ Match active pair
|
||||
- 历史记录
|
||||
- Active Pair Unique
|
||||
|
||||
具体表结构由Codex合理实现。
|
||||
具体表结构由WorkBuddy合理实现。
|
||||
|
||||
---
|
||||
|
||||
@@ -1665,7 +1665,7 @@ closed
|
||||
|
||||
属于建议技术模型。
|
||||
|
||||
如果Codex优化命名:
|
||||
如果WorkBuddy优化命名:
|
||||
|
||||
> 语义必须一致。
|
||||
|
||||
@@ -2050,9 +2050,9 @@ UI细节
|
||||
|
||||
---
|
||||
|
||||
# 第一百二十章 Codex开发模式
|
||||
# 第一百二十章 WorkBuddy开发模式
|
||||
|
||||
Codex必须采用:
|
||||
WorkBuddy必须采用:
|
||||
|
||||
> 分阶段、可运行、可验证。
|
||||
|
||||
@@ -2090,7 +2090,7 @@ Codex必须采用:
|
||||
|
||||
# 第一百二十二章 不允许静默改变需求
|
||||
|
||||
如果Codex发现:
|
||||
如果WorkBuddy发现:
|
||||
|
||||
- 规则冲突
|
||||
- 前文未确认
|
||||
@@ -2260,7 +2260,7 @@ ADR-003-object-storage.md
|
||||
|
||||
---
|
||||
|
||||
# 第一百三十一章 Codex项目记忆文件
|
||||
# 第一百三十一章 WorkBuddy项目记忆文件
|
||||
|
||||
建议仓库根目录建立:
|
||||
|
||||
@@ -2270,7 +2270,7 @@ AGENTS.md
|
||||
|
||||
用于放:
|
||||
|
||||
> Codex执行规则摘要。
|
||||
> WorkBuddy执行规则摘要。
|
||||
|
||||
---
|
||||
|
||||
@@ -2311,7 +2311,7 @@ docs/STATUS.md
|
||||
待确认事项
|
||||
```
|
||||
|
||||
Codex每阶段更新。
|
||||
WorkBuddy每阶段更新。
|
||||
|
||||
---
|
||||
|
||||
@@ -2994,7 +2994,7 @@ MFA强化
|
||||
|
||||
# 第一百八十章 V1明确不做列表
|
||||
|
||||
Codex遇到以下需求不得自行开发:
|
||||
WorkBuddy遇到以下需求不得自行开发:
|
||||
|
||||
```text
|
||||
直播
|
||||
@@ -3203,7 +3203,7 @@ UI走:
|
||||
|
||||
# 第一百九十二章 核心Invariant
|
||||
|
||||
Codex必须在代码、测试中明确保护以下Invariant:
|
||||
WorkBuddy必须在代码、测试中明确保护以下Invariant:
|
||||
|
||||
```text
|
||||
未Mutual不可聊天
|
||||
@@ -3316,7 +3316,7 @@ Operator
|
||||
|
||||
每完成模块:
|
||||
|
||||
Codex必须运行:
|
||||
WorkBuddy必须运行:
|
||||
|
||||
> 相关测试。
|
||||
|
||||
@@ -3458,7 +3458,7 @@ SQLSTATE
|
||||
|
||||
---
|
||||
|
||||
# 第二百零九章 Codex每阶段输出要求
|
||||
# 第二百零九章 WorkBuddy每阶段输出要求
|
||||
|
||||
每完成一个阶段:
|
||||
|
||||
@@ -3533,7 +3533,7 @@ Appeal是否V1首发
|
||||
|
||||
---
|
||||
|
||||
# 第二百一十三章 Codex不得把待确认项硬编码
|
||||
# 第二百一十三章 WorkBuddy不得把待确认项硬编码
|
||||
|
||||
必须:
|
||||
|
||||
@@ -3795,7 +3795,7 @@ V1不需要大量SDK。
|
||||
|
||||
# 第二百三十三章 License
|
||||
|
||||
Codex新增开源库时:
|
||||
WorkBuddy新增开源库时:
|
||||
|
||||
必须确认License。
|
||||
|
||||
@@ -3935,7 +3935,7 @@ is_test_account
|
||||
|
||||
# 第二百四十四章 小程序发布前
|
||||
|
||||
Codex必须准备:
|
||||
WorkBuddy必须准备:
|
||||
|
||||
```text
|
||||
微信合法域名清单
|
||||
@@ -4017,7 +4017,7 @@ agreed_at
|
||||
|
||||
# 第二百五十章 删除/注销
|
||||
|
||||
Codex必须设计:
|
||||
WorkBuddy必须设计:
|
||||
|
||||
> 正式后端流程。
|
||||
|
||||
@@ -4119,7 +4119,7 @@ V1至少保证:
|
||||
|
||||
---
|
||||
|
||||
# 第二百五十九章 Codex不得擅自加入“内部用户价值评分”
|
||||
# 第二百五十九章 WorkBuddy不得擅自加入“内部用户价值评分”
|
||||
|
||||
禁止:
|
||||
|
||||
@@ -4341,7 +4341,7 @@ Mutual
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十二章 Codex阶段验收机制
|
||||
# 第二百七十二章 WorkBuddy阶段验收机制
|
||||
|
||||
每完成Phase:
|
||||
|
||||
@@ -4357,7 +4357,7 @@ Mutual
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十三章 Codex禁止声明完成的情况
|
||||
# 第二百七十三章 WorkBuddy禁止声明完成的情况
|
||||
|
||||
如果:
|
||||
|
||||
@@ -4449,7 +4449,7 @@ Phase 26 Production
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十八章 Codex每次启动工作时
|
||||
# 第二百七十八章 WorkBuddy每次启动工作时
|
||||
|
||||
必须先阅读:
|
||||
|
||||
@@ -4464,7 +4464,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
---
|
||||
|
||||
# 第二百七十九章 Codex发现旧代码与文档冲突
|
||||
# 第二百七十九章 WorkBuddy发现旧代码与文档冲突
|
||||
|
||||
优先级:
|
||||
|
||||
@@ -4506,7 +4506,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
# 第二百八十一章 已知需要纠正的旧示例
|
||||
|
||||
Codex特别注意:
|
||||
WorkBuddy特别注意:
|
||||
|
||||
以下不是固定产品要求:
|
||||
|
||||
@@ -4573,7 +4573,7 @@ Codex特别注意:
|
||||
线下闭环
|
||||
```
|
||||
|
||||
Codex投入重点必须放在这些地方。
|
||||
WorkBuddy投入重点必须放在这些地方。
|
||||
|
||||
---
|
||||
|
||||
@@ -4744,7 +4744,7 @@ Rollback Pass
|
||||
|
||||
# 第二百九十六章 项目最终交付物
|
||||
|
||||
Codex必须至少交付:
|
||||
WorkBuddy必须至少交付:
|
||||
|
||||
```text
|
||||
完整源码
|
||||
@@ -4840,7 +4840,7 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零一章 Codex最终执行宣言
|
||||
# 第三百零一章 WorkBuddy最终执行宣言
|
||||
|
||||
在整个开发过程中必须始终遵守:
|
||||
|
||||
@@ -4876,7 +4876,7 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零二章 Codex项目最终原则
|
||||
# 第三百零二章 WorkBuddy项目最终原则
|
||||
|
||||
整个项目只围绕一件事情设计:
|
||||
|
||||
@@ -4910,9 +4910,9 @@ Tests
|
||||
|
||||
---
|
||||
|
||||
# 第三百零三章 Codex收到本文档后的第一条执行指令
|
||||
# 第三百零三章 WorkBuddy收到本文档后的第一条执行指令
|
||||
|
||||
Codex不得立即开始无序编码。
|
||||
WorkBuddy不得立即开始无序编码。
|
||||
|
||||
首先执行:
|
||||
|
||||
@@ -4947,7 +4947,7 @@ Codex不得立即开始无序编码。
|
||||
|
||||
---
|
||||
|
||||
# 第三百零四章 Codex首次仓库审计必须输出
|
||||
# 第三百零四章 WorkBuddy首次仓库审计必须输出
|
||||
|
||||
```text
|
||||
当前技术栈
|
||||
@@ -4994,7 +4994,7 @@ Pending Decisions
|
||||
Test Result
|
||||
```
|
||||
|
||||
保证下一次Codex恢复上下文时:
|
||||
保证下一次WorkBuddy恢复上下文时:
|
||||
|
||||
> 可以从项目状态继续,而不是重新理解整个项目。
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# 丹阳公益婚恋服务平台
|
||||
# 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 +631,7 @@ Step 8
|
||||
|
||||
---
|
||||
|
||||
# 十三、Codex每次开发前
|
||||
# 十三、WorkBuddy每次开发前
|
||||
|
||||
必须读取:
|
||||
|
||||
@@ -646,7 +646,7 @@ docs/PENDING_DECISIONS.md
|
||||
|
||||
---
|
||||
|
||||
# 十四、Codex每次完成任务后
|
||||
# 十四、WorkBuddy每次完成任务后
|
||||
|
||||
必须更新:
|
||||
|
||||
@@ -2134,7 +2134,7 @@ INV-010 跨Organization不能越权
|
||||
|
||||
---
|
||||
|
||||
# 第五部分:Codex每阶段输出格式
|
||||
# 第五部分:WorkBuddy每阶段输出格式
|
||||
|
||||
每个Phase完成以后必须输出以下报告。
|
||||
|
||||
@@ -2192,9 +2192,9 @@ Phase XX+1
|
||||
|
||||
---
|
||||
|
||||
# 第六部分:Codex首轮实际执行任务
|
||||
# 第六部分:WorkBuddy首轮实际执行任务
|
||||
|
||||
首次把本文档交给Codex后,不要让它直接执行26个Phase。
|
||||
首次把本文档交给WorkBuddy后,不要让它直接执行26个Phase。
|
||||
|
||||
第一轮只执行以下任务。
|
||||
|
||||
@@ -2319,7 +2319,7 @@ Status
|
||||
|
||||
# 三十一、首轮任务06:Phase 01详细Task Breakdown
|
||||
|
||||
Codex把P01继续拆成:
|
||||
WorkBuddy把P01继续拆成:
|
||||
|
||||
> 0.5~1天左右粒度的工程任务。
|
||||
|
||||
@@ -2704,7 +2704,7 @@ Jira
|
||||
|
||||
# 第十二部分:最终执行要求
|
||||
|
||||
Codex必须将整个项目理解为:
|
||||
WorkBuddy必须将整个项目理解为:
|
||||
|
||||
> **一个长期可运营的公益婚恋服务系统。**
|
||||
|
||||
@@ -2744,7 +2744,7 @@ Codex必须将整个项目理解为:
|
||||
|
||||
---
|
||||
|
||||
# 第十三部分:给Codex的首轮最终指令
|
||||
# 第十三部分:给WorkBuddy的首轮最终指令
|
||||
|
||||
现在开始执行本项目。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env*
|
||||
pnpm-lock.yaml
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -1,68 +1,742 @@
|
||||
<template>
|
||||
<el-container class="main-layout">
|
||||
<el-aside :width="isCollapse ? '72px' : '220px'" class="sidebar">
|
||||
<div class="brand" :class="{ compact: isCollapse }">
|
||||
<div class="brand-symbol">缘</div>
|
||||
<div v-if="!isCollapse" class="brand-copy"><strong>善缘丹阳</strong><span>公益婚恋运营平台</span></div>
|
||||
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
|
||||
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
|
||||
<div class="scene-brand">
|
||||
<img src="/brand/logo-mark.png" alt="善缘丹阳" />
|
||||
<div class="scene-brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
<div class="scene-title">
|
||||
<h1>管理端(运营后台)</h1>
|
||||
<p>用专业的服务,帮助更多人遇见对的人</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-scrollbar class="menu-scroll">
|
||||
<el-menu :default-active="activeMenu" :collapse="isCollapse" :router="true" class="sidebar-menu">
|
||||
<el-menu-item index="/dashboard"><el-icon><DataBoard /></el-icon><template #title>工作台</template></el-menu-item>
|
||||
<el-menu-item-group v-if="showGroup(['review.view','user.view'])" title="资料与用户">
|
||||
<el-menu-item v-if="hasPerm('review.view')" index="/review"><el-icon><DocumentChecked /></el-icon><template #title>资料审核</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('user.view')" index="/users"><el-icon><User /></el-icon><template #title>用户管理</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['counseling.service.view','service_program.manage','referral.manage'])" title="婚恋服务">
|
||||
<el-menu-item v-if="hasPerm('counseling.service.view')" index="/counseling"><el-icon><ChatDotRound /></el-icon><template #title>婚恋咨询</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('service_program.manage')" index="/service-program"><el-icon><Service /></el-icon><template #title>专项公益服务</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('referral.manage')" index="/referral"><el-icon><Connection /></el-icon><template #title>服务转介</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['activity.review_registration'])" title="活动运营">
|
||||
<el-menu-item index="/activities/interests"><el-icon><Calendar /></el-icon><template #title>活动与互选</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['banner.view','content.view'])" title="内容运营">
|
||||
<el-menu-item v-if="hasPerm('banner.view')" index="/banner"><el-icon><Promotion /></el-icon><template #title>首页 Banner</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('content.view')" index="/content"><el-icon><Reading /></el-icon><template #title>课堂与反诈内容</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['service_location.view','service_appointment.view'])" title="服务阵地">
|
||||
<el-menu-item v-if="hasPerm('service_location.view')" index="/service-station"><el-icon><Location /></el-icon><template #title>善缘服务站</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('service_appointment.view')" index="/service-appointment"><el-icon><Memo /></el-icon><template #title>服务预约</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['risk.report.view','risk.report.process'])" title="安全治理">
|
||||
<el-menu-item v-if="hasPerm('risk.report.view')" index="/risk/reports"><el-icon><Warning /></el-icon><template #title>举报中心</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('risk.report.process')" index="/appeals"><el-icon><CircleCheck /></el-icon><template #title>申诉管理</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="showGroup(['organization.manage','permission.manage','user.view'])" title="组织与系统">
|
||||
<el-menu-item v-if="hasPerm('organization.manage')" index="/organizations"><el-icon><OfficeBuilding /></el-icon><template #title>组织管理</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('permission.manage')" index="/operators"><el-icon><Setting /></el-icon><template #title>工作人员</template></el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('user.view')" index="/audit"><el-icon><Document /></el-icon><template #title>操作日志</template></el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item v-if="hasPerm('analytics.view')" index="/analytics"><el-icon><TrendCharts /></el-icon><template #title>数据统计</template></el-menu-item>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<button class="collapse-control" type="button" @click="isCollapse=!isCollapse"><el-icon><Fold v-if="!isCollapse"/><Expand v-else/></el-icon><span v-if="!isCollapse">收起导航</span></button>
|
||||
</el-aside>
|
||||
<el-container class="workspace">
|
||||
<el-header class="header">
|
||||
<div><div class="context-kicker">善缘丹阳 · 公益服务</div><el-breadcrumb separator="/"><el-breadcrumb-item :to="{path:'/dashboard'}">工作台</el-breadcrumb-item><el-breadcrumb-item v-if="currentTitle!=='工作台'">{{currentTitle}}</el-breadcrumb-item></el-breadcrumb></div>
|
||||
<div class="header-right"><div class="service-state"><i></i>服务正常</div><el-dropdown><span class="user-info"><el-avatar :size="36">{{(operator?.name||'工')[0]}}</el-avatar><span><strong>{{operator?.name||'工作人员'}}</strong><small>运营人员</small></span><el-icon><ArrowDown/></el-icon></span><template #dropdown><el-dropdown-menu><el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div>
|
||||
</el-header>
|
||||
<el-main class="main-content"><router-view v-slot="{Component}"><keep-alive><component :is="Component"/></keep-alive></router-view></el-main>
|
||||
<div class="scene-slogan" aria-hidden="true">
|
||||
<span>善缘相牵</span>
|
||||
<strong>爱在丹阳</strong>
|
||||
</div>
|
||||
<div class="scene-benefits" aria-label="平台服务原则">
|
||||
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
|
||||
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
|
||||
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-container class="admin-shell">
|
||||
<el-aside :width="isCollapse ? '76px' : '208px'" class="admin-sidebar">
|
||||
<div class="brand" :class="{ compact: isCollapse }">
|
||||
<img class="brand-mark" src="/brand/logo-mark.png" alt="" />
|
||||
<div v-if="!isCollapse" class="brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="menu-scroll">
|
||||
<nav aria-label="管理后台主导航">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:router="true"
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="item in visibleNavigation"
|
||||
:key="item.label"
|
||||
:index="item.path"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<template #title>{{ item.label }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</nav>
|
||||
</el-scrollbar>
|
||||
|
||||
<div v-if="!isCollapse" class="sidebar-motto">
|
||||
<el-icon><StarFilled /></el-icon>
|
||||
<span>汇聚善意<br />成就良缘</span>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-control"
|
||||
type="button"
|
||||
:aria-label="isCollapse ? '展开导航' : '收起导航'"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
|
||||
<span v-if="!isCollapse">收起导航</span>
|
||||
</button>
|
||||
</el-aside>
|
||||
|
||||
<el-container class="workspace">
|
||||
<el-header class="admin-topbar">
|
||||
<div class="page-context">
|
||||
<button class="mobile-collapse" type="button" aria-label="切换导航" @click="isCollapse = !isCollapse">
|
||||
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
|
||||
</button>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/dashboard' }">工作台</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="currentTitle !== '工作台'">{{ currentTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-tools">
|
||||
<el-input
|
||||
v-model="globalSearch"
|
||||
class="global-search"
|
||||
clearable
|
||||
aria-label="搜索后台页面"
|
||||
placeholder="搜索用户、活动、内容…"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<button class="notification-button" type="button" aria-label="查看系统通知" @click="showNotifications">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span class="notification-badge" aria-hidden="true">{{ notificationCount }}</span>
|
||||
</button>
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-info">
|
||||
<el-avatar :size="34">{{ (operator?.name || '工')[0] }}</el-avatar>
|
||||
<strong>{{ operator?.name || '工作人员' }}</strong>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>{{ currentRoleName }}</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="admin-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive><component :is="Component" /></keep-alive>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<footer class="scene-footer">
|
||||
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<small>真实 · 真诚 · 公益 · 幸福</small>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref,computed} from 'vue'
|
||||
import {useRoute,useRouter} from 'vue-router'
|
||||
import {DataBoard,DocumentChecked,User,OfficeBuilding,Setting,Calendar,Warning,ChatDotRound,TrendCharts,Document,Service,Location,Memo,Promotion,Reading,Connection,CircleCheck,Fold,Expand,ArrowDown} from '@element-plus/icons-vue'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
const route=useRoute();const router=useRouter();const authStore=useAuthStore();const isCollapse=ref(false)
|
||||
const activeMenu=computed(()=>route.path);const currentTitle=computed(()=>(route.meta?.title as string)||'页面');const operator=computed(()=>authStore.operator)
|
||||
function hasPerm(permission:string){return (authStore.permissions||[]).includes(permission)}
|
||||
function showGroup(perms:string[]){return perms.some(hasPerm)}
|
||||
function handleLogout(){authStore.logout();router.push('/login')}
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowDown,
|
||||
Bell,
|
||||
Calendar,
|
||||
ChatDotRound,
|
||||
Clock,
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
DataBoard,
|
||||
DocumentChecked,
|
||||
Expand,
|
||||
Fold,
|
||||
House,
|
||||
Location,
|
||||
LocationFilled,
|
||||
Lock,
|
||||
Memo,
|
||||
Opportunity,
|
||||
Picture,
|
||||
Reading,
|
||||
Search,
|
||||
Setting,
|
||||
StarFilled,
|
||||
Tickets,
|
||||
User,
|
||||
UserFilled,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface NavigationItem {
|
||||
label: string
|
||||
path: string
|
||||
permissions: string[]
|
||||
icon: unknown
|
||||
}
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ label: '工作台', path: '/dashboard', permissions: [], icon: DataBoard },
|
||||
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
||||
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
||||
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
||||
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
|
||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
|
||||
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
||||
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
||||
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
||||
{ label: 'Banner管理', path: '/banner', permissions: ['banner.view'], icon: Picture },
|
||||
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
|
||||
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
|
||||
{ label: '服务预约', path: '/service-appointment', permissions: ['service_appointment.view'], icon: House },
|
||||
{ label: '专项服务', path: '/service-program', permissions: ['service_program.manage'], icon: Tickets },
|
||||
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
|
||||
{ label: '申诉管理', path: '/appeals', permissions: ['risk.report.process'], icon: Memo },
|
||||
{ label: '数据统计', path: '/analytics', permissions: ['analytics.view'], icon: DataAnalysis },
|
||||
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
||||
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
||||
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const isCollapse = ref(false)
|
||||
const globalSearch = ref('')
|
||||
const notificationCount = ref(0)
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path === '/users/detail') return '/users'
|
||||
return route.path
|
||||
})
|
||||
const currentTitle = computed(() => (route.meta?.title as string) || '页面')
|
||||
const operator = computed(() => authStore.operator)
|
||||
const permissions = computed(() => authStore.permissions || [])
|
||||
const currentRoleName = computed(() => {
|
||||
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
||||
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
||||
const perms = permissions.value
|
||||
if (perms.includes('permission.manage')) return '平台管理员'
|
||||
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
||||
if (perms.includes('content.publish')) return '内容编辑员'
|
||||
if (perms.includes('risk.report.process')) return '风控运营人员'
|
||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||
if (perms.includes('review.view')) return '资料审核人员'
|
||||
if (perms.includes('matchmaker.service.view')) return '公益红娘'
|
||||
return '运营人员'
|
||||
})
|
||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
||||
|
||||
function hasPerm(permission: string) {
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
const query = globalSearch.value.trim()
|
||||
if (!query) {
|
||||
ElMessage.info('请输入要查找的页面名称')
|
||||
return
|
||||
}
|
||||
const destination = router.getRoutes().find((item) => {
|
||||
const title = String(item.meta?.title || '')
|
||||
const required = (item.meta?.permissions as string[] | undefined) || []
|
||||
return item.path !== '/login' && title.includes(query) && required.every(hasPerm)
|
||||
})
|
||||
if (!destination) {
|
||||
ElMessage.info('没有找到可访问的相关页面')
|
||||
return
|
||||
}
|
||||
router.push(destination.path)
|
||||
globalSearch.value = ''
|
||||
}
|
||||
|
||||
function showNotifications() {
|
||||
ElMessage.info(notificationCount.value ? `您有 ${notificationCount.value} 条待处理通知` : '当前没有新的系统通知')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-layout{height:100vh;width:100%}.sidebar{display:flex;flex-direction:column;background:#FFFCFA;border-right:1px solid var(--color-border-light);transition:width .2s ease;overflow:hidden}.brand{height:92px;padding:20px 18px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--color-border-light)}.brand.compact{justify-content:center;padding:18px 10px}.brand-symbol{width:42px;height:42px;flex:none;display:grid;place-items:center;border-radius:14px;background:linear-gradient(145deg,var(--color-primary),#EF8A7B);color:#fff;font-size:19px;font-weight:700;box-shadow:0 8px 18px rgba(233,107,107,.2)}.brand-copy{display:flex;flex-direction:column;white-space:nowrap}.brand-copy strong{font-size:17px;letter-spacing:1px}.brand-copy span{margin-top:3px;font-size:11px;color:var(--color-text-tertiary)}.menu-scroll{flex:1}.sidebar-menu{border:0;background:transparent;padding:12px 10px}.sidebar-menu :deep(.el-menu-item){height:44px;margin:3px 0;border-radius:10px;color:var(--color-text-secondary)}.sidebar-menu :deep(.el-menu-item:hover){background:var(--color-primary-lighter);color:var(--color-primary)}.sidebar-menu :deep(.el-menu-item.is-active){background:var(--color-primary-light);color:var(--color-primary);font-weight:600}.sidebar-menu :deep(.el-menu-item-group__title){padding:18px 12px 5px!important;color:#AAA09A;font-size:11px;letter-spacing:1px}.collapse-control{height:52px;margin:10px;border:0;border-top:1px solid var(--color-border-light);background:transparent;color:var(--color-text-tertiary);display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer}.workspace{min-width:0}.header{height:var(--header-height);padding:0 24px;background:rgba(255,255,255,.94);border-bottom:1px solid var(--color-border-light);display:flex;align-items:center;justify-content:space-between}.context-kicker{font-size:10px;color:var(--color-primary);margin-bottom:4px;letter-spacing:1px}.header-right,.user-info{display:flex;align-items:center}.header-right{gap:24px}.service-state{font-size:12px;color:var(--color-success)}.service-state i{display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:50%;background:var(--color-success)}.user-info{gap:9px;cursor:pointer}.user-info .el-avatar{background:var(--color-primary);color:#fff}.user-info span{display:flex;flex-direction:column}.user-info strong{font-size:13px}.user-info small{font-size:11px;color:var(--color-text-tertiary);margin-top:2px}.main-content{background:var(--color-bg-page);padding:0;overflow:auto}
|
||||
@media(max-width:900px){.sidebar{width:72px!important}.brand-copy,.collapse-control span,.sidebar-menu :deep(.el-menu-item-group__title){display:none}.brand{justify-content:center}.header{padding:0 16px}.service-state{display:none}}
|
||||
</style>
|
||||
.admin-scene {
|
||||
min-width: 1100px;
|
||||
min-height: 100vh;
|
||||
padding: 18px 34px 14px;
|
||||
overflow: hidden;
|
||||
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
max-width: 1840px;
|
||||
height: 188px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(640px, 1fr) auto;
|
||||
grid-template-rows: 112px 76px;
|
||||
align-items: center;
|
||||
column-gap: 32px;
|
||||
}
|
||||
|
||||
.scene-brand,
|
||||
.scene-benefits,
|
||||
.scene-benefits span,
|
||||
.scene-brand-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
display: flex;
|
||||
padding-right: 24px;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
color: var(--admin-coral);
|
||||
font-family: KaiTi, STKaiti, serif;
|
||||
line-height: 1.05;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
margin-top: 4px;
|
||||
font-size: 25px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scene-brand-copy {
|
||||
min-width: 172px;
|
||||
margin-left: 10px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
color: var(--admin-coral);
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: 27px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.scene-brand-copy span {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
min-width: 370px;
|
||||
margin-left: 28px;
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid rgba(244, 95, 104, 0.22);
|
||||
}
|
||||
|
||||
.scene-title h1 {
|
||||
font-size: clamp(26px, 2.2vw, 38px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.scene-title p {
|
||||
margin-top: 9px;
|
||||
color: #66717a;
|
||||
}
|
||||
|
||||
.scene-benefits {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(38px, 5vw, 92px);
|
||||
padding: 4px 0 0 50px;
|
||||
}
|
||||
|
||||
.scene-benefits span {
|
||||
display: grid;
|
||||
grid-template-columns: 36px auto;
|
||||
column-gap: 8px;
|
||||
color: var(--admin-coral);
|
||||
}
|
||||
|
||||
.scene-benefits .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.scene-benefits b {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scene-benefits small {
|
||||
margin-top: 2px;
|
||||
color: #7b7f86;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1840px, 100%);
|
||||
height: calc(100vh - 238px);
|
||||
min-height: 560px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: var(--admin-shell-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--admin-shell-radius);
|
||||
box-shadow: var(--admin-shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--admin-sidebar-text);
|
||||
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
height: 82px;
|
||||
padding: 17px 16px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.brand.compact {
|
||||
justify-content: center;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
margin-top: 4px;
|
||||
color: #b5c0c8;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.menu-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
padding: 13px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item) {
|
||||
height: 41px;
|
||||
margin: 2px 0;
|
||||
color: var(--admin-sidebar-text);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||
color: #fff;
|
||||
background: var(--admin-sidebar-surface);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||
color: #fff;
|
||||
font-weight: 650;
|
||||
background: linear-gradient(135deg, #f45f68, #ee727b);
|
||||
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-icon) {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sidebar-motto {
|
||||
display: flex;
|
||||
margin: 8px 15px 2px;
|
||||
padding: 14px 12px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
color: #ff969c;
|
||||
font-family: KaiTi, STKaiti, serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-motto .el-icon {
|
||||
font-size: 31px;
|
||||
}
|
||||
|
||||
.collapse-control {
|
||||
display: flex;
|
||||
height: 42px;
|
||||
margin: 2px 10px 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
color: var(--admin-sidebar-muted);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.collapse-control:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid #edf0f2;
|
||||
}
|
||||
|
||||
.page-context,
|
||||
.header-tools,
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-context {
|
||||
min-width: 180px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-collapse {
|
||||
display: none;
|
||||
color: #56616a;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.global-search {
|
||||
width: clamp(220px, 22vw, 320px);
|
||||
}
|
||||
|
||||
.global-search :deep(.el-input__wrapper) {
|
||||
background: #f6f7f8;
|
||||
border-radius: 9px;
|
||||
box-shadow: inset 0 0 0 1px #e8eaed;
|
||||
}
|
||||
|
||||
.notification-button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #4f5b65;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.notification-button:hover {
|
||||
background: #f6f7f8;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
background: var(--admin-coral);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info .el-avatar {
|
||||
color: #fff;
|
||||
background: var(--admin-coral);
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
background: var(--admin-page-bg);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
display: flex;
|
||||
max-width: 1840px;
|
||||
height: 32px;
|
||||
margin: 0 auto;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
color: #8d8587;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.scene-footer strong {
|
||||
color: var(--admin-coral);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scene-footer small {
|
||||
margin-left: auto;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px), (max-height: 820px) {
|
||||
.admin-scene {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
height: 120px;
|
||||
grid-template-columns: minmax(520px, 1fr) auto;
|
||||
grid-template-rows: 66px 54px;
|
||||
column-gap: 18px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
min-width: 310px;
|
||||
margin-left: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.scene-title h1 {
|
||||
font-size: 27px;
|
||||
}
|
||||
|
||||
.scene-benefits {
|
||||
padding: 0 0 0 28px;
|
||||
gap: clamp(22px, 4vw, 54px);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.scene-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-collapse {
|
||||
display: inline-grid;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 76px !important;
|
||||
}
|
||||
|
||||
.brand-copy,
|
||||
.collapse-control span,
|
||||
.sidebar-motto {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-scene {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scene-header,
|
||||
.scene-footer,
|
||||
.global-search,
|
||||
.user-info strong {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<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="manualUserId" placeholder="输入用户 ID" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
||||
@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="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</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">
|
||||
/**
|
||||
* 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 manualUserId = 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 ${localStorage.getItem('accessToken') || ''}` })
|
||||
|
||||
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 || !manualUserId.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserId.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
||||
manualUserId.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: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.opt-type {
|
||||
float: right;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<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="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="organizationId" label="组织ID" min-width="160" />
|
||||
<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">
|
||||
/**
|
||||
* 活动管理(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 ${localStorage.getItem('accessToken')}` }
|
||||
}
|
||||
|
||||
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: #8a90a2;
|
||||
font-size: 12px;
|
||||
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>
|
||||
@@ -84,13 +224,14 @@ 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' }
|
||||
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
|
||||
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
|
||||
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
|
||||
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
|
||||
]
|
||||
|
||||
const matchStages = [
|
||||
@@ -113,21 +254,27 @@ async function loadFunnel() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
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}` }),
|
||||
get('/operator/analytics/match-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` })
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
|
||||
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
|
||||
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
|
||||
// 后端已实现却无人调用 —— 本行起补齐。
|
||||
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
|
||||
])
|
||||
const userData = userRes.data || {}
|
||||
const matchData = matchRes.data || {}
|
||||
const safetyData = safetyRes.data || {}
|
||||
const v11Data = v11Res.data || {}
|
||||
funnelStats.value = userData
|
||||
matchFunnelStats.value = matchData
|
||||
safetyStats.value = safetyData
|
||||
v11.value = v11Data
|
||||
|
||||
// 更新漏斗显示
|
||||
funnelStages[0].value = userData.totalRegistered || 0
|
||||
@@ -188,4 +335,15 @@ onMounted(loadData)
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.mini-stat {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
|
||||
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
|
||||
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
|
||||
.unavailable { color: #c0c4cc; 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,7 +59,7 @@
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
|
||||
@@ -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} · ${banner.targetValue}`
|
||||
}
|
||||
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: #f5f6f8; border-radius: 12px; }
|
||||
.preview-card {
|
||||
padding: 20px 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ffe9df, #fff7f2);
|
||||
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
|
||||
}
|
||||
.preview-tag {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
|
||||
}
|
||||
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
|
||||
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
|
||||
.preview-button {
|
||||
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
</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: 12px; 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.counselorId" placeholder="请输入咨询师工号 / ID" />
|
||||
</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,15 +122,22 @@
|
||||
<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)
|
||||
@@ -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.counselorId = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorId) {
|
||||
ElMessage.warning('请填写咨询师 ID')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorId })
|
||||
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,160 @@
|
||||
<template>
|
||||
<div class="matchmaker-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="服务请求队列" name="services">
|
||||
<el-table :data="services" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<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==='processing'" size="small" @click="openRecommendation(row)">创建推荐</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="userId" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserId" 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>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 分配弹窗 -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
|
||||
<el-option label="王红娘" value="operator_wang" />
|
||||
<el-option label="陈红娘" value="operator_chen" />
|
||||
</el-select></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>
|
||||
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('services')
|
||||
const loading = ref(false)
|
||||
const services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const assignForm = ref({ operatorId: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
submitted: 'warning', assigned: 'primary', 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'
|
||||
}
|
||||
|
||||
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 formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
|
||||
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 refresh() {
|
||||
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
|
||||
}
|
||||
|
||||
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
|
||||
async function doAssign() {
|
||||
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
|
||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
async function doCreateRecommendation() {
|
||||
if (!recForm.value.targetUserId || !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.targetUserId, reason: recForm.value.reason
|
||||
})
|
||||
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
</style>
|
||||
@@ -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,288 @@
|
||||
<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="userId" 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="userId" 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.userId }}</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:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
|
||||
</style>
|
||||
|
||||
@@ -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="userId" label="用户ID" 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: 12px;
|
||||
}
|
||||
</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.id }}</span></div>
|
||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
|
||||
</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:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.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:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.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,170 @@
|
||||
<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="id" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</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="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 +172,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: 12px;
|
||||
font-weight: 650;
|
||||
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'] } },
|
||||
@@ -35,6 +38,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ 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: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
|
||||
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
|
||||
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 {
|
||||
metrics: [
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
|
||||
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 [coreResult, serviceResult, activityResult] = 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),
|
||||
])
|
||||
|
||||
const core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||
const analyticsAvailable = Boolean(core || service)
|
||||
|
||||
const eligibility = recordOf(service || undefined, 'localEligibility')
|
||||
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(eligibility, 'pending'),
|
||||
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
|
||||
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
|
||||
},
|
||||
{
|
||||
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: '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) },
|
||||
] 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].filter((item) => item.status === 'rejected').length
|
||||
const notice = !analyticsAvailable && !activityRows
|
||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||
|
||||
return {
|
||||
metrics,
|
||||
todos,
|
||||
activities,
|
||||
serviceOverview,
|
||||
effectiveness,
|
||||
activitiesAvailable: Boolean(activityRows),
|
||||
notice,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
:root {
|
||||
--color-primary: #E96B6B;
|
||||
--color-primary-hover: #D95F5F;
|
||||
--admin-coral: #f45f68;
|
||||
--admin-coral-hover: #df505a;
|
||||
--admin-coral-soft: #fff0f1;
|
||||
--admin-shell-bg: rgba(255, 255, 255, 0.96);
|
||||
--admin-sidebar-bg: #202d38;
|
||||
--admin-sidebar-surface: #2b3a46;
|
||||
--admin-sidebar-text: #D9E1E6;
|
||||
--admin-sidebar-muted: #8FA1AC;
|
||||
--admin-page-bg: #f7f9fb;
|
||||
--admin-primary: var(--admin-coral);
|
||||
--admin-primary-hover: var(--admin-coral-hover);
|
||||
--admin-card-radius: 14px;
|
||||
--admin-shell-radius: 22px;
|
||||
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
|
||||
--admin-header-bg: #FFFFFF;
|
||||
--admin-soft-pink: var(--admin-coral-soft);
|
||||
--admin-soft-blue: #EEF5FF;
|
||||
--admin-soft-green: #EDF8F1;
|
||||
--admin-soft-purple: #F4EFFF;
|
||||
--color-primary: var(--admin-primary);
|
||||
--color-primary-hover: var(--admin-primary-hover);
|
||||
--color-primary-light: #FFF3F2;
|
||||
--color-primary-lighter: #FFF8F7;
|
||||
--color-success: #2E9B68;
|
||||
@@ -18,7 +37,7 @@
|
||||
--sidebar-width: 220px;
|
||||
--header-height: 64px;
|
||||
--border-radius-sm: 8px;
|
||||
--border-radius-md: 12px;
|
||||
--border-radius-md: var(--admin-card-radius);
|
||||
--border-radius-lg: 16px;
|
||||
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
|
||||
--el-color-primary: var(--color-primary);
|
||||
@@ -34,6 +53,13 @@ html, body, #app {
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
body { min-width: 1100px; }
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid rgba(238, 102, 107, 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--color-primary);
|
||||
--el-button-border-color: var(--color-primary);
|
||||
@@ -45,7 +71,7 @@ body { min-width: 1100px; }
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.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-table { --el-table-header-bg-color: #FBFAF8; --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; }
|
||||
@@ -64,4 +90,4 @@ body { min-width: 1100px; }
|
||||
.mb-lg { margin-bottom: 24px; }
|
||||
.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; } }
|
||||
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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 {
|
||||
metrics: DashboardMetric[]
|
||||
todos: DashboardTodo[]
|
||||
activities: DashboardActivity[]
|
||||
serviceOverview: DashboardOverviewItem[]
|
||||
effectiveness: DashboardEffectivenessItem[]
|
||||
activitiesAvailable: boolean
|
||||
notice: string
|
||||
}
|
||||
@@ -1,34 +1,734 @@
|
||||
<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>
|
||||
|
||||
<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, onMounted, 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 type { DashboardActivity, DashboardModel } from '@/types/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const activityFallback = '/brand/activity-default.jpg'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(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 {
|
||||
min-width: 0;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 26px 28px;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
display: flex;
|
||||
min-height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-welcome h1 {
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dashboard-welcome p {
|
||||
margin-top: 6px;
|
||||
color: #78828b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: #7b858e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-date small {
|
||||
padding: 6px 10px;
|
||||
color: #d75a63;
|
||||
background: #fff0f1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
}
|
||||
|
||||
.dashboard-error,
|
||||
.dashboard-forbidden,
|
||||
.dashboard-notice {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
margin: 8px 0 12px;
|
||||
padding: 10px 14px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #6f5355;
|
||||
background: #fff6f6;
|
||||
border: 1px solid #f7dfe1;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.dashboard-error > span,
|
||||
.dashboard-forbidden > span {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-error small,
|
||||
.dashboard-forbidden small {
|
||||
margin-top: 3px;
|
||||
color: #8c7d7e;
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 108px;
|
||||
padding: 14px 12px;
|
||||
grid-template-columns: 38px 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(235, 235, 238, 0.9);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-card.unavailable {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.metric-icon,
|
||||
.todo-icon {
|
||||
display: grid;
|
||||
color: var(--tone-color);
|
||||
background: var(--tone-soft);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.metric-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-copy small {
|
||||
overflow: hidden;
|
||||
color: #55616a;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-copy strong {
|
||||
margin-top: 3px;
|
||||
color: #171a1d;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-copy em {
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--tone-color);
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-arrow {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
color: var(--tone-color);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
|
||||
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
|
||||
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
|
||||
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
|
||||
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
|
||||
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf0;
|
||||
border-radius: var(--admin-card-radius);
|
||||
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f0f1f2;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
padding-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel-header h2 span {
|
||||
display: inline-grid;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 20px;
|
||||
background: var(--admin-coral);
|
||||
border-radius: 999px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #89929a;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.panel-header > span {
|
||||
color: #98a0a7;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.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 #f2f3f4;
|
||||
}
|
||||
|
||||
.todo-row:hover,
|
||||
.activity-row:hover {
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
.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: #30373d;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-copy small,
|
||||
.activity-copy small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: #939ba2;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-row > b {
|
||||
min-width: 29px;
|
||||
color: var(--admin-coral);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-row > em {
|
||||
color: var(--admin-coral);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.activity-row img {
|
||||
width: 72px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
padding: 3px 6px;
|
||||
color: #318b5a;
|
||||
font-size: 9px;
|
||||
background: #eaf7ef;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.activity-row > em {
|
||||
min-width: 76px;
|
||||
color: #7d878f;
|
||||
font-size: 9px;
|
||||
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: #a0a7ad;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dashboard-empty .el-icon {
|
||||
color: #d7dce0;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
min-height: 118px;
|
||||
padding-top: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overview-grid > div {
|
||||
display: flex;
|
||||
padding: 12px 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
background: var(--tone-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview-grid small {
|
||||
color: var(--tone-color);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.overview-grid strong {
|
||||
margin-top: 7px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.overview-grid em {
|
||||
margin-top: 7px;
|
||||
color: #8a9298;
|
||||
font-size: 9px;
|
||||
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: 10px;
|
||||
}
|
||||
|
||||
.effectiveness-row > span {
|
||||
color: #747e87;
|
||||
}
|
||||
|
||||
.effectiveness-row > div {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
background: #fdebee;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.effectiveness-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f56b75, #ef5a65);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.effectiveness-row strong {
|
||||
color: #75808a;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
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: 20px; }
|
||||
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
|
||||
.dashboard-metrics { margin-top: 6px; gap: 7px; }
|
||||
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
|
||||
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
|
||||
.metric-copy strong { font-size: 21px; }
|
||||
.dashboard-grid { margin-top: 9px; gap: 9px; }
|
||||
.dashboard-panel { padding: 10px 12px; }
|
||||
.todo-list, .activity-list { min-height: 152px; }
|
||||
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
|
||||
.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: 8px 6px; }
|
||||
.overview-grid strong { font-size: 20px; }
|
||||
}
|
||||
|
||||
@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,77 @@
|
||||
<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-illustration.jpg" alt="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img src="/brand/logo-mark.png" 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 { 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 +79,192 @@ 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: #FFFFFF;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.brand-title div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-title strong {
|
||||
color: var(--admin-primary);
|
||||
font-size: 25px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.brand-title span {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brand-message {
|
||||
max-width: 590px;
|
||||
padding: 26px 30px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(255, 255, 255, 0.94);
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand-message p {
|
||||
color: var(--admin-primary);
|
||||
font-weight: 650;
|
||||
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: #FFFFFF;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--admin-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
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: 12px;
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
|
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: 931 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 |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 89 KiB |
@@ -1,3 +0,0 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>善缘丹阳小程序首页预览</title><style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#ece9e6;font:14px -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#222}.phone{width:min(390px,100vw);min-height:844px;margin:28px auto;background:#FAFAF8;box-shadow:0 20px 60px #4b372f33;overflow:hidden}.top{height:76px;padding:14px 16px 10px;background:#fff;display:flex;align-items:center;border-bottom:1px solid #f0ede9;position:relative}.brand{flex:1}.brand strong{display:block;font-size:19px;color:#E96B6B}.brand small{color:#888}.pill{font-size:11px;color:#2E9B68;background:#ECF8F2;padding:5px 8px;border-radius:99px}.bell{margin-left:12px;font-size:12px}.preview{text-align:center;padding:4px;background:#FFF7E9;color:#B77B19;font-size:10px}.status{margin:8px 16px 0;padding:10px 12px;background:#fff;border:1px solid #e8e5e2;border-radius:12px;display:flex;justify-content:space-between;font-size:12px}.ok{color:#2E9B68}.banner{height:146px;margin:8px 16px 0;border-radius:16px;background:linear-gradient(135deg,#FFF3F2,#FBE8DE);padding:22px 16px;position:relative;overflow:hidden}.banner:after{content:'♡';position:absolute;right:8px;top:-36px;font-size:150px;color:#e96b6b26}.banner h1{font-size:20px;margin:0;color:#6F3A35;width:70%;line-height:1.35}.banner p{font-size:12px;color:#8B5E56}.banner a{display:inline-block;color:#fff;background:#E96B6B;border-radius:99px;padding:7px 12px;font-size:12px}.entries{margin:10px 16px 0;padding:12px 5px;background:#fff;border-radius:16px;display:grid;grid-template-columns:repeat(4,1fr);row-gap:12px;box-shadow:0 4px 16px #4b372f0d}.entry{text-align:center;font-size:12px}.icon{width:38px;height:38px;margin:0 auto 5px;border-radius:12px;display:grid;place-items:center;font-weight:700}.entry:nth-child(1) .icon{background:#FFF0EF;color:#D85D62}.entry:nth-child(2) .icon{background:#FFF7E6;color:#B77B19}.entry:nth-child(3) .icon{background:#ECF8F2;color:#2E8A62}.entry:nth-child(4) .icon{background:#EEF5FF;color:#4A72B8}.entry:nth-child(5) .icon{background:#F5EFFB;color:#8865A7}.entry:nth-child(6) .icon{background:#FFF2E8;color:#B9723B}.entry:nth-child(7) .icon{background:#EEF0FB;color:#5F6FAE}.entry:nth-child(8) .icon{background:#EAF7F5;color:#31867C}.section{margin:24px 16px 0}.heading{display:flex;align-items:end;justify-content:space-between;margin-bottom:10px}.heading h2{font-size:17px;margin:0}.heading small{display:block;color:#888;font-weight:400;margin-top:3px}.more{font-size:12px;color:#E96B6B}.recommend{background:#fff;padding:13px;border-radius:16px;box-shadow:0 4px 16px #4b372f0d;display:flex;gap:12px}.avatar{width:68px;height:84px;border-radius:10px;background:linear-gradient(145deg,#f3d8cf,#e8b7aa);display:grid;place-items:center;color:#fff;font-size:22px}.person{flex:1}.name{font-size:16px;font-weight:650}.meta{font-size:12px;color:#777;margin-top:5px}.reason{margin-top:8px;color:#555;font-size:12px}.tag{display:inline-block;background:#ECF8F2;color:#2E9B68;padding:3px 7px;border-radius:99px;font-size:10px}.actions{margin-top:9px;display:flex;gap:8px}.actions span{flex:1;text-align:center;padding:7px;border-radius:99px;background:#FFF3F2;color:#E96B6B}.actions .primary{background:#E96B6B;color:#fff}.service{padding:14px;background:linear-gradient(135deg,#FFF8F7,#FFF9F0);border:1px solid #f8e3dd;border-radius:16px;display:flex;align-items:center}.service strong{display:block}.service p{font-size:11px;color:#666;margin:5px 0 0}.service a{margin-left:auto;color:#E96B6B;font-size:11px}.tabbar{position:sticky;bottom:0;margin-top:24px;height:62px;background:#fff;border-top:1px solid #eee;display:flex;justify-content:space-around;align-items:center;font-size:11px}.tabbar span:first-child{color:#E96B6B;font-weight:650}
|
||||
</style></head><body><main class="phone"><header class="top"><div class="brand"><strong>善缘丹阳</strong><small>真实相识 · 公益牵线</small></div><span class="pill">公益服务</span><span class="bell">消息 2</span></header><div class="preview">界面预览数据</div><div class="status"><span class="ok">✓ 已实名</span><span class="ok">✓ 本地资格</span><span class="ok">✓ 资料已审核</span></div><section class="banner"><h1>让真诚的相遇,在丹阳发生</h1><p>实名认证 · 人工审核 · 公益服务</p><a>了解平台 ›</a></section><section class="entries"><div class="entry"><div class="icon">缘</div>找对象</div><div class="entry"><div class="icon">牵</div>公益红娘</div><div class="entry"><div class="icon">聚</div>公益活动</div><div class="entry"><div class="icon">谈</div>婚恋咨询</div><div class="entry"><div class="icon">学</div>婚恋课堂</div><div class="entry"><div class="icon">伴</div>婚前辅导</div><div class="entry"><div class="icon">安</div>婚恋反诈</div><div class="entry"><div class="icon">站</div>善缘服务站</div></section><section class="section"><div class="heading"><h2>今日推荐<small>根据双方条件,为你认真筛选</small></h2><span class="more">查看更多</span></div><div class="recommend"><div class="avatar">林</div><div class="person"><div><span class="name">林女士 · 29岁</span> <span class="tag">已认证</span></div><div class="meta">165cm · 丹阳 · 教育行业</div><div class="reason">✓ 双方生活规划较为契合<br>✓ 都期待认真稳定的关系</div><div class="actions"><span>不合适</span><span class="primary">喜欢</span></div></div></div></section><section class="section"><div class="heading"><h2>善缘公益服务<small>需要时,我们在这里陪伴你</small></h2></div><div class="service"><div><strong>需要更有温度的帮助?</strong><p>公益红娘尊重双方意愿并提供人工推荐</p></div><a>申请 ›</a></div></section><nav class="tabbar"><span>首页</span><span>找对象</span><span>消息</span><span>活动</span><span>我的</span></nav></main></body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
target
|
||||
.m2*
|
||||
local-*
|
||||
build-temp
|
||||
*.log
|
||||
@@ -14,3 +14,7 @@
|
||||
# MINIO_ENDPOINT=http://localhost:9000
|
||||
# MINIO_ACCESS_KEY=replace-with-local-access-key
|
||||
# MINIO_SECRET_KEY=replace-with-local-secret-key
|
||||
# 本地资格佐证保留期(天)
|
||||
LOCAL_EVIDENCE_RETENTION_DAYS=365
|
||||
LOCAL_EVIDENCE_ALLOWED_TYPES=jpg,png
|
||||
MATCH_LOCAL_ELIGIBILITY_REQUIRED=false
|
||||
|
||||
@@ -10,4 +10,4 @@ FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=${SPRING_PROFILES_ACTIVE:docker}"]
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>temp-dependencies</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</parent>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<version>9.22.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>1.5.5.Final</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo1.maven.org/maven2</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
@@ -8,7 +8,6 @@ import com.danyang.match.platform.account.mapper.ConsentRecordMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
@@ -18,12 +17,19 @@ import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -48,11 +54,22 @@ public class AccountLifecycleService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
|
||||
|
||||
/**
|
||||
* 本地资格「审核进行中」的状态集合。
|
||||
*
|
||||
* <p>取值域严格来自生产 {@code V017 chk_elig_verify_status}:
|
||||
* {@code declared / needs_evidence / pending_review / verified / rejected}。
|
||||
* 这里刻意**不复用** {@code Constants.VERIFICATION_STATUS_*} ——
|
||||
* 那组常量描述的是 {@code user_identities}(V002 的 CHECK 才含 failed),
|
||||
* 两个表的状态域不同,跨域复用正是本类两处缺陷的根因。
|
||||
*/
|
||||
private static final Set<String> LOCAL_ELIGIBILITY_IN_REVIEW =
|
||||
Set.of("needs_evidence", "pending_review");
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
@@ -62,6 +79,10 @@ public class AccountLifecycleService {
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final ChatService chatService;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final CounselingFollowupMapper counselingFollowupMapper;
|
||||
|
||||
// ==================== 退出婚恋服务(暂停推荐) ====================
|
||||
|
||||
@@ -173,9 +194,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// V1.1: 检查是否有进行中的咨询
|
||||
long activeCounselings = counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getUserId, userId)
|
||||
.in(CounselingService::getStatus,
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getUserId, userId)
|
||||
.in(CounselingServiceRecord::getStatus,
|
||||
Constants.COUNSELING_STATUS_PENDING,
|
||||
Constants.COUNSELING_STATUS_ASSIGNED,
|
||||
Constants.COUNSELING_STATUS_SCHEDULED,
|
||||
@@ -194,10 +215,14 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
// V1.1: 检查本地资格核验状态
|
||||
// 注意状态域:本地资格表(V017 chk_elig_verify_status)只有
|
||||
// declared / needs_evidence / pending_review / verified / rejected
|
||||
// 此前这里比较的是 "submitted" / "pending" —— 这两个值在该表里**永不可能出现**,
|
||||
// 因此"本地资格审核进行中"这道拦截从未生效过(死代码),
|
||||
// 审核中的用户可以直接注销。正确的"审核进行中"是 pending_review。
|
||||
Optional<LocalEligibility> localElig = localEligibilityMapper.findByUserId(userId);
|
||||
if (localElig.isPresent() &&
|
||||
(Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) ||
|
||||
Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) {
|
||||
LOCAL_ELIGIBILITY_IN_REVIEW.contains(localElig.get().getVerificationStatus())) {
|
||||
blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待");
|
||||
}
|
||||
|
||||
@@ -221,10 +246,16 @@ public class AccountLifecycleService {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 1. 检查是否可以注销
|
||||
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
|
||||
// 依据 AGENTS「注销必须在线便捷、不设置障碍」与计划 520「注销事务关闭未来服务」,
|
||||
// 这里**不再抛异常阻断**——closeAccount 会自行取消/退出/失效这些业务。
|
||||
// 早期实现在此抛异常,导致下方 cancelXxx / invalidateLocalEligibility 的清理逻辑
|
||||
// 永远不可达(死代码),且给用户注销制造了「需先手动取消所有业务」的障碍。
|
||||
Map<String, Object> checkResult = checkCanWithdraw(userId);
|
||||
if (!(boolean) checkResult.get("canWithdraw")) {
|
||||
throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> blockers = (Map<String, Object>) checkResult.get("blockers");
|
||||
if (blockers != null && !blockers.isEmpty()) {
|
||||
log.info("注销时存在进行中业务(将自动关闭): userId={}, blockers={}", userId, blockers.keySet());
|
||||
}
|
||||
|
||||
// 2. 停止所有进行中的业务
|
||||
@@ -257,6 +288,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// 3. 敏感数据脱敏(保留审计所需的最小信息)
|
||||
sanitizeUserData(user.getId());
|
||||
// 3b. 服务历史(含咨询正文)脱敏 —— 此前 P21-T05 的实现从未被调用(死代码),
|
||||
// 导致注销后咨询摘要/正文仍以明文留存,违反 Gate「不反向暴露个人和咨询正文」。
|
||||
anonymizeServiceHistory(user.getId());
|
||||
|
||||
// 4. 记录退出时间
|
||||
Instant now = Instant.now();
|
||||
@@ -333,11 +367,28 @@ public class AccountLifecycleService {
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 通知所有活跃 Match 对方账号已关闭
|
||||
* 注销时关闭用户所有活跃 Match 并通知对方。
|
||||
*
|
||||
* <p>这是 Gate「注销后不能聊天」的核心机制:仅仅把 {@code accountStatus} 置为
|
||||
* {@code closed} 并不足以阻止聊天 —— 既有的 active Match 仍会让对方看到可聊入口。
|
||||
* 因此这里把每条 active/getting_to_know 的 Match 置为 {@code unmatched}(与 Unmatch 同态),
|
||||
* 并通过 {@link ChatService#closeConversationForMatch} 触发 {@code conversation.closed}
|
||||
* 事件,让对方实时灰显、发送时被 {@code assertMatchAlive} 拦截。
|
||||
*
|
||||
* <p>此前该方法是 TODO 桩(只写日志),注销后 Match 仍 active、对方完全不知情 →
|
||||
* 用户已注销却还能被匹配对象继续聊天,Gate 形同虚设。
|
||||
*/
|
||||
private void notifyActiveMatches(String userId, String reason) {
|
||||
// TODO: 查询该用户的所有 active match,通知对方
|
||||
log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason);
|
||||
List<Match> matches = matchMapper.findActiveMatchesByUser(userId);
|
||||
int closed = 0;
|
||||
for (Match m : matches) {
|
||||
m.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
||||
m.setUnmatchedAt(Instant.now());
|
||||
matchMapper.updateById(m);
|
||||
chatService.closeConversationForMatch(m.getId(), Constants.CONVERSATION_STATUS_UNMATCHED);
|
||||
closed++;
|
||||
}
|
||||
log.info("注销关闭活跃 Match 并通知对方: userId={}, closed={}, reason={}", userId, closed, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,16 +400,36 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有未开始的活动报名
|
||||
* 注销时取消用户所有进行中的活动报名(pending/approved)。
|
||||
*
|
||||
* <p>计划 520 要求注销事务「关闭未来服务」——活动报名属于未来服务,若不取消,
|
||||
* 已注销用户仍停留在活动名单、可能收到签到提醒或现场被识别。
|
||||
* 此前该方法是 TODO 桩(只写日志),报名完全不被取消。
|
||||
*
|
||||
* <p>注意:{@code ActivityRegistration} 无 {@code deleted_at} 列(D12 软删过滤不适用),
|
||||
* 且既有 {@code findByUserId} 带 {@code LIMIT 20} 不足以覆盖全部报名,故用
|
||||
* {@code selectList} 全量查询后逐条置 {@code cancelled}。
|
||||
*/
|
||||
private void cancelPendingActivityRegistrations(String userId) {
|
||||
// TODO: 查询 activity_registrations 中 pending/approved 且活动未开始的记录,将其状态设为 cancelled
|
||||
log.info("取消用户活动报名: userId={}", userId);
|
||||
List<ActivityRegistration> regs = registrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getUserId, userId)
|
||||
.in(ActivityRegistration::getStatus,
|
||||
Constants.REGISTRATION_STATUS_PENDING,
|
||||
Constants.REGISTRATION_STATUS_APPROVED));
|
||||
int cancelled = 0;
|
||||
for (ActivityRegistration reg : regs) {
|
||||
reg.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
|
||||
reg.setUpdatedAt(Instant.now());
|
||||
registrationMapper.updateById(reg);
|
||||
cancelled++;
|
||||
}
|
||||
log.info("取消用户活动报名: userId={}, cancelled={}", userId, cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感数据脱敏
|
||||
* 核心:身份证/手机号脱敏,保留审计所需的最小信息
|
||||
* 核心:身份证/手机号/实名姓名脱敏,保留审计所需的最小信息
|
||||
*/
|
||||
private void sanitizeUserData(String userId) {
|
||||
// 脱敏手机号
|
||||
@@ -373,7 +444,14 @@ public class AccountLifecycleService {
|
||||
}
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
// TODO: 同步更新 user_identities 中的身份证号码
|
||||
// 脱敏身份证号与姓名(user_identities):两列均为 TEXT NOT NULL,
|
||||
// 不能以 null 删除,只能覆盖为不可还原的占位符(原文为密文,部分打码无意义)。
|
||||
// 此前此处是 TODO,导致注销后身份证明文(密文)永久留存。
|
||||
userIdentityMapper.findByUserId(userId).ifPresent(identity -> {
|
||||
identity.setIdNumberEnc("***ACCOUNT_CLOSED***");
|
||||
identity.setRealNameEnc("***ACCOUNT_CLOSED***");
|
||||
userIdentityMapper.updateById(identity);
|
||||
});
|
||||
log.info("敏感数据脱敏: userId={}", userId);
|
||||
}
|
||||
|
||||
@@ -399,9 +477,9 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
private void cancelCounselingServices(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
|
||||
int cancelled = 0;
|
||||
for (CounselingService cs : services) {
|
||||
for (CounselingServiceRecord cs : services) {
|
||||
if (Constants.COUNSELING_STATUS_PENDING.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_ASSIGNED.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_SCHEDULED.equals(cs.getStatus()) ||
|
||||
@@ -433,17 +511,36 @@ public class AccountLifecycleService {
|
||||
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
|
||||
if (elig.isPresent()) {
|
||||
LocalEligibility le = elig.get();
|
||||
le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED);
|
||||
// 生产 V017 的 chk_elig_verify_status 只允许
|
||||
// declared / needs_evidence / pending_review / verified / rejected
|
||||
// —— **没有 failed**。VERIFICATION_STATUS_FAILED 属于 user_identities 的状态域
|
||||
// (V002 的 chk 里才有 failed),把它写进本地资格表会在真实 PostgreSQL 上
|
||||
// 直接触发 CHECK 违约 → closeAccount 整个事务回滚 → 注销 100% 不可用。
|
||||
// 测试库此前没有该 CHECK,所以这个必崩缺陷在测试期完全不可见。
|
||||
le.setVerificationStatus(Constants.VERIFICATION_STATUS_REJECTED);
|
||||
localEligibilityMapper.updateById(le);
|
||||
log.info("失效用户本地资格: userId={}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private void anonymizeServiceHistory(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingService cs : services) {
|
||||
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
|
||||
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingServiceRecord cs : services) {
|
||||
boolean changed = false;
|
||||
if (cs.getUserRequestSummary() != null && !cs.getUserRequestSummary().isEmpty()) {
|
||||
cs.setUserRequestSummary("***已脱敏***");
|
||||
changed = true;
|
||||
}
|
||||
// 咨询正文在独立的 counseling_followups 表(INV-013 保护的敏感正文),
|
||||
// 仅脱敏主表摘要会漏掉逐条跟进记录 → 注销后咨询详情仍明文留存。
|
||||
List<CounselingFollowup> followups = counselingFollowupMapper.findByServiceId(cs.getId());
|
||||
for (CounselingFollowup f : followups) {
|
||||
if (f.getContent() != null && !f.getContent().isEmpty()) {
|
||||
f.setContent("***已脱敏***");
|
||||
counselingFollowupMapper.updateById(f);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
counselingServiceMapper.updateById(cs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -48,6 +47,7 @@ public class ActivityController {
|
||||
return ApiResponse.ok(detail);
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@PostMapping("/{id}/register")
|
||||
public ApiResponse<Map<String, Object>> register(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@@ -99,33 +99,13 @@ public class ActivityController {
|
||||
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
|
||||
}
|
||||
|
||||
@PostMapping("/operator")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> createActivityOperator(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/organizations")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addActivityOrganization(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/capacity-groups")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addCapacityGroup(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Phase 14 复验:以下三个运营端写入端点原先挂在**用户端** /activities 下
|
||||
// POST /activities/operator
|
||||
// POST /activities/operator/{id}/organizations
|
||||
// POST /activities/operator/{id}/capacity-groups
|
||||
// 属双实现 + 权限边界错配(运营写操作混在用户端控制器里),
|
||||
// 且后台实际调用的是 /operator/activities,这三个端点零调用方。
|
||||
// 已统一迁至 ActivityOperatorController(类级 @PreAuthorize('activity.view'))。
|
||||
// ------------------------------------------------------------------
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityInterest;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
@@ -54,6 +55,7 @@ public class ActivityInterestController {
|
||||
* 提交活动反馈 + 表达意向
|
||||
* POST /api/v1/activities/{id}/feedback
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/{id}/feedback")
|
||||
public ApiResponse<Map<String, Object>> submitFeedback(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@@ -105,6 +107,7 @@ public class ActivityInterestController {
|
||||
* POST /api/v1/activities/{id}/interests/confirm
|
||||
* 双方确认后创建 Match Episode,不自动创建 Conversation
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/{id}/interests/confirm")
|
||||
public ApiResponse<Map<String, Object>> confirmInterest(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
@@ -14,8 +14,6 @@ import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import java.util.Map;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
|
||||
/**
|
||||
* 后台活动管理控制器
|
||||
@@ -30,35 +28,69 @@ public class ActivityOperatorController {
|
||||
private final ActivityMapper activityMapper;
|
||||
|
||||
/**
|
||||
* 创建活动
|
||||
* 创建活动(V1.1 类型化)
|
||||
* POST /api/v1/operator/activities
|
||||
*
|
||||
* <p><b>Phase 14 修复</b>:原实现把请求体的 activityType / registrationPolicy /
|
||||
* 多组织 / 容量分组**全部丢弃**,且把 {@code organizer_org_id} 错设为操作者 userId。
|
||||
* 后台动态表单因此无法创建带类型的活动 —— Gate「分类 Tabs 有数据」根本不成立。
|
||||
* 现统一委托 {@link ActivityService#createActivityFromAdmin},只保留一条实现。
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
@Transactional
|
||||
public ApiResponse<Activity> createActivity(
|
||||
public ApiResponse<?> createActivity(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
Activity activity = new Activity();
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setOrganizerOrgId(operatorId);
|
||||
activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50));
|
||||
activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25));
|
||||
activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25));
|
||||
activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue());
|
||||
activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue());
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
// 默认时间:明天开始,持续3小时
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activityMapper.insert(activity);
|
||||
return ApiResponse.ok(activity);
|
||||
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动主办/协作组织列表
|
||||
* GET /api/v1/operator/activities/{id}/organizations
|
||||
*/
|
||||
@GetMapping("/{id}/organizations")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityOrganizations(@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityOrganizations(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加活动组织关系
|
||||
* POST /api/v1/operator/activities/{id}/organizations
|
||||
* Body: { "organizationId": "...", "role": "host|organizer|co_organizer|partner|venue_partner" }
|
||||
*/
|
||||
@PostMapping("/{id}/organizations")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addActivityOrganization(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(activityService.addActivityOrganization(
|
||||
id, body.get("organizationId"), body.get("role"), principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动容量分组列表
|
||||
* GET /api/v1/operator/activities/{id}/capacity-groups
|
||||
*/
|
||||
@GetMapping("/{id}/capacity-groups")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityCapacityGroups(@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增容量分组
|
||||
* POST /api/v1/operator/activities/{id}/capacity-groups
|
||||
* Body: { "groupCode": "...", "capacity": 20, "ruleDefinition": "{...}" }
|
||||
*/
|
||||
@PostMapping("/{id}/capacity-groups")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addCapacityGroup(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return ApiResponse.ok(activityService.addCapacityGroup(id, body, principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +136,8 @@ public class ActivityOperatorController {
|
||||
m.put("id", a.getId());
|
||||
m.put("title", a.getTitle());
|
||||
m.put("status", a.getStatus());
|
||||
// Phase 14:后台列表必须能看到活动类型,否则运营无法核对分类是否建对
|
||||
m.put("activityType", a.getActivityType());
|
||||
m.put("startAt", a.getStartAt());
|
||||
m.put("endAt", a.getEndAt());
|
||||
m.put("locationName", a.getLocationName());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.activity.service.CheckinService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
@@ -38,6 +39,7 @@ public class CheckinController {
|
||||
* POST /api/v1/operator/activities/checkin/scan
|
||||
* Body: { "token": "..." }
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/operator/checkin/scan")
|
||||
@PreAuthorize("hasAuthority('activity.checkin')")
|
||||
public ApiResponse<Map<String, Object>> scanCheckin(
|
||||
@@ -55,7 +57,14 @@ public class CheckinController {
|
||||
* 手工签到
|
||||
* POST /api/v1/operator/activities/{id}/checkin/manual
|
||||
* Body: { "userId": "..." }
|
||||
*
|
||||
* <p>规范第八章要求「签到」支持 `Idempotency-Key`,第 146 章的验收场景
|
||||
* 「扫码两次」正是这类操作:签到工作台在弱网下重发、或操作员双击,
|
||||
* 都会重复提交同一个签到动作。此前该端点漏标(同控制器的
|
||||
* {@code /operator/checkin/scan} 已标注),补上一处声明即可由
|
||||
* {@code IdempotencyInterceptor} 统一兜住,业务代码无需改动。
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/operator/{id}/checkin/manual")
|
||||
@PreAuthorize("hasAuthority('activity.checkin')")
|
||||
public ApiResponse<Map<String, Object>> manualCheckin(
|
||||
|
||||
@@ -12,6 +12,26 @@ public class ActivityListItemDTO {
|
||||
private String activityId;
|
||||
private String title;
|
||||
private String coverUrl;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:封面文件 ID。
|
||||
*
|
||||
* <p><b>修复</b>:{@code coverUrl} 此前**从未被赋值**(全库零写入点),
|
||||
* 活动卡片封面恒回退到默认图。小程序侧按 {@code uploadBase + '/files/' + coverFileId}
|
||||
* 拼接真实地址(见 {@code components/activity-card/index.js})。
|
||||
*/
|
||||
private String coverFileId;
|
||||
|
||||
/** V1.1 Phase 14:活动简介(卡片摘要)。此前 DTO 无此字段,卡片摘要恒不渲染。 */
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:活动状态。
|
||||
*
|
||||
* <p><b>修复</b>:卡片按钮按 {@code activity.status === 'registration_open'} 显示
|
||||
* "立即报名",而 DTO 根本没有 status —— 按钮永远显示"查看详情"。
|
||||
*/
|
||||
private String status;
|
||||
private Instant startAt;
|
||||
private Instant endAt;
|
||||
private String locationName;
|
||||
@@ -22,4 +42,22 @@ public class ActivityListItemDTO {
|
||||
private int remainingSeats;
|
||||
private boolean registered;
|
||||
private String registrationStatus;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:活动类型。
|
||||
*
|
||||
* <p><b>修复</b>:小程序 {@code pages/activity/activity.ts} 的分类 Tabs 读取
|
||||
* {@code item.activityType},但后端列表 DTO 此前**根本没有该字段** ——
|
||||
* 分类筛选恒无数据,Gate「matchmaking 与课堂/反诈类活动显示不同」直接失败。
|
||||
*/
|
||||
private String activityType;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:已报名人数。
|
||||
*
|
||||
* <p><b>修复</b>:小程序 {@code ui-activity-card} 读 {@code activity.registeredCount}
|
||||
* (回退 {@code currentMaleCount + currentFemaleCount}),而后端从未提供这三个字段 ——
|
||||
* 卡片上的"报名人数 x/y"恒为 {@code 0/y}。此处由真实性别统计汇总。
|
||||
*/
|
||||
private int registeredCount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.danyang.match.platform.activity.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 活动工作人员指派
|
||||
*
|
||||
* <p>V1.1 Phase 15(V040):服务站操作员的活动维度数据范围。
|
||||
* <b>主办/协办组织关系不授予活动操作权限(INV-020)</b> —— 能否为某活动执行签到,
|
||||
* 只取决于本表的显式指派,与 operator 所属组织无关。
|
||||
*
|
||||
* <p><b>注意</b>:本表主键为 {@code (activity_id, operator_id)} 复合主键,
|
||||
* 因此**不继承** {@code BaseEntity} —— BaseEntity 会引入 id / updated_at / deleted_at,
|
||||
* 而 MyBatis-Plus 对非 null 字段一律写入列清单,那会让每次 insert 都在生产
|
||||
* 撞 {@code column does not exist}(与本项目历次"只在 PG 上炸"同族)。
|
||||
*/
|
||||
@Data
|
||||
@TableName("activity_staff_assignments")
|
||||
public class ActivityStaffAssignment {
|
||||
|
||||
private String activityId;
|
||||
private String operatorId;
|
||||
/** checkin_staff / activity_manager */
|
||||
private String assignmentRole;
|
||||
private String createdBy;
|
||||
private Instant createdAt;
|
||||
}
|
||||
@@ -3,7 +3,35 @@ package com.danyang.match.platform.activity.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityCapacityGroupMapper extends BaseMapper<ActivityCapacityGroup> {
|
||||
|
||||
@Select("SELECT * FROM activity_capacity_groups WHERE activity_id = #{activityId} AND deleted_at IS NULL "
|
||||
+ "ORDER BY group_code")
|
||||
List<ActivityCapacityGroup> findByActivityId(@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* **原子**占用分组名额:仅当 {@code approved_count < capacity} 时才 +1。
|
||||
*
|
||||
* <p>Phase 15:容量分组此前只"落库回读",从未参与名额扣减 —— 运营配置了分组容量也形同虚设。
|
||||
* 单条 UPDATE 由数据库保证原子性,避免 {@code SELECT → UPDATE} 在并发下超卖。
|
||||
*
|
||||
* @return 影响行数;0 表示该分组已无名额(或分组不存在)
|
||||
*/
|
||||
@Update("UPDATE activity_capacity_groups SET approved_count = approved_count + 1, updated_at = NOW() "
|
||||
+ "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
|
||||
+ "AND deleted_at IS NULL AND approved_count < capacity")
|
||||
int incrementIfAvailable(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
|
||||
|
||||
/** 取消/拒绝 approved 报名时回退分组名额(不会退到负数) */
|
||||
@Update("UPDATE activity_capacity_groups SET approved_count = GREATEST(0, approved_count - 1), updated_at = NOW() "
|
||||
+ "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
|
||||
+ "AND deleted_at IS NULL AND approved_count > 0")
|
||||
int decrementIfPositive(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
|
||||
}
|
||||
|
||||
@@ -10,17 +10,32 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityMapper extends BaseMapper<Activity> {
|
||||
int countRemainingCapacity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
|
||||
List<Activity> findOpenActivities();
|
||||
|
||||
/** V1.1 Phase 14: 按类型下推查询(原为全表捞取后内存 filter) */
|
||||
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL "
|
||||
+ "AND activity_type = #{activityType} ORDER BY start_at ASC")
|
||||
List<Activity> findOpenActivitiesByType(@Param("activityType") String activityType);
|
||||
|
||||
@Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10")
|
||||
List<Activity> findUpcomingActivities();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedRegistrations(@Param("activityId") String activityId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender);
|
||||
/**
|
||||
* 统计某活动已通过报名的**真实性别构成**。
|
||||
*
|
||||
* <p><b>Phase 14 修复</b>:原 {@code countApprovedMaleRegistrations} 的 SQL
|
||||
* 完全忽略了 {@code gender} 入参(永远返回全部通过人数),且零调用方——
|
||||
* 导致 {@code ActivityService#toListItemDTO} 只能用 {@code approvedCount / 2}
|
||||
* **凭空猜测**男女人数。此处改为按 {@code user_profiles.gender} 真实聚合。
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ "JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ "WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ "AND r.deleted_at IS NULL AND p.gender = #{gender}")
|
||||
int countApprovedByGender(@Param("activityId") String activityId, @Param("gender") String gender);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -19,13 +21,185 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
|
||||
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
List<ActivityRegistration> findApprovedByActivity(String activityId);
|
||||
|
||||
/**
|
||||
* 某活动的**全部**报名记录(含 pending)。
|
||||
*
|
||||
* <p><b>Phase 15 修复</b>:后台报名列表此前只查 {@code status='approved'},
|
||||
* 审核页面因此永远看不到待审记录 —— 审核功能实际不可用。
|
||||
*/
|
||||
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND deleted_at IS NULL "
|
||||
+ "ORDER BY registered_at ASC")
|
||||
List<ActivityRegistration> findByActivity(@Param("activityId") String activityId);
|
||||
|
||||
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
|
||||
List<ActivityRegistration> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* 原子检查名额:返回剩余名额数
|
||||
* 原子检查名额:返回剩余名额数。
|
||||
*
|
||||
* <p><b>Phase 14 说明</b>:本方法是全模块**唯一**有 SQL 实现的容量查询。
|
||||
* 此前 {@code ActivityMapper} 上有一个同名方法却**既无 {@code @Select} 也无 XML**,
|
||||
* 而 {@code ActivityService} 调用的正是那个空壳 ——
|
||||
* 活动详情 / 报名 / 审核三条路径在生产必然抛
|
||||
* {@code Invalid bound statement (not found)}。已删除空壳并统一走本方法。
|
||||
*/
|
||||
@Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " +
|
||||
"(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining")
|
||||
int countRemainingCapacity(@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* **原子**占位插入:仅当「总名额未满」**且**「该性别名额未满」时才写入 approved 报名。
|
||||
*
|
||||
* <p>把「查余量」与「写记录」合并为一条 SQL,由数据库保证原子性,
|
||||
* 避免 {@code SELECT COUNT → INSERT} 的先读后写在并发下超卖。
|
||||
*
|
||||
* <p>性别名额按**配置**生效:{@code male_limit}/{@code female_limit} 为 NULL 时
|
||||
* (如婚恋课堂 / 反诈类活动)该条件下退化为 999999,即只受总名额约束 ——
|
||||
* 这正是 Gate「matchmaking 与课堂/反诈类容量策略不同」的实现方式。
|
||||
*
|
||||
* @return 影响行数;0 表示名额已满(或活动不存在),调用方必须据此报错
|
||||
*/
|
||||
@Insert("INSERT INTO activity_registrations (id, activity_id, user_id, status, registered_at, "
|
||||
+ "checkin_status, emergency_contact_name, emergency_contact_phone_enc, created_at, updated_at) "
|
||||
+ "SELECT #{id}, #{activityId}, #{userId}, #{status}, NOW(), 'not_checked_in', "
|
||||
+ "#{contactName}, #{contactPhone}, NOW(), NOW() "
|
||||
+ "WHERE (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
|
||||
+ " AND status = 'approved' AND deleted_at IS NULL) "
|
||||
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
|
||||
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
|
||||
+ " WHEN 'female' THEN a.female_limit "
|
||||
+ " ELSE a.total_limit END "
|
||||
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
|
||||
int insertIfCapacityAvailable(@Param("id") String id,
|
||||
@Param("activityId") String activityId,
|
||||
@Param("userId") String userId,
|
||||
@Param("status") String status,
|
||||
@Param("contactName") String contactName,
|
||||
@Param("contactPhone") String contactPhone,
|
||||
@Param("gender") String gender);
|
||||
|
||||
/**
|
||||
* **原子**审核通过:仅当记录仍为 pending **且**总名额与性别名额均未满时才置为 approved。
|
||||
*
|
||||
* @return 影响行数;0 表示名额已满或状态已被他人变更
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET status = 'approved', reviewed_by = #{operatorId}, "
|
||||
+ "reviewed_at = NOW(), updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
|
||||
+ " AND status = 'approved' AND deleted_at IS NULL) "
|
||||
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
|
||||
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
|
||||
+ " WHEN 'female' THEN a.female_limit "
|
||||
+ " ELSE a.total_limit END "
|
||||
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
|
||||
int approveIfCapacityAvailable(@Param("id") String id,
|
||||
@Param("activityId") String activityId,
|
||||
@Param("operatorId") String operatorId,
|
||||
@Param("gender") String gender);
|
||||
|
||||
// ==================== Phase 15 新增 ====================
|
||||
|
||||
/**
|
||||
* **原子**复用历史报名记录(cancelled / rejected → pending|approved)。
|
||||
*
|
||||
* <p><b>Phase 15 D7(必崩)</b>:{@code UNIQUE(activity_id, user_id)} 在生产库一直存在,
|
||||
* 而"取消后重新报名"走的是 {@code INSERT} —— 用户一旦取消就永远无法再报该活动,
|
||||
* 直接撞唯一键 500。测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。
|
||||
* 正确做法:复用原记录做条件 UPDATE,并**同样**带上容量条件。
|
||||
*
|
||||
* @return 影响行数;0 表示名额已满,或记录状态已被他人变更
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET status = #{status}, registered_at = NOW(), "
|
||||
+ "reviewed_by = NULL, reviewed_at = NULL, reject_reason = NULL, "
|
||||
+ "checkin_status = 'not_checked_in', checkin_at = NULL, "
|
||||
+ "emergency_contact_name = #{contactName}, emergency_contact_phone_enc = #{contactPhone}, "
|
||||
+ "updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND status IN ('cancelled', 'rejected') AND deleted_at IS NULL "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
|
||||
+ " AND status = 'approved' AND deleted_at IS NULL) "
|
||||
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
|
||||
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
|
||||
+ " WHEN 'female' THEN a.female_limit "
|
||||
+ " ELSE a.total_limit END "
|
||||
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
|
||||
int reuseIfCapacityAvailable(@Param("id") String id,
|
||||
@Param("activityId") String activityId,
|
||||
@Param("status") String status,
|
||||
@Param("contactName") String contactName,
|
||||
@Param("contactPhone") String contactPhone,
|
||||
@Param("gender") String gender);
|
||||
|
||||
/**
|
||||
* **原子**签到:仅当当前为 {@code not_checked_in} 时才置为已签到。
|
||||
*
|
||||
* <p><b>Phase 15 D2</b>:原实现为「读 → 判断 → updateById 无条件覆盖」,
|
||||
* 并发扫码会重复写入 checkin_at 并重复记审计。此处合并为一条条件 UPDATE,
|
||||
* 由数据库保证"重复签到幂等"。
|
||||
*
|
||||
* @return 影响行数;0 表示已签到(幂等命中)或记录已被取消
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET checkin_status = 'checked_in', checkin_at = NOW(), updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND checkin_status = 'not_checked_in' AND deleted_at IS NULL")
|
||||
int checkinIfNotCheckedIn(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* **原子**取消:仅当状态仍为 pending / approved 时才置为 cancelled。
|
||||
*
|
||||
* @return 影响行数;0 表示状态已被他人变更(如已被审核拒绝),调用方不得重复回补名额
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET status = 'cancelled', updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND status IN ('pending', 'approved') AND deleted_at IS NULL")
|
||||
int cancelIfActive(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* **原子**拒绝:仅当仍为 pending 时才置为 rejected。
|
||||
*
|
||||
* <p>与 {@link #approveIfCapacityAvailable} 对称 —— 审核并发时两人同时操作,
|
||||
* 只有一个能生效,避免"拒绝后又被通过"。
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET status = 'rejected', reject_reason = #{reason}, "
|
||||
+ "reviewed_by = #{operatorId}, reviewed_at = NOW(), updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL")
|
||||
int rejectIfPending(@Param("id") String id,
|
||||
@Param("operatorId") String operatorId,
|
||||
@Param("reason") String reason);
|
||||
|
||||
// ==================== Phase 20:活动漏斗口径统计 ====================
|
||||
// 此前 AnalyticsService.getActivityFunnel()/activityFunnelStats() 把全部指标
|
||||
// 硬编码为 0(并且留着一行 `userMapper.selectCount(...)` 当作"活动数"的死代码),
|
||||
// 后台"活动漏斗"永远显示 0 —— 不是没数据,是根本没查。
|
||||
|
||||
/** 报名总记录数(去重键 = id;含 pending/rejected/cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countAllRegistrations();
|
||||
|
||||
/** 按状态统计报名数(pending / approved / rejected / cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE status = #{status} AND deleted_at IS NULL")
|
||||
long countRegistrationsByStatus(@Param("status") String status);
|
||||
|
||||
/** 已签到人数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations "
|
||||
+ "WHERE checkin_status = 'checked_in' AND deleted_at IS NULL")
|
||||
long countCheckedIn();
|
||||
|
||||
/** 按报名来源统计(chk_reg_source: user_self / matchmaker_referral / activity_followup / service_station / operator_assisted)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE source = #{source} AND deleted_at IS NULL")
|
||||
long countRegistrationsBySource(@Param("source") String source);
|
||||
|
||||
/** 去重报名人数(同一用户报多个活动只算一人)。 */
|
||||
@Select("SELECT COUNT(DISTINCT user_id) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countDistinctRegisteredUsers();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityStaffAssignment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 活动工作人员指派(Phase 15 签到数据范围)
|
||||
*
|
||||
* <p>与 {@code service_location_operators} 的范式一致:
|
||||
* 有指派记录的工作人员只能操作被指派的资源;没有任何指派记录的是平台级人员。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ActivityStaffAssignmentMapper extends BaseMapper<ActivityStaffAssignment> {
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_staff_assignments WHERE operator_id = #{operatorId}")
|
||||
int countAssignmentsForOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_staff_assignments "
|
||||
+ "WHERE activity_id = #{activityId} AND operator_id = #{operatorId}")
|
||||
int canOperateActivity(@Param("activityId") String activityId, @Param("operatorId") String operatorId);
|
||||
}
|
||||
@@ -16,8 +16,11 @@ import com.danyang.match.platform.activity.entity.ActivityOrganization;
|
||||
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper;
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import com.danyang.match.platform.verification.service.IdentityVerificationService;
|
||||
import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -34,6 +37,46 @@ public class ActivityService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
|
||||
|
||||
// ==================== Phase 14:多类型活动常量(必须与生产 V023 CHECK 白名单一致)====================
|
||||
// 守卫 ActivityConstraintGuardTest 会直接解析 db/migration/V*.sql 校验这些集合,
|
||||
// 任何与生产白名单的偏离都会让测试变红 —— 不要在此处"顺手"加值。
|
||||
|
||||
/** 合法活动类型 ⊆ 生产 chk_activity_type */
|
||||
public static final Set<String> VALID_ACTIVITY_TYPES = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL,
|
||||
Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION,
|
||||
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE,
|
||||
Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE,
|
||||
Constants.ACTIVITY_TYPE_ANTI_FRAUD);
|
||||
|
||||
/** 合法活动组织角色 ⊆ 生产 chk_org_role */
|
||||
public static final Set<String> VALID_ORG_ROLES = Set.of(
|
||||
Constants.ORG_ROLE_HOST,
|
||||
Constants.ORG_ROLE_ORGANIZER,
|
||||
Constants.ORG_ROLE_CO_ORGANIZER,
|
||||
Constants.ORG_ROLE_PARTNER,
|
||||
Constants.ORG_ROLE_VENUE_PARTNER);
|
||||
|
||||
/**
|
||||
* 需要**实名认证**才能报名的类型。
|
||||
*
|
||||
* <p>Gate 要求「matchmaking 与课堂/反诈类活动资格策略不同」:
|
||||
* 相亲类接触陌生人、必须实名;婚恋课堂 / 反诈宣传是公益普适科普,不设实名门槛。
|
||||
*/
|
||||
public static final Set<String> TYPES_REQUIRING_IDENTITY = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE);
|
||||
|
||||
/** 需要**本地资格**才能报名的类型(丹阳户籍/工作/居住) */
|
||||
public static final Set<String> TYPES_REQUIRING_LOCAL_ELIGIBILITY = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING);
|
||||
|
||||
/** 需要按**性别名额**分别扣减的类型(课堂/反诈类只按总名额) */
|
||||
public static final Set<String> TYPES_WITH_GENDER_QUOTA = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL);
|
||||
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
@@ -42,6 +85,10 @@ public class ActivityService {
|
||||
private final ActivityOrganizationMapper activityOrganizationMapper;
|
||||
private final ActivityCapacityGroupMapper capacityGroupMapper;
|
||||
private final OrganizationMapper organizationMapper;
|
||||
private final ActivityStaffAssignmentMapper staffAssignmentMapper;
|
||||
private final com.danyang.match.platform.shared.security.DataScopeGuard dataScopeGuard;
|
||||
private final IdentityVerificationService identityVerificationService;
|
||||
private final LocalEligibilityService localEligibilityService;
|
||||
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
|
||||
|
||||
/**
|
||||
@@ -144,13 +191,17 @@ public class ActivityService {
|
||||
detail.put("organizerOrgId", activity.getOrganizerOrgId());
|
||||
detail.put("coorganizerText", activity.getCoorganizerText());
|
||||
|
||||
// 报名统计
|
||||
// 报名统计(Phase 14: 改走 ActivityRegistrationMapper —— ActivityMapper 上的同名方法无 SQL 实现)
|
||||
int approvedCount = activityMapper.countApprovedRegistrations(activityId);
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
int remaining = registrationMapper.countRemainingCapacity(activityId);
|
||||
detail.put("approvedCount", approvedCount);
|
||||
detail.put("remainingSeats", Math.max(0, remaining));
|
||||
detail.put("isFull", remaining <= 0);
|
||||
|
||||
// V1.1 Phase 14: 类型与资格策略(小程序据此渲染差异化的资格提示)
|
||||
detail.put("activityType", activity.getActivityType());
|
||||
detail.put("eligibility", resolveEligibility(activity));
|
||||
|
||||
// 用户报名状态
|
||||
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> {
|
||||
detail.put("registered", true);
|
||||
@@ -173,37 +224,90 @@ public class ActivityService {
|
||||
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("活动未开放报名或已结束");
|
||||
}
|
||||
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
|
||||
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
|
||||
assertWithinRegistrationWindow(activity);
|
||||
|
||||
// 检查是否已报名
|
||||
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(existing -> {
|
||||
if (!Constants.REGISTRATION_STATUS_REJECTED.equals(existing.getStatus()) &&
|
||||
!Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.getStatus())) {
|
||||
throw new RuntimeException("已经报名过该活动");
|
||||
// 性别:容量分组与性别名额都依赖它
|
||||
String gender = genderOf(userId);
|
||||
|
||||
// 已报名记录的处置:pending/approved 直接拒绝;cancelled/rejected 必须**复用**原记录
|
||||
java.util.Optional<ActivityRegistration> existing =
|
||||
registrationMapper.findByActivityAndUser(activityId, userId);
|
||||
if (existing.isPresent()
|
||||
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
|
||||
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
|
||||
throw new RuntimeException("已经报名过该活动");
|
||||
}
|
||||
|
||||
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
|
||||
assertEligible(userId, activity);
|
||||
|
||||
// V1.1 Phase 14: 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 完全不生效)
|
||||
assertAgeInRange(userId, activity);
|
||||
|
||||
// 创建报名记录(needReview=false 时直接 approved —— 必须原子占位,否则并发超卖)
|
||||
boolean autoApprove = !Boolean.TRUE.equals(activity.getNeedReview());
|
||||
String status = autoApprove
|
||||
? Constants.REGISTRATION_STATUS_APPROVED
|
||||
: Constants.REGISTRATION_STATUS_PENDING;
|
||||
|
||||
ActivityRegistration registration;
|
||||
|
||||
if (existing.isPresent()) {
|
||||
// Phase 15 修复(必崩):生产 UNIQUE(activity_id, user_id) 一直存在,
|
||||
// 而"取消/拒绝后重新报名"走的却是 INSERT —— 用户取消一次即永远无法再报,直接撞唯一键 500。
|
||||
// 测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。此处改为复用原记录。
|
||||
ActivityRegistration old = existing.get();
|
||||
if (autoApprove) {
|
||||
String groupCode = occupyCapacityGroup(activityId, gender);
|
||||
try {
|
||||
if (registrationMapper.reuseIfCapacityAvailable(
|
||||
old.getId(), activityId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// pending 不占名额
|
||||
registrationMapper.reuseIfCapacityAvailable(
|
||||
old.getId(), activityId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, gender);
|
||||
}
|
||||
});
|
||||
registration = registrationMapper.selectById(old.getId());
|
||||
} else {
|
||||
ActivityRegistration fresh = new ActivityRegistration();
|
||||
fresh.setActivityId(activityId);
|
||||
fresh.setUserId(userId);
|
||||
fresh.setStatus(status);
|
||||
fresh.setRegisteredAt(Instant.now());
|
||||
fresh.setEmergencyContactName(emergencyContactName);
|
||||
fresh.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
|
||||
|
||||
// 检查名额(事务内原子检查)
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
if (autoApprove) {
|
||||
String groupCode = occupyCapacityGroup(activityId, gender);
|
||||
String newId = UUID.randomUUID().toString();
|
||||
try {
|
||||
if (registrationMapper.insertIfCapacityAvailable(
|
||||
newId, activityId, userId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
|
||||
throw e;
|
||||
}
|
||||
fresh.setId(newId);
|
||||
} else {
|
||||
// pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复)
|
||||
registrationMapper.insert(fresh);
|
||||
}
|
||||
registration = fresh;
|
||||
}
|
||||
|
||||
// 检查年龄范围
|
||||
if (activity.getMinAge() != null) {
|
||||
// TODO: 获取用户年龄并校验
|
||||
}
|
||||
|
||||
// 创建报名记录
|
||||
ActivityRegistration registration = new ActivityRegistration();
|
||||
registration.setActivityId(activityId);
|
||||
registration.setUserId(userId);
|
||||
registration.setStatus(activity.getNeedReview() ?
|
||||
Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setRegisteredAt(Instant.now());
|
||||
registration.setEmergencyContactName(emergencyContactName);
|
||||
registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
|
||||
registrationMapper.insert(registration);
|
||||
|
||||
// P17-T03: 线下活动风险检测
|
||||
try {
|
||||
String riskLevel = riskReferralService.detectActivityRisk(userId, emergencyContactName, null);
|
||||
@@ -235,16 +339,26 @@ public class ActivityService {
|
||||
throw new RuntimeException("当前状态不允许审核通过");
|
||||
}
|
||||
|
||||
// 再次检查名额(防止并发超卖)
|
||||
int remaining = activityMapper.countRemainingCapacity(registration.getActivityId());
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满,无法通过审核");
|
||||
}
|
||||
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
|
||||
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
|
||||
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setReviewedBy(operatorId);
|
||||
registration.setReviewedAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
// 原子审核:名额已满或状态已被他人变更时 affectedRows=0
|
||||
String gender = genderOf(registration.getUserId());
|
||||
String groupCode = occupyCapacityGroup(registration.getActivityId(), gender);
|
||||
int affected;
|
||||
try {
|
||||
affected = registrationMapper.approveIfCapacityAvailable(
|
||||
registrationId, registration.getActivityId(), operatorId, gender);
|
||||
} catch (RuntimeException e) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
|
||||
throw e;
|
||||
}
|
||||
if (affected == 0) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
|
||||
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
|
||||
}
|
||||
// 回读事务内最新状态(条件更新不回填实体)
|
||||
registration = registrationMapper.selectById(registrationId);
|
||||
|
||||
// 通知用户
|
||||
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
@@ -266,12 +380,14 @@ public class ActivityService {
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许审核");
|
||||
}
|
||||
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
|
||||
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_REJECTED);
|
||||
registration.setRejectReason(reason);
|
||||
registration.setReviewedBy(operatorId);
|
||||
registration.setReviewedAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
|
||||
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
|
||||
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
|
||||
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
|
||||
}
|
||||
registration = registrationMapper.selectById(registrationId);
|
||||
|
||||
// 通知用户
|
||||
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
@@ -294,9 +410,20 @@ public class ActivityService {
|
||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不能取消");
|
||||
}
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
|
||||
registrationMapper.updateById(registration);
|
||||
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
|
||||
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
|
||||
throw new RuntimeException("该报名状态已被他人变更,无法取消");
|
||||
}
|
||||
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
|
||||
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
String gender = genderOf(userId);
|
||||
String groupCode = resolveCapacityGroupCode(activityId, gender);
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
|
||||
}
|
||||
registration = registrationMapper.selectById(registration.getId());
|
||||
log.info("用户取消报名: userId={}, activityId={}", userId, activityId);
|
||||
// Phase 15 修复:取消是唯一没有审计日志的写操作,无法追溯"谁取消了报名"
|
||||
saveAuditLog(userId, "CANCEL_REGISTRATION", "activity_registration", registration.getId(), null, null);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@@ -307,8 +434,11 @@ public class ActivityService {
|
||||
return registrationMapper.findByUserId(userId).stream()
|
||||
.map(reg -> {
|
||||
Activity activity = activityMapper.selectById(reg.getActivityId());
|
||||
// Phase 14 修复:活动被软删后 selectById 返回 null,原实现直接 NPE
|
||||
if (activity == null) return null;
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("activityId", activity.getId());
|
||||
map.put("activityType", activity.getActivityType());
|
||||
map.put("title", activity.getTitle());
|
||||
map.put("startAt", activity.getStartAt());
|
||||
map.put("locationName", activity.getLocationName());
|
||||
@@ -318,6 +448,7 @@ public class ActivityService {
|
||||
map.put("reviewedAt", reg.getReviewedAt());
|
||||
return map;
|
||||
})
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -325,7 +456,9 @@ public class ActivityService {
|
||||
* 获取活动报名列表(后台)
|
||||
*/
|
||||
public List<Map<String, Object>> getRegistrations(String activityId) {
|
||||
return registrationMapper.findApprovedByActivity(activityId).stream()
|
||||
// Phase 15 修复(必崩):原实现只查 status='approved',后台审核列表因此
|
||||
// **永远看不到 pending 记录** —— 审核功能实际不可用(无待审项可点)。
|
||||
return registrationMapper.findByActivity(activityId).stream()
|
||||
.map(reg -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("registrationId", reg.getId());
|
||||
@@ -349,19 +482,37 @@ public class ActivityService {
|
||||
ActivityListItemDTO dto = new ActivityListItemDTO();
|
||||
dto.setActivityId(activity.getId());
|
||||
dto.setTitle(activity.getTitle());
|
||||
dto.setCoverFileId(activity.getCoverFileId());
|
||||
dto.setDescription(activity.getDescription());
|
||||
dto.setStatus(activity.getStatus());
|
||||
dto.setStartAt(activity.getStartAt());
|
||||
dto.setEndAt(activity.getEndAt());
|
||||
dto.setLocationName(activity.getLocationName());
|
||||
dto.setTotalLimit(activity.getTotalLimit());
|
||||
long approvedCount = registrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getActivityId, activity.getId())
|
||||
.eq(ActivityRegistration::getStatus, "approved"));
|
||||
// 注:性别在 user_profiles 中,此处简化处理(男女各半)
|
||||
dto.setMaleCount((int)(approvedCount / 2));
|
||||
dto.setFemaleCount((int)(approvedCount - approvedCount / 2));
|
||||
dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ?
|
||||
activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0));
|
||||
dto.setActivityType(activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType());
|
||||
|
||||
// Phase 14 修复:男女人数改为按 user_profiles.gender 真实统计。
|
||||
// 原实现为 approvedCount/2 与 approvedCount-approvedCount/2 —— 凭空编造的性别构成。
|
||||
int approvedCount = activityMapper.countApprovedRegistrations(activity.getId());
|
||||
int male = activityMapper.countApprovedByGender(activity.getId(), "male");
|
||||
int female = activityMapper.countApprovedByGender(activity.getId(), "female");
|
||||
dto.setMaleCount(male);
|
||||
dto.setFemaleCount(female);
|
||||
dto.setRegisteredCount(male + female);
|
||||
|
||||
// 剩余名额:总名额优先;性别名额启用时取"两个性别各自剩余"与总剩余的较小值
|
||||
int remainingByTotal = activity.getTotalLimit() == null ? approvedCount : activity.getTotalLimit() - approvedCount;
|
||||
int remaining = remainingByTotal;
|
||||
if (TYPES_WITH_GENDER_QUOTA.contains(dto.getActivityType())) {
|
||||
if (activity.getMaleLimit() != null) {
|
||||
remaining = Math.min(remaining, activity.getMaleLimit() - male);
|
||||
}
|
||||
if (activity.getFemaleLimit() != null) {
|
||||
remaining = Math.min(remaining, activity.getFemaleLimit() - female);
|
||||
}
|
||||
}
|
||||
dto.setRemainingSeats(Math.max(0, remaining));
|
||||
dto.setRegistered(false);
|
||||
return dto;
|
||||
}
|
||||
@@ -369,9 +520,14 @@ public class ActivityService {
|
||||
/** V1.1 Phase 14: 按活动类型过滤 */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
|
||||
List<Activity> activities = activityMapper.findOpenActivities();
|
||||
// Phase 14 修复:类型必须落在生产 chk_activity_type 白名单内,
|
||||
// 否则下推到 SQL 后只会静默返回空列表,掩盖前端传错值的问题。
|
||||
if (activityType == null || !VALID_ACTIVITY_TYPES.contains(activityType)) {
|
||||
throw new IllegalArgumentException("非法的活动类型: " + activityType);
|
||||
}
|
||||
// 原实现为「全表捞取 + 内存 filter」;改为下推到 SQL。
|
||||
List<Activity> activities = activityMapper.findOpenActivitiesByType(activityType);
|
||||
return activities.stream()
|
||||
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
|
||||
.map(a -> (Map<String, Object>) convertToMap(toListItemDTO(userId, a)))
|
||||
.toList();
|
||||
}
|
||||
@@ -385,8 +541,10 @@ public class ActivityService {
|
||||
m.put("totalLimit", dto.getTotalLimit());
|
||||
m.put("maleCount", dto.getMaleCount());
|
||||
m.put("femaleCount", dto.getFemaleCount());
|
||||
m.put("registeredCount", dto.getRegisteredCount());
|
||||
m.put("remainingSeats", dto.getRemainingSeats());
|
||||
m.put("registered", dto.isRegistered());
|
||||
m.put("activityType", dto.getActivityType());
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -426,33 +584,42 @@ public class ActivityService {
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setCoverFileId((String) body.get("coverFileId"));
|
||||
activity.setStartAt(Instant.parse((String) body.get("startAt")));
|
||||
activity.setEndAt(Instant.parse((String) body.get("endAt")));
|
||||
// Phase 14 修复:运营端表单允许缺省时间/名额/年龄,原实现直接 Instant.parse(null)
|
||||
// 与 ((Number) null).intValue() —— 后台一漏填就 NPE 500。此处按旧运营端端点兜底。
|
||||
activity.setStartAt(parseInstant(body.get("startAt"), Instant.now().plusSeconds(3600)));
|
||||
activity.setEndAt(parseInstant(body.get("endAt"), Instant.now().plusSeconds(7200)));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setTotalLimit(((Number) body.get("totalLimit")).intValue());
|
||||
activity.setMinAge(((Number) body.get("minAge")).shortValue());
|
||||
activity.setMaxAge(((Number) body.get("maxAge")).shortValue());
|
||||
activity.setTotalLimit(toInt(body.get("totalLimit"), 50));
|
||||
// minAge/maxAge 为空表示不设年龄门槛(null 参与比较会 NPE,故用包装类型)
|
||||
activity.setMinAge(toShort(body.get("minAge")));
|
||||
activity.setMaxAge(toShort(body.get("maxAge")));
|
||||
Object regStart = body.get("registrationStartAt");
|
||||
Object regEnd = body.get("registrationEndAt");
|
||||
activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null);
|
||||
activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null);
|
||||
activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString()));
|
||||
activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking"));
|
||||
// Phase 14 修复:活动类型必须落在生产 chk_activity_type 白名单内。
|
||||
// 原实现直接把请求体原样写库 —— 非法类型在 H2 测试库里静默通过,
|
||||
// 上到 PostgreSQL 才撞 CHECK 约束返回 500(典型的"只在 PG 上炸")。
|
||||
activity.setActivityType(requireActivityType((String) body.getOrDefault("activityType", "matchmaking")));
|
||||
activity.setRegistrationPolicy((String) body.get("registrationPolicy"));
|
||||
activity.setTargetGroup((String) body.get("targetGroup"));
|
||||
activity.setIndustryScope((String) body.get("industryScope"));
|
||||
activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString()));
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
// Phase 14 修复:主办组织此前只写进 activity_organizations,activities.organizer_org_id 留空,
|
||||
// 后台列表与详情里的"主办方"恒为空。
|
||||
String hostOrgId = (String) body.get("organizerOrgId");
|
||||
activity.setOrganizerOrgId(hostOrgId);
|
||||
activityMapper.insert(activity);
|
||||
|
||||
// 保存主组织
|
||||
String orgId = (String) body.get("organizerOrgId");
|
||||
if (orgId != null) {
|
||||
if (hostOrgId != null) {
|
||||
ActivityOrganization hostOrg = new ActivityOrganization();
|
||||
hostOrg.setActivityId(activity.getId());
|
||||
hostOrg.setOrganizationId(orgId);
|
||||
hostOrg.setOrganizationId(hostOrgId);
|
||||
hostOrg.setRole(Constants.ORG_ROLE_HOST);
|
||||
activityOrganizationMapper.insert(hostOrg);
|
||||
}
|
||||
@@ -486,7 +653,8 @@ public class ActivityService {
|
||||
ActivityOrganization org = new ActivityOrganization();
|
||||
org.setActivityId(activityId);
|
||||
org.setOrganizationId(organizationId);
|
||||
org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER);
|
||||
// Phase 14 修复:角色必须落在生产 chk_org_role 白名单内(同 D3 根因)
|
||||
org.setRole(requireOrgRole(role != null ? role : Constants.ORG_ROLE_PARTNER));
|
||||
activityOrganizationMapper.insert(org);
|
||||
log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role);
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
@@ -514,6 +682,198 @@ public class ActivityService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Phase 14:类型化资格策略 ====================
|
||||
|
||||
/**
|
||||
* 解析某活动的资格要求。
|
||||
*
|
||||
* <p><b>Gate 落点</b>:「matchmaking 与课堂/反诈类活动显示和资格策略不同」——
|
||||
* 相亲类要求实名 + 本地资格 + 性别名额;婚恋课堂 / 反诈宣传是公益科普,
|
||||
* 不设实名与本地资格门槛,容量也只按总名额计算。
|
||||
*/
|
||||
public Map<String, Object> resolveEligibility(Activity activity) {
|
||||
String type = activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("activityType", type);
|
||||
m.put("requiresIdentityVerified", TYPES_REQUIRING_IDENTITY.contains(type));
|
||||
m.put("requiresLocalEligibility", TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type));
|
||||
m.put("usesGenderQuota", TYPES_WITH_GENDER_QUOTA.contains(type));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报名前按活动类型校验资格。
|
||||
*
|
||||
* <p>此前 {@code registration_policy} 字段被写入却**从未被读取**,
|
||||
* 资格策略实际是空的(Gate 未落地);此处改为按类型强制执行。
|
||||
*/
|
||||
private void assertEligible(String userId, Activity activity) {
|
||||
String type = activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
|
||||
|
||||
if (TYPES_REQUIRING_IDENTITY.contains(type)) {
|
||||
Boolean verified = Boolean.FALSE;
|
||||
try {
|
||||
Object v = identityVerificationService.getIdentityStatus(userId).get("verified");
|
||||
verified = Boolean.TRUE.equals(v);
|
||||
} catch (Exception e) {
|
||||
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!verified) {
|
||||
throw new RuntimeException("该活动需完成实名认证后方可报名");
|
||||
}
|
||||
}
|
||||
|
||||
if (TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type)) {
|
||||
String st = null;
|
||||
try {
|
||||
st = localEligibilityService.getStatus(userId).getVerificationStatus();
|
||||
} catch (Exception e) {
|
||||
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
|
||||
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 此前完全不生效) */
|
||||
private void assertAgeInRange(String userId, Activity activity) {
|
||||
if (activity.getMinAge() == null && activity.getMaxAge() == null) return;
|
||||
java.time.LocalDate birth = userProfileMapper.findByUserId(userId)
|
||||
.map(p -> p.getBirthDate()).orElse(null);
|
||||
if (birth == null) return; // 资料不全时不阻断报名
|
||||
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
|
||||
if (activity.getMinAge() != null && age < activity.getMinAge()) {
|
||||
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
|
||||
}
|
||||
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
|
||||
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验并归一化活动类型;非法值直接拒绝,避免撞生产 CHECK 约束 */
|
||||
private String requireActivityType(String type) {
|
||||
if (type == null || !VALID_ACTIVITY_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException("非法的活动类型: " + type
|
||||
+ ",允许值:" + new TreeSet<>(VALID_ACTIVITY_TYPES));
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 校验并归一化活动组织角色;非法值直接拒绝,避免撞生产 CHECK 约束 */
|
||||
private String requireOrgRole(String role) {
|
||||
if (role == null || !VALID_ORG_ROLES.contains(role)) {
|
||||
throw new IllegalArgumentException("非法的活动组织角色: " + role
|
||||
+ ",允许值:" + new TreeSet<>(VALID_ORG_ROLES));
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
/** 读取用户性别(用于性别名额扣减);资料缺失时返回 null,退化为按总名额约束 */
|
||||
private String genderOf(String userId) {
|
||||
return userProfileMapper.findByUserId(userId).map(p -> p.getGender()).orElse(null);
|
||||
}
|
||||
|
||||
// ==================== Phase 15:报名窗口、数据范围与容量分组 ====================
|
||||
|
||||
/** 报名窗口校验:registration_start_at / registration_end_at 此前完全不生效 */
|
||||
private void assertWithinRegistrationWindow(Activity activity) {
|
||||
Instant now = Instant.now();
|
||||
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
|
||||
throw new RuntimeException("活动报名尚未开始");
|
||||
}
|
||||
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
|
||||
throw new RuntimeException("活动报名已截止");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动维度的数据范围校验(计划项③)。
|
||||
*
|
||||
* <p>服务站操作员只能操作被显式指派的活动;主办/协办组织关系不授予任何操作权限(INV-020)。
|
||||
* 没有任何指派记录的工作人员视为平台级人员,不受此限制。
|
||||
*/
|
||||
private void assertActivityScope(String activityId, String operatorId, String message) {
|
||||
if (operatorId == null) {
|
||||
dataScopeGuard.deny(operatorId, "activity", activityId, message);
|
||||
}
|
||||
if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0
|
||||
&& staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) {
|
||||
dataScopeGuard.deny(operatorId, "activity", activityId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析该用户应落入的容量分组;活动未配置分组时返回 null(表示不启用分组约束)。
|
||||
*
|
||||
* <p>分组即配置:只要活动存在 {@code activity_capacity_groups} 记录就启用分组容量,
|
||||
* 无需额外的开关列(生产 V023 没有 {@code capacity_group_enabled})。
|
||||
*/
|
||||
private String resolveCapacityGroupCode(String activityId, String gender) {
|
||||
java.util.List<ActivityCapacityGroup> groups = capacityGroupMapper.findByActivityId(activityId);
|
||||
if (groups.isEmpty()) return null;
|
||||
for (ActivityCapacityGroup g : groups) {
|
||||
if (g.getGroupCode() != null && g.getGroupCode().equals(gender)) return g.getGroupCode();
|
||||
}
|
||||
for (ActivityCapacityGroup g : groups) {
|
||||
if ("general".equals(g.getGroupCode())) return "general";
|
||||
}
|
||||
return null; // 有分组但无匹配 → 不施加分组约束(避免误拒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子占用容量分组名额。
|
||||
*
|
||||
* <p>Phase 15 修复:容量分组此前只"落库回读",{@code approved_count} 永远是 0 ——
|
||||
* 运营配了分组容量也完全不生效。此处让分组真正参与扣减。
|
||||
*
|
||||
* @return 被占用的 groupCode;null 表示该活动未启用分组约束
|
||||
*/
|
||||
private String occupyCapacityGroup(String activityId, String gender) {
|
||||
String groupCode = resolveCapacityGroupCode(activityId, gender);
|
||||
if (groupCode == null) return null;
|
||||
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
|
||||
throw new RuntimeException("该分组名额已满");
|
||||
}
|
||||
return groupCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 容忍 null / 时间戳 / 本地无时区文本 / ISO 文本的时间字段。
|
||||
*
|
||||
* <p>后台 el-date-picker 默认输出 {@code 2026-09-20T14:00:00}(无时区偏移),
|
||||
* 直接 {@code Instant.parse} 会抛异常;若简单兜底成"现在",运营填的时间会被**静默丢弃** ——
|
||||
* 比报错更危险。故无偏移时按服务器时区解释。
|
||||
*/
|
||||
private static Instant parseInstant(Object raw, Instant fallback) {
|
||||
if (raw == null) return fallback;
|
||||
try {
|
||||
if (raw instanceof Number n) return Instant.ofEpochMilli(n.longValue());
|
||||
String s = raw.toString().trim();
|
||||
if (s.isEmpty()) return fallback;
|
||||
try {
|
||||
return Instant.parse(s);
|
||||
} catch (RuntimeException ignored) {
|
||||
return java.time.LocalDateTime.parse(s)
|
||||
.atZone(java.time.ZoneId.systemDefault()).toInstant();
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("非法时间字段,回落默认值: raw={}, err={}", raw, e.getMessage());
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static int toInt(Object raw, int fallback) {
|
||||
return raw instanceof Number n ? n.intValue() : fallback;
|
||||
}
|
||||
|
||||
/** null 表示不设年龄门槛 —— 不能用 0 兜底,否则 age >= minAge 恒成立反而放行 */
|
||||
private static Short toShort(Object raw) {
|
||||
return raw instanceof Number n ? n.shortValue() : null;
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
|
||||
@@ -4,10 +4,14 @@ import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -16,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
@@ -30,6 +35,13 @@ import java.util.concurrent.TimeUnit;
|
||||
@RequiredArgsConstructor
|
||||
public class CheckinService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CheckinService.class);
|
||||
|
||||
/** Redis 中标记"已签发但未消费"的签到码 */
|
||||
private static final String TOKEN_STATE_ISSUED = "issued";
|
||||
/** Redis 中标记"已消费"的签到码(一次性,不可重放) */
|
||||
private static final String TOKEN_STATE_USED = "used";
|
||||
|
||||
@Value("${checkin.token.secret:danyang-match-checkin-secret-change-in-production}")
|
||||
private String tokenSecret;
|
||||
|
||||
@@ -38,15 +50,16 @@ public class CheckinService {
|
||||
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityStaffAssignmentMapper staffAssignmentMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
/**
|
||||
* 生成签到Token(短时有效,二维码内容)
|
||||
* Token格式: base64(userId:activityId:timestamp: HMAC)
|
||||
* Token格式: base64(userId:activityId:timestamp:HMAC)
|
||||
*/
|
||||
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
||||
// 检查是否有有效的报名记录
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
||||
|
||||
@@ -58,24 +71,21 @@ public class CheckinService {
|
||||
throw new RuntimeException("您已经签到过了");
|
||||
}
|
||||
|
||||
// 检查活动是否在进行中
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new RuntimeException("活动不存在");
|
||||
}
|
||||
Activity activity = requireCheckableActivity(activityId);
|
||||
|
||||
Instant now = Instant.now();
|
||||
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
|
||||
throw new RuntimeException("活动未开始或已结束,无法签到");
|
||||
}
|
||||
|
||||
// 生成签名Token
|
||||
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
|
||||
String signature = sign(payload);
|
||||
String token = Base64.getEncoder().encodeToString((payload + ":" + signature).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 缓存Token(防止重放攻击)
|
||||
String cacheKey = "checkin:token:" + token;
|
||||
redisTemplate.opsForValue().set(cacheKey, "used", tokenTtlMinutes, TimeUnit.MINUTES);
|
||||
// Phase 15 修复(必崩):此前此处写入的值为 "used",而 scanCheckin 见到 "used" 立即拒绝 ——
|
||||
// 结果是**每一个刚生成的签到码都自判已使用**,扫码签到 100% 失败。
|
||||
// 生成时只能标记 issued,真正的消费标记在扫码成功后写入。
|
||||
redisTemplate.opsForValue().set(tokenKey(token), TOKEN_STATE_ISSUED, tokenTtlMinutes, TimeUnit.MINUTES);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
@@ -87,125 +97,188 @@ public class CheckinService {
|
||||
|
||||
/**
|
||||
* 工作人员扫码签到
|
||||
*
|
||||
* <p>幂等:同一签到码重复扫码、或用户已签到时返回 {@code alreadyCheckedIn=true} 且不重复记审计。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> scanCheckin(String token, String staffOperatorId) {
|
||||
// 1. 解码Token
|
||||
String decoded;
|
||||
try {
|
||||
decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("无效的签到码");
|
||||
throw new IllegalArgumentException("无效的签到码");
|
||||
}
|
||||
|
||||
String[] parts = decoded.split(":");
|
||||
if (parts.length != 4) {
|
||||
throw new RuntimeException("无效的签到码格式");
|
||||
throw new IllegalArgumentException("无效的签到码格式");
|
||||
}
|
||||
|
||||
String userId = parts[0];
|
||||
String activityId = parts[1];
|
||||
long timestamp = Long.parseLong(parts[2]);
|
||||
long timestamp;
|
||||
try {
|
||||
timestamp = Long.parseLong(parts[2]);
|
||||
} catch (NumberFormatException e) {
|
||||
// Phase 15 修复:此前非法时间戳会抛 NumberFormatException → 500 而非 400
|
||||
throw new IllegalArgumentException("无效的签到码格式");
|
||||
}
|
||||
String signature = parts[3];
|
||||
|
||||
// 2. 验证签名
|
||||
String expectedPayload = userId + ":" + activityId + ":" + timestamp;
|
||||
String expectedSignature = sign(expectedPayload);
|
||||
if (!expectedSignature.equals(signature)) {
|
||||
throw new RuntimeException("签到码签名验证失败");
|
||||
// 恒定时间比较,避免通过响应时间侧信道逐字节爆破签名
|
||||
if (!MessageDigest.isEqual(sign(userId + ":" + activityId + ":" + timestamp).getBytes(StandardCharsets.UTF_8),
|
||||
signature.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new IllegalArgumentException("签到码签名验证失败");
|
||||
}
|
||||
|
||||
// 3. 检查Token是否已使用(防重放)
|
||||
String cacheKey = "checkin:token:" + token;
|
||||
String cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if ("used".equals(cached)) {
|
||||
throw new RuntimeException("该签到码已使用过");
|
||||
if (Instant.now().isAfter(Instant.ofEpochMilli(timestamp).plusSeconds(tokenTtlMinutes * 60L))) {
|
||||
throw new IllegalArgumentException("签到码已过期");
|
||||
}
|
||||
|
||||
// 4. 检查Token是否过期
|
||||
Instant tokenTime = Instant.ofEpochMilli(timestamp);
|
||||
if (Instant.now().isAfter(tokenTime.plusSeconds(tokenTtlMinutes * 60L))) {
|
||||
throw new RuntimeException("签到码已过期");
|
||||
}
|
||||
// Gate「签到不可跨活动复用」:码里带的 activityId 决定作用域,
|
||||
// 工作人员必须对该活动有操作权限(显式指派),否则拒绝并记审计。
|
||||
assertCheckinScope(activityId, staffOperatorId);
|
||||
|
||||
// 5. 检查报名记录
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
|
||||
.orElseThrow(() -> new IllegalArgumentException("未找到报名记录"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未审核通过,无法签到");
|
||||
throw new IllegalArgumentException("报名未审核通过,无法签到");
|
||||
}
|
||||
|
||||
String cacheKey = tokenKey(token);
|
||||
|
||||
// 幂等:无论拿着哪个码,只要该用户已签到就直接返回成功,避免现场重复扫码报错
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("该用户已经签到");
|
||||
redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS);
|
||||
Map<String, Object> idempotent = new HashMap<>();
|
||||
idempotent.put("success", true);
|
||||
idempotent.put("userId", userId);
|
||||
idempotent.put("activityId", activityId);
|
||||
idempotent.put("alreadyCheckedIn", true);
|
||||
idempotent.put("checkedInAt", registration.getCheckinAt());
|
||||
return idempotent;
|
||||
}
|
||||
|
||||
// 6. 执行签到
|
||||
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
|
||||
registration.setCheckinAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
// 一次性:已消费的码不可被**换人**重放
|
||||
if (TOKEN_STATE_USED.equals(redisTemplate.opsForValue().get(cacheKey))) {
|
||||
throw new IllegalArgumentException("该签到码已使用过");
|
||||
}
|
||||
|
||||
// 7. 标记Token为已使用
|
||||
redisTemplate.opsForValue().set(cacheKey, "used", 1, TimeUnit.HOURS);
|
||||
// 原子签到:affectedRows=0 即并发下已被他人签到
|
||||
int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId());
|
||||
|
||||
// 8. 审计
|
||||
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("userId", userId);
|
||||
result.put("activityId", activityId);
|
||||
result.put("checkedInAt", Instant.now());
|
||||
result.put("alreadyCheckedIn", affected == 0);
|
||||
if (affected > 0) {
|
||||
Instant checkedInAt = Instant.now();
|
||||
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
result.put("checkedInAt", checkedInAt);
|
||||
} else {
|
||||
ActivityRegistration latest = registrationMapper.selectById(registration.getId());
|
||||
result.put("checkedInAt", latest == null ? null : latest.getCheckinAt());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工签到(工作人员手动输入)
|
||||
*
|
||||
* <p>Phase 15 修复:原实现既无活动存在性/状态校验,也无工作人员数据范围校验 ——
|
||||
* 任意持 {@code activity.checkin} 权限的人可以对任意活动的任意用户签到(违反 INV-020 精神)。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> manualCheckin(String userId, String activityId, String staffOperatorId) {
|
||||
requireCheckableActivity(activityId);
|
||||
assertCheckinScope(activityId, staffOperatorId);
|
||||
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
|
||||
.orElseThrow(() -> new IllegalArgumentException("未找到报名记录"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未审核通过");
|
||||
throw new IllegalArgumentException("报名未审核通过,无法签到");
|
||||
}
|
||||
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("该用户已经签到");
|
||||
}
|
||||
|
||||
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
|
||||
registration.setCheckinAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
|
||||
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("checkedInAt", Instant.now());
|
||||
result.put("userId", userId);
|
||||
result.put("activityId", activityId);
|
||||
result.put("alreadyCheckedIn", affected == 0);
|
||||
if (affected > 0) {
|
||||
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
result.put("checkedInAt", Instant.now());
|
||||
} else {
|
||||
ActivityRegistration latest = registrationMapper.selectById(registration.getId());
|
||||
result.put("checkedInAt", latest == null ? null : latest.getCheckinAt());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动签到统计
|
||||
*
|
||||
* <p>Phase 15 修复:活动不存在/已软删时原实现直接 NPE;
|
||||
* 且 {@code remainingCount} 用「总名额 − 已签到」计算,语义上是"空座"而非"未签到人数"。
|
||||
*/
|
||||
public Map<String, Object> getCheckinStats(String activityId) {
|
||||
ActivityRegistrationMapper regMapper = registrationMapper;
|
||||
// 统计已签到人数
|
||||
long checkedInCount = registrationMapper.findApprovedByActivity(activityId).stream()
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new IllegalArgumentException("活动不存在");
|
||||
}
|
||||
|
||||
java.util.List<ActivityRegistration> approved = registrationMapper.findApprovedByActivity(activityId);
|
||||
long checkedInCount = approved.stream()
|
||||
.filter(r -> Constants.CHECKIN_STATUS_CHECKED_IN.equals(r.getCheckinStatus()))
|
||||
.count();
|
||||
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("activityId", activityId);
|
||||
stats.put("totalApproved", registrationMapper.findApprovedByActivity(activityId).size());
|
||||
stats.put("totalApproved", approved.size());
|
||||
stats.put("checkedInCount", checkedInCount);
|
||||
stats.put("remainingCount", Math.max(0, (activity.getTotalLimit() != null ? activity.getTotalLimit() : 0) - checkedInCount));
|
||||
// 语义修正:运营关心的是"还有多少已通过的人没到场"
|
||||
stats.put("notCheckedInCount", approved.size() - checkedInCount);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到数据范围校验(Phase 15 计划项③)。
|
||||
*
|
||||
* <p>服务站操作员只能操作**被显式指派**的活动;主办/协办组织关系不授予任何操作权限(INV-020)。
|
||||
* 没有任何指派记录的工作人员视为平台级活动管理员,不受此限制。
|
||||
*/
|
||||
private void assertCheckinScope(String activityId, String operatorId) {
|
||||
if (operatorId == null) {
|
||||
dataScopeGuard.deny(operatorId, "activity", activityId, "无法识别签到操作人");
|
||||
}
|
||||
if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0
|
||||
&& staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) {
|
||||
dataScopeGuard.deny(operatorId, "activity", activityId, "只能为被指派的活动执行签到");
|
||||
}
|
||||
}
|
||||
|
||||
/** 活动必须存在且未被取消,否则签到无意义 */
|
||||
private Activity requireCheckableActivity(String activityId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new IllegalArgumentException("活动不存在");
|
||||
}
|
||||
if (Constants.ACTIVITY_STATUS_CANCELLED.equals(activity.getStatus())) {
|
||||
throw new IllegalArgumentException("活动已取消,无法签到");
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static String tokenKey(String token) {
|
||||
return "checkin:token:" + token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算HMAC-SHA256签名
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.danyang.match.platform.activity_mutual.controller;
|
||||
|
||||
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
|
||||
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -38,6 +39,7 @@ public class ActivityMutualConsentController {
|
||||
* P16-T04: 创建互选同意记录
|
||||
* POST /api/v1/activities/{id}/mutual/consent
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/consent")
|
||||
public ApiResponse<?> createConsent(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@@ -52,6 +54,7 @@ public class ActivityMutualConsentController {
|
||||
* P16-T05/T06: 用户确认/拒绝开启沟通
|
||||
* POST /api/v1/activities/{id}/mutual/confirm
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/confirm")
|
||||
public ApiResponse<Map<String, Object>> confirmConnection(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@@ -66,6 +69,7 @@ public class ActivityMutualConsentController {
|
||||
* P16-T12: 用户拒绝开启沟通
|
||||
* POST /api/v1/activities/{id}/mutual/decline
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/decline")
|
||||
public ApiResponse<Map<String, Object>> declineConnection(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
@@ -23,6 +23,10 @@ public class ActivityMutualConsent extends BaseEntity {
|
||||
private String userAStatus;
|
||||
/** B的同意状态:pending / accepted / declined */
|
||||
private String userBStatus;
|
||||
/** 双方都同意时的时间戳 */
|
||||
/** 双方都同意时的时间戳(非 NULL 即代表已双确认,exactly-once 的判定依据) */
|
||||
private Instant bothConfirmedAt;
|
||||
/** 二阶段同意截止时间(V041);NULL 表示不限制。超时后不能再推动成双确认 */
|
||||
private Instant expiresAt;
|
||||
/** 双确认形成的 Match Episode id(V041),仅用于溯源 */
|
||||
private String matchId;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -12,20 +13,98 @@ import java.util.Optional;
|
||||
@Mapper
|
||||
public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualConsent> {
|
||||
|
||||
// Phase 16 修复:以下查询此前全部缺少 deleted_at IS NULL。
|
||||
// V037 已为该表补上 deleted_at,软删后的同意记录仍会被检出,
|
||||
// 导致已作废的互选关系继续参与判断。
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE activity_id = #{activityId} " +
|
||||
"AND ((user_a_id = #{userId} AND user_b_id = #{partnerId}) " +
|
||||
"OR (user_a_id = #{partnerId} AND user_b_id = #{userId})) " +
|
||||
"AND deleted_at IS NULL " +
|
||||
"LIMIT 1")
|
||||
Optional<ActivityMutualConsent> findByActivityPair(@Param("activityId") String activityId,
|
||||
@Param("userId") String userId,
|
||||
@Param("partnerId") String partnerId);
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
|
||||
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted' " +
|
||||
"AND deleted_at IS NULL")
|
||||
List<ActivityMutualConsent> findBothConfirmedByActivity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
|
||||
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) " +
|
||||
"AND user_a_status = 'accepted' AND user_b_status = 'accepted' " +
|
||||
"AND deleted_at IS NULL")
|
||||
List<ActivityMutualConsent> findBothConfirmedByUser(String userId);
|
||||
|
||||
/**
|
||||
* 原子确认本方(Phase 16 核心)。
|
||||
*
|
||||
* <p>单条 SQL 内完成三件事,避免「先读后写」在并发下丢更新:
|
||||
* <ol>
|
||||
* <li>只把「调用方所在侧」置为 accepted(CASE WHEN 定位本侧);</li>
|
||||
* <li>若两侧因此都变成 accepted,则同一语句写入 both_confirmed_at = NOW();</li>
|
||||
* <li>WHERE 同时要求:未软删、尚未双确认、未过期、本侧当前为 pending。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>exactly-once 依据</b>:把 both_confirmed_at 从 NULL 改成非 NULL 只能有一个事务成功
|
||||
* (其它并发事务的 WHERE both_confirmed_at IS NULL 不再成立),
|
||||
* 因此只有 affectedRows == 1 且回读 both_confirmed_at != null 的那一方负责创建 Match。
|
||||
*
|
||||
* @return 受影响行数:1 = 本次写入生效;0 = 已双确认 / 本方已表态 / 已过期 / 已软删
|
||||
*/
|
||||
@Update("UPDATE activity_mutual_connection_consents SET " +
|
||||
" user_a_status = CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END, " +
|
||||
" user_b_status = CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END, " +
|
||||
" both_confirmed_at = CASE WHEN " +
|
||||
" (CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END) = 'accepted' " +
|
||||
" AND (CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END) = 'accepted' " +
|
||||
" THEN NOW() ELSE NULL END, " +
|
||||
" updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND deleted_at IS NULL " +
|
||||
" AND both_confirmed_at IS NULL " +
|
||||
" AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||
" AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'")
|
||||
int confirmSideIfPending(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
/**
|
||||
* 原子拒绝本方。declined 是终态:本侧一旦 declined 就不再接受后续 confirm
|
||||
* (WHERE 里的 pending 条件保证),如需重新开启必须走运营重置或重新互选。
|
||||
*/
|
||||
@Update("UPDATE activity_mutual_connection_consents SET " +
|
||||
" user_a_status = CASE WHEN user_a_id = #{userId} THEN 'declined' ELSE user_a_status END, " +
|
||||
" user_b_status = CASE WHEN user_b_id = #{userId} THEN 'declined' ELSE user_b_status END, " +
|
||||
" updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND deleted_at IS NULL " +
|
||||
" AND both_confirmed_at IS NULL " +
|
||||
" AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'")
|
||||
int declineSideIfPending(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
/** 记录双确认后形成的 Match,便于溯源与幂等回查 */
|
||||
@Update("UPDATE activity_mutual_connection_consents SET match_id = #{matchId}, updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND deleted_at IS NULL")
|
||||
int bindMatch(@Param("id") String id, @Param("matchId") String matchId);
|
||||
|
||||
// ==================== Phase 20:互选口径统计 ====================
|
||||
|
||||
/**
|
||||
* **双向已确认**的互选对数(Gate「活动10对互选 ≠ 10 对 Success」的分母侧)。
|
||||
*
|
||||
* <p>「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。
|
||||
* Match 的 {@code activity_mutual} 来源行只有在双确认后才创建,
|
||||
* 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事:
|
||||
* 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。
|
||||
*
|
||||
* <p>去重键:{@code id}(同一对用户在同一活动内只有一行)。
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM activity_mutual_connection_consents "
|
||||
+ "WHERE user_a_status = 'accepted' AND user_b_status = 'accepted' AND deleted_at IS NULL")
|
||||
long countBothConfirmed();
|
||||
|
||||
/** 仅单侧表态的记录数(用于观察"已表达但未配成"的漏斗中间态)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_mutual_connection_consents WHERE deleted_at IS NULL "
|
||||
+ "AND (user_a_status = 'accepted' OR user_b_status = 'accepted') "
|
||||
+ "AND NOT (user_a_status = 'accepted' AND user_b_status = 'accepted')")
|
||||
long countOneSidedAccepted();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package com.danyang.match.platform.activity_mutual.service;
|
||||
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
|
||||
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
@@ -17,7 +19,7 @@ import java.util.*;
|
||||
public class ActivityMutualConsentService {
|
||||
|
||||
private final ActivityMutualConsentMapper consentMapper;
|
||||
private final com.danyang.match.platform.relationship.mapper.MatchMapper matchMapper;
|
||||
private final RelationshipService relationshipService;
|
||||
|
||||
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
|
||||
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
|
||||
@@ -47,32 +49,60 @@ public class ActivityMutualConsentService {
|
||||
return consent;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
if (userId.equals(consent.getUserAId())) {
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
} else {
|
||||
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
}
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
|
||||
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
||||
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
||||
// return 1 表示该事务是「把双确认落定」的那一方。
|
||||
int affected = consentMapper.confirmSideIfPending(consent.getId(), userId);
|
||||
|
||||
// 回读权威状态(可能受并发另一方提交影响)
|
||||
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
|
||||
|
||||
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
||||
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
||||
boolean amA = userId.equals(consent.getUserAId());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("myStatus", userId.equals(consent.getUserAId()) ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
|
||||
result.put("partnerStatus", userId.equals(consent.getUserAId()) ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
|
||||
result.put("myStatus", amA ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
|
||||
result.put("partnerStatus", amA ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
|
||||
|
||||
if (aAccepted && bAccepted) {
|
||||
consent.setBothConfirmedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
result.put("bothConfirmed", true);
|
||||
log.info("双方确认活动互选: activityId={}, userA={}, userB={}", activityId, consent.getUserAId(), consent.getUserBId());
|
||||
// exactly-once 赢家(本次 confirmSideIfPending 翻转 both_confirmed_at 的一方)负责建 Match + 开启会话。
|
||||
// 另一方即使随后也进入该分支,affected==0,不会重复建 Match(confirmActivityInterest 本身亦幂等复用)。
|
||||
if (affected == 1 && consent.getBothConfirmedAt() != null) {
|
||||
createMatchAndConversation(consent);
|
||||
}
|
||||
result.put("matchCreated", true);
|
||||
} else {
|
||||
result.put("bothConfirmed", false);
|
||||
result.put("matchCreated", false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 双确认后形成 Match 并开启 Conversation(Gate:互选确认后双方可沟通)。
|
||||
* 通过统一的 {@code RelationshipService.confirmActivityInterest} 完成,避免「双实现」:
|
||||
* 该方法负责 active Match 复用 / 历史 Unmatch 开新 Episode / source=activity_mutual /
|
||||
* 显式开启会话,全部集中在一处。
|
||||
*/
|
||||
private void createMatchAndConversation(ActivityMutualConsent consent) {
|
||||
Map<String, Object> matchResult = relationshipService.confirmActivityInterest(
|
||||
consent.getUserAId(), consent.getUserBId());
|
||||
Object matchIdObj = matchResult.get("matchId");
|
||||
if (matchIdObj != null) {
|
||||
consentMapper.bindMatch(consent.getId(), String.valueOf(matchIdObj));
|
||||
}
|
||||
log.info("活动互选双确认后形成Match并开启会话: activityId={}, userA={}, userB={}, matchId={}",
|
||||
consent.getActivityId(), consent.getUserAId(), consent.getUserBId(), matchIdObj);
|
||||
}
|
||||
|
||||
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
@@ -89,10 +119,6 @@ public class ActivityMutualConsentService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean hasActiveMatch(String userId, String partnerId) {
|
||||
return matchMapper.findActiveMatch(userId, partnerId).isPresent();
|
||||
}
|
||||
|
||||
public List<MutualInterestResultDTO> getMyMutualResults(String userId) {
|
||||
List<ActivityMutualConsent> consents = consentMapper.findBothConfirmedByUser(userId);
|
||||
return consents.stream().map(c -> toResultDTO(c, c.getActivityId())).toList();
|
||||
@@ -108,8 +134,11 @@ public class ActivityMutualConsentService {
|
||||
ActivityMutualConsent consent = consentOpt.get();
|
||||
boolean myAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()))
|
||||
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()));
|
||||
boolean partnerAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
|
||||
boolean partnerSideAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
|
||||
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()));
|
||||
// INV-006 单向意向保密:只有本侧也已确认,才允许回显对方是否已确认。
|
||||
// 否则 A 已确认、B 未确认时,B 能通过此接口探知 A 的意向,违反「单向意向不暴露」。
|
||||
boolean partnerAccepted = myAccepted && partnerSideAccepted;
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("exists", true);
|
||||
result.put("myStatus", myAccepted ? "accepted" : "pending");
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.match.entity.RecommendationRecord;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.messaging.entity.Conversation;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.content.entity.ContentArticle;
|
||||
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.risk.entity.Report;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.shared.exception.BusinessException;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.content.entity.ContentArticle;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgram;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -43,43 +32,70 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Analytics 服务
|
||||
* 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据
|
||||
* Analytics 服务 —— 对外提供各统计视图的数据装配。
|
||||
*
|
||||
* <p><b>Phase 20 重构说明</b>:本类不再**自己**计算任何指标数字,
|
||||
* 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。
|
||||
*
|
||||
* <p>修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标,
|
||||
* 且互不相同:
|
||||
* <ul>
|
||||
* <li>"实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回),
|
||||
* 把「资料还在填」和「被退回待改」都算成了已实名;</li>
|
||||
* <li>"开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;</li>
|
||||
* <li>"Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配
|
||||
* 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;</li>
|
||||
* <li>活动漏斗全部硬编码为 0,后台永远显示 0。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>字段名兼容性</b>:admin-web 已消费的响应字段名({@code verifiedCount} /
|
||||
* {@code totalLiked} / {@code chatsStarted} 等)一律保留,改动只发生在**取值来源**上,
|
||||
* 不破坏前端契约。新增的按来源分离字段为增量添加。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsService {
|
||||
|
||||
/** 唯一口径来源。 */
|
||||
private final AnalyticsMetricsCalculator metrics;
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final RelationshipSuccessRequestMapper successRequestMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
private final HomeBannerMapper homeBannerMapper;
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
private final ActivityMutualConsentMapper activityMutualConsentMapper;
|
||||
private final ContentArticleMapper contentArticleMapper;
|
||||
private final ServiceProgramMapper serviceProgramMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
/**
|
||||
* 综合看板。
|
||||
*
|
||||
* <p>与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径,
|
||||
* 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。
|
||||
*/
|
||||
public Map<String, Object> getDashboardStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.putAll(userBasicStats());
|
||||
stats.putAll(matchFunnelStats());
|
||||
stats.putAll(safetyStats());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalUsers", metrics.registeredUsers());
|
||||
stats.put("verifiedUsers", metrics.realNameVerifiedUsers());
|
||||
stats.put("approvedProfiles", metrics.profileApprovedUsers());
|
||||
stats.put("activeUsers7d", metrics.activeUsers7d());
|
||||
|
||||
stats.put("totalLikes", metrics.likes());
|
||||
stats.put("totalMatches", metrics.matches());
|
||||
stats.put("chatsStarted", metrics.chatsStarted());
|
||||
stats.put("gettingToKnow", matchMapper.countByStatus("getting_to_know"));
|
||||
stats.put("successPairs", metrics.successfulMatches());
|
||||
|
||||
stats.putAll(activityFunnelStats());
|
||||
stats.putAll(safetyStats());
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -88,45 +104,46 @@ public class AnalyticsService {
|
||||
public Map<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 各阶段总量
|
||||
long totalRegistered = userMapper.selectCount(null);
|
||||
long verifiedCount = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.ne(UserProfile::getProfileStatus, "rejected"));
|
||||
long profileApproved = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.eq(UserProfile::getProfileStatus, "approved"));
|
||||
long totalRegistered = metrics.registeredUsers();
|
||||
// Phase 20 D1:实名口径改为 user_identities.verification_status='verified'。
|
||||
long verifiedCount = metrics.realNameVerifiedUsers();
|
||||
long profileApproved = metrics.profileApprovedUsers();
|
||||
// activeUsers 在本视图中的语义是「所选时间范围内登录过」,
|
||||
// 因此使用范围起点而非固定 7 天窗口(与 Dashboard 的 activeUsers7d 语义不同,勿混用)。
|
||||
// deleted_at 必须显式过滤:BaseEntity 无 @TableLogic,selectCount 不会自动排除软删。
|
||||
long activeUsers = userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()));
|
||||
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant())
|
||||
.isNull(User::getDeletedAt));
|
||||
|
||||
result.put("totalRegistered", totalRegistered);
|
||||
result.put("verifiedCount", verifiedCount);
|
||||
result.put("profileApproved", profileApproved);
|
||||
result.put("activeUsers", activeUsers);
|
||||
|
||||
// 转化率
|
||||
result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%");
|
||||
result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%");
|
||||
result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%");
|
||||
result.put("verifyRate", rate(verifiedCount, totalRegistered));
|
||||
result.put("approvalRate", rate(profileApproved, verifiedCount));
|
||||
result.put("activeRate", rate(activeUsers, totalRegistered));
|
||||
|
||||
// 每日新增趋势
|
||||
List<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
result.put("dailyTrend", buildDailyNewUsers(since, until));
|
||||
|
||||
// 男女比例
|
||||
// Phase 20 D12(补全):性别/年龄分布此前漏了软删过滤,
|
||||
// 会让"分布"与已完成软删过滤的 totalRegistered 对不上账。
|
||||
Map<String, Long> genderRatio = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.isNotNull(UserProfile::getGender)
|
||||
.isNull(UserProfile::getDeletedAt))
|
||||
.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
UserProfile::getGender, Collectors.counting()));
|
||||
.collect(java.util.stream.Collectors.groupingBy(
|
||||
UserProfile::getGender, java.util.stream.Collectors.counting()));
|
||||
result.put("genderRatio", genderRatio);
|
||||
|
||||
// 年龄分布
|
||||
List<UserProfile> profiles = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.isNotNull(UserProfile::getBirthDate)
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
|
||||
Map<String, Long> ageDist = new LinkedHashMap<>();
|
||||
for (int i = 0; i < breaks.size() - 1; i++) {
|
||||
@@ -146,28 +163,20 @@ public class AnalyticsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Match漏斗 ====================
|
||||
// ==================== Match 漏斗 ====================
|
||||
|
||||
public Map<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 总量
|
||||
long totalRecommended = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.isNotNull(RecommendationRecord::getShownAt));
|
||||
long totalLiked = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.eq(RecommendationRecord::getAction, "liked"));
|
||||
long totalMatches = matchMapper.selectCount(null);
|
||||
long chatsStarted = conversationMapper.selectCount(null);
|
||||
long gettingToKnow = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.eq(Match::getStatus, "getting_to_know"));
|
||||
long successPairs = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
long totalRecommended = metrics.recommendationsShown();
|
||||
long totalLiked = metrics.likes();
|
||||
long totalMatches = metrics.matches();
|
||||
// Phase 20 D3:只统计有真人消息的会话,系统通知不计入「开启聊天」。
|
||||
long chatsStarted = metrics.chatsStarted();
|
||||
long gettingToKnow = matchMapper.countByStatus("getting_to_know");
|
||||
long successPairs = metrics.successfulMatches();
|
||||
|
||||
result.put("totalRecommended", totalRecommended);
|
||||
result.put("totalLiked", totalLiked);
|
||||
@@ -176,61 +185,75 @@ public class AnalyticsService {
|
||||
result.put("gettingToKnow", gettingToKnow);
|
||||
result.put("successPairs", successPairs);
|
||||
|
||||
// 各阶段转化率
|
||||
result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%");
|
||||
result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%");
|
||||
result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%");
|
||||
result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%");
|
||||
result.put("likeRate", rate(totalLiked, totalRecommended));
|
||||
result.put("matchRate", rate(totalMatches, totalLiked));
|
||||
result.put("chatRate", rate(chatsStarted, totalMatches));
|
||||
result.put("successRate", rate(successPairs, totalMatches));
|
||||
|
||||
// 每日新增匹配趋势
|
||||
List<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
// Phase 20 D4:按来源分离,避免把活动互选/红娘促成的量算进用户自主匹配。
|
||||
Map<String, Object> bySource = new LinkedHashMap<>();
|
||||
bySource.put("userLike", metrics.matchesBySource("user_like"));
|
||||
bySource.put("activityMutual", metrics.matchesBySource("activity_mutual"));
|
||||
bySource.put("matchmaker", metrics.matchesBySource("matchmaker"));
|
||||
result.put("matchesBySource", bySource);
|
||||
result.put("matchmakerRecommendations", metrics.matchmakerRecommendations());
|
||||
|
||||
result.put("dailyTrend", buildDailyNewMatches(since, until));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 活动漏斗 ====================
|
||||
|
||||
/**
|
||||
* 活动漏斗。
|
||||
*
|
||||
* <p><b>Phase 20 D5 修复</b>:修复前本方法的所有指标**硬编码为 0**
|
||||
* (并且留着一行 {@code userMapper.selectCount(...)} 把"用户数"当成"活动数"的死代码),
|
||||
* 后台"活动漏斗"因此永远显示 0 —— 不是没有数据,是根本没有查询。
|
||||
*
|
||||
* <p>同时严格区分「互选成立」与「互选后成功」:Gate 要求
|
||||
* 「活动10对互选 ≠ 10 对 Success」。
|
||||
*/
|
||||
public Map<String, Object> getActivityFunnel() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// 活动数量(简化:统计 distinct activity_id)
|
||||
long activityCount = userMapper.selectCount(
|
||||
new LambdaQueryWrapper<>() {}); // placeholder
|
||||
// 报名数、通过数、签到数需要从 activity_registrations 统计
|
||||
// 简化处理
|
||||
result.put("totalActivities", 0);
|
||||
result.put("totalRegistrations", 0);
|
||||
result.put("approvedRegistrations", 0);
|
||||
result.put("checkedInCount", 0);
|
||||
return result;
|
||||
return activityFunnelStats();
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new LinkedHashMap<>();
|
||||
s.put("totalActivities", metrics.activities());
|
||||
s.put("totalRegistrations", metrics.activityRegistrations());
|
||||
s.put("approvedRegistrations", metrics.approvedActivityRegistrations());
|
||||
s.put("checkedInCount", metrics.checkedInRegistrations());
|
||||
// 互选成立对数(严格双确认)
|
||||
s.put("mutualBothConfirmed", metrics.mutualBothConfirmed());
|
||||
// 互选后建立 Match 数
|
||||
s.put("mutualMatches", metrics.mutualMatches());
|
||||
// 互选后成功牵手数 —— 与互选对数严格区分
|
||||
s.put("mutualSuccessful", metrics.mutualSuccessful());
|
||||
return s;
|
||||
}
|
||||
|
||||
// ==================== 安全统计 ====================
|
||||
|
||||
public Map<String, Object> getSafetyStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
|
||||
long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
|
||||
long highRiskUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
long bannedUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
return safetyStats();
|
||||
}
|
||||
|
||||
stats.put("pendingReports", pendingReports);
|
||||
stats.put("resolvedReports", resolvedReports);
|
||||
stats.put("highRiskUsers", highRiskUsers);
|
||||
stats.put("bannedUsers", bannedUsers);
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("pendingReports", metrics.pendingReports());
|
||||
stats.put("resolvedReports", metrics.resolvedReports());
|
||||
stats.put("highRiskUsers", metrics.highRiskUsers());
|
||||
stats.put("bannedUsers", metrics.bannedUsers());
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ==================== 每日汇总 ====================
|
||||
|
||||
public List<Map<String, Object>> getDailySummary(int days) {
|
||||
if (days < 1 || days > 365) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
|
||||
}
|
||||
List<Map<String, Object>> summary = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
@@ -239,7 +262,7 @@ public class AnalyticsService {
|
||||
Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
|
||||
Map<String, Object> day = new HashMap<>();
|
||||
Map<String, Object> day = new LinkedHashMap<>();
|
||||
day.put("date", date.toString());
|
||||
day.put("newUsers", countEntityByDateRange(start, end));
|
||||
day.put("newMatches", countMatchByDateRange(start, end));
|
||||
@@ -250,97 +273,36 @@ public class AnalyticsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
private Map<String, Object> userBasicStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalUsers", userMapper.selectCount(null));
|
||||
s.put("verifiedUsers", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
|
||||
s.put("approvedProfiles", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
|
||||
s.put("activeUsers7d", userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> matchFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalLikes", recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
|
||||
s.put("totalMatches", matchMapper.selectCount(null));
|
||||
s.put("gettingToKnow", matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
|
||||
s.put("successPairs", successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING));
|
||||
s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED));
|
||||
s.put("highRiskUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
s.put("bannedUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalActivities", 0);
|
||||
s.put("totalRegistrations", 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countEntityByDateRange(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
// ==================== V1.1 公益项目统计 ====================
|
||||
|
||||
/**
|
||||
* Banner 统计。
|
||||
*
|
||||
* <p><b>Phase 20 D6 修复</b>:修复前 {@code impressions} / {@code clicks} 被**硬编码为 0**。
|
||||
* 0 会被读成"真的没人看到 / 没人点击",而实际是项目内根本不存在曝光与点击的埋点表或上报端点。
|
||||
* 两者对运营决策的含义完全相反,因此改为显式标注不可用,绝不返回 0 冒充真实值。
|
||||
*/
|
||||
public Map<String, Object> getBannerStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long totalBanners = homeBannerMapper.selectCount(null);
|
||||
Instant now = Instant.now();
|
||||
Instant finalNow = now;
|
||||
long activeBanners = homeBannerMapper.selectCount(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.danyang.match.platform.banner.entity.HomeBanner>()
|
||||
.eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active")
|
||||
.le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow)
|
||||
.ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow)
|
||||
);
|
||||
stats.put("totalBanners", totalBanners);
|
||||
stats.put("activeBanners", activeBanners);
|
||||
stats.put("impressions", 0);
|
||||
stats.put("clicks", 0);
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalBanners", metrics.banners());
|
||||
stats.put("activeBanners", metrics.activeBanners());
|
||||
// 未埋点:明确置 null 并给出标记,避免被当成真实的 0。
|
||||
stats.put("impressions", null);
|
||||
stats.put("clicks", null);
|
||||
stats.put("impressionsAvailable", false);
|
||||
stats.put("clicksAvailable", false);
|
||||
stats.put("unavailableReason", "未埋点:项目内不存在 Banner 曝光/点击的上报端与事件表,"
|
||||
+ "故无法计算。修复前此处返回硬编码 0,会被误读为真实数据。");
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceStationStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
);
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
Set<String> uniqueLocations = new HashSet<>();
|
||||
for (ServiceAppointment a : appointments) {
|
||||
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
|
||||
@@ -351,140 +313,181 @@ public class AnalyticsService {
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceAppointmentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count());
|
||||
stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("total", metrics.serviceAppointments());
|
||||
stats.put("submitted", countAppointmentsByStatus("submitted"));
|
||||
stats.put("confirmed", countAppointmentsByStatus("confirmed"));
|
||||
stats.put("completed", countAppointmentsByStatus("completed"));
|
||||
stats.put("cancelled", countAppointmentsByStatus("cancelled"));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCounselingStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count());
|
||||
stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long total = metrics.counselingServices();
|
||||
stats.put("total", total);
|
||||
stats.put("pending", countCounselingByStatus("pending"));
|
||||
stats.put("assigned", countCounselingByStatus("assigned"));
|
||||
stats.put("completed", countCounselingByStatus("completed"));
|
||||
stats.put("cancelled", countCounselingByStatus("cancelled"));
|
||||
stats.put("premaritalGuidance", countCounselingByServiceType("premarital_guidance"));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getPremaritalStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getServiceType, "premarital_guidance")
|
||||
.isNull(CounselingService::getDeletedAt)
|
||||
);
|
||||
stats.put("totalRequests", all.size());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalRequests", countCounselingByServiceType("premarital_guidance"));
|
||||
stats.put("completed", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "completed")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("pending", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "pending")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("withCompanion", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.isNotNull(CounselingServiceRecord::getCompanionUserId)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getContentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ContentArticle> articles = contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt)
|
||||
);
|
||||
long totalViews = articles.stream()
|
||||
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt));
|
||||
stats.put("totalPublishedArticles", articles.size());
|
||||
stats.put("totalViews", totalViews);
|
||||
stats.put("totalViews", metrics.contentViews());
|
||||
articles.sort((a, b) -> Integer.compare(
|
||||
b.getViewCount() != null ? b.getViewCount() : 0,
|
||||
a.getViewCount() != null ? a.getViewCount() : 0));
|
||||
b.getViewCount() != null ? b.getViewCount() : 0,
|
||||
a.getViewCount() != null ? a.getViewCount() : 0));
|
||||
List<Map<String, Object>> top = articles.stream().limit(5)
|
||||
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
|
||||
.toList();
|
||||
.map(a -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", a.getId());
|
||||
m.put("title", a.getTitle());
|
||||
m.put("views", a.getViewCount());
|
||||
return m;
|
||||
})
|
||||
.toList();
|
||||
stats.put("topArticles", top);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getLocalEligibilityStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<LocalEligibility> all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>());
|
||||
stats.put("total", all.size());
|
||||
stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count());
|
||||
stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count());
|
||||
stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long total = localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
stats.put("total", total);
|
||||
stats.put("verified", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "verified")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("pending", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.in(LocalEligibility::getVerificationStatus, "pending_review", "pending")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("rejected", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "rejected")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceProgramStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceProgram> programs = serviceProgramMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(null);
|
||||
stats.put("totalPrograms", programs.size());
|
||||
stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count());
|
||||
stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count());
|
||||
stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalPrograms", metrics.servicePrograms());
|
||||
stats.put("activePrograms", metrics.activeServicePrograms());
|
||||
stats.put("totalMembers", metrics.approvedProgramMembers());
|
||||
stats.put("pendingApplications", serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "pending")
|
||||
.isNull(ServiceProgramMember::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getRegistrationSourceStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
|
||||
Map<String, Long> bySource = regs.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(
|
||||
r -> r.getSource() != null ? r.getSource() : "unknown",
|
||||
java.util.stream.Collectors.counting()));
|
||||
stats.put("totalRegistrations", regs.size());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<String> sources = List.of("user_self", "matchmaker_referral", "activity_followup",
|
||||
"service_station", "operator_assisted");
|
||||
Map<String, Long> bySource = new LinkedHashMap<>();
|
||||
long total = 0;
|
||||
for (String source : sources) {
|
||||
long count = activityRegistrationMapper.countRegistrationsBySource(source);
|
||||
bySource.put(source, count);
|
||||
total += count;
|
||||
}
|
||||
stats.put("totalRegistrations", total);
|
||||
stats.put("bySource", bySource);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公益服务用户数与服务人次。
|
||||
*
|
||||
* <p><b>Phase 20 计划明确要求「分离服务用户数与服务人次」</b>:
|
||||
* 前者是去重后的**人数**(覆盖面),后者是不去重的**人次**(承载量),
|
||||
* 两者比值即人均服务次数。修复前只有去重人数、没有服务人次。
|
||||
*/
|
||||
public Map<String, Object> getUniqueServiceUsersStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt))
|
||||
.stream().map(CounselingService::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
|
||||
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> pu = serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
|
||||
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>())
|
||||
.stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
stats.put("counselingUniqueUsers", cu.size());
|
||||
stats.put("appointmentUniqueUsers", au.size());
|
||||
stats.put("programUniqueUsers", pu.size());
|
||||
stats.put("eligibilityUniqueUsers", eu.size());
|
||||
Set<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
|
||||
stats.put("totalUniqueServiceUsers", all.size());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
Set<String> counselingUsers = new HashSet<>();
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> counselingUsers.add(c.getUserId()));
|
||||
Set<String> appointmentUsers = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> appointmentUsers.add(a.getUserId()));
|
||||
Set<String> programUsers = new HashSet<>();
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> programUsers.add(m.getUserId()));
|
||||
Set<String> eligibilityUsers = new HashSet<>();
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.isNull(LocalEligibility::getDeletedAt))
|
||||
.forEach(e -> eligibilityUsers.add(e.getUserId()));
|
||||
|
||||
stats.put("counselingUniqueUsers", sizeOf(counselingUsers));
|
||||
stats.put("appointmentUniqueUsers", sizeOf(appointmentUsers));
|
||||
stats.put("programUniqueUsers", sizeOf(programUsers));
|
||||
stats.put("eligibilityUniqueUsers", sizeOf(eligibilityUsers));
|
||||
|
||||
// 服务用户数(跨域去重)
|
||||
stats.put("totalUniqueServiceUsers", metrics.uniqueServiceUsers());
|
||||
// 服务人次(不去重)—— 与上面的"用户数"是两个不同指标
|
||||
stats.put("totalServiceVisits", metrics.serviceVisits());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getEffectiveConnectionStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long activeMatches = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
|
||||
long matchesWithChat = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
|
||||
long successRequests = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long activeMatches = matchMapper.countByStatus("active");
|
||||
long matchesWithChat = metrics.chatsStarted();
|
||||
long successRequests = metrics.successfulMatches();
|
||||
stats.put("activeMatches", activeMatches);
|
||||
stats.put("matchesWithChat", matchesWithChat);
|
||||
stats.put("successConnections", successRequests);
|
||||
double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0;
|
||||
stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0);
|
||||
// 口径未确认,必须显式标注,禁止对外宣传。
|
||||
stats.put("effectiveMatchesProvisional", true);
|
||||
stats.put("provisionalNote", "「有效交友匹配」口径尚未经业务确认,仅内部参考,禁止对外宣传。");
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getV11ServiceDashboard() {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("banners", getBannerStats());
|
||||
d.put("serviceStations", getServiceStationStats());
|
||||
d.put("serviceAppointments", getServiceAppointmentStats());
|
||||
@@ -496,50 +499,100 @@ public class AnalyticsService {
|
||||
d.put("registrationSources", getRegistrationSourceStats());
|
||||
d.put("uniqueServiceUsers", getUniqueServiceUsersStats());
|
||||
d.put("effectiveConnections", getEffectiveConnectionStats());
|
||||
// 活跃活动漏斗(含互选/成功分离)
|
||||
d.put("activityFunnel", getActivityFunnel());
|
||||
return d;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助 ====================
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
private static int sizeOf(Set<String> set) {
|
||||
set.remove(null);
|
||||
return set.size();
|
||||
}
|
||||
|
||||
/** 统一的比率格式化,保证同一比率在任何视图里呈现一致。 */
|
||||
private static String rate(long numerator, long denominator) {
|
||||
if (denominator <= 0) return "0%";
|
||||
return String.format("%.1f%%", 100.0 * numerator / denominator);
|
||||
}
|
||||
|
||||
private long countAppointmentsByStatus(String status) {
|
||||
return serviceAppointmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getStatus, status)
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByStatus(String status) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getStatus, status)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByServiceType(String serviceType) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, serviceType)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countEntityByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countMatchByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyTrend(LocalDate since, LocalDate until,
|
||||
java.util.function.BiFunction<Instant, Instant, Long> counter) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
Map<String, Object> point = new LinkedHashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countMatchByDateRange(start, end));
|
||||
point.put("value", counter.apply(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
// 以下四个"按日区间"计数同样必须显式过滤软删,
|
||||
// 否则每日汇总里的"新增用户/新增 Match"会把已删除记录继续算进当天。
|
||||
private long countEntityByDateRange(Instant start, Instant end) {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getCreatedAt, start)
|
||||
.le(User::getCreatedAt, end));
|
||||
.le(User::getCreatedAt, end)
|
||||
.isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countMatchByDateRange(Instant start, Instant end) {
|
||||
return matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.ge(Match::getMatchedAt, start)
|
||||
.le(Match::getMatchedAt, end));
|
||||
.le(Match::getMatchedAt, end)
|
||||
.isNull(Match::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countConversationByDateRange(Instant start, Instant end) {
|
||||
return conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.ge(Conversation::getCreatedAt, start)
|
||||
.le(Conversation::getCreatedAt, end));
|
||||
.le(Conversation::getCreatedAt, end)
|
||||
.isNull(Conversation::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countReportByDateRange(Instant start, Instant end) {
|
||||
return reportMapper.selectCount(
|
||||
new LambdaQueryWrapper<Report>()
|
||||
.ge(Report::getCreatedAt, start)
|
||||
.le(Report::getCreatedAt, end));
|
||||
.le(Report::getCreatedAt, end)
|
||||
.isNull(Report::getDeletedAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.banner.entity.HomeBanner;
|
||||
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.chat.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.content.entity.ContentArticle;
|
||||
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgram;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 统一口径计算层 —— 所有统计数字的**唯一**出处。
|
||||
*
|
||||
* <p>为什么要单独抽一层:Phase 20 的 Gate 是「Dashboard 和专项页面相同指标必须一致」。
|
||||
* 修复前 {@code AnalyticsService} 里同一个指标有 2~3 份实现
|
||||
* ({@code userBasicStats} / {@code getUserFunnel} / {@code getV11ServiceDashboard}),
|
||||
* 名字还各不相同({@code verifiedUsers} vs {@code verifiedCount}),
|
||||
* 只要有一处漏改就永久不一致,且没有任何测试能发现。
|
||||
*
|
||||
* <p>本类中每个方法都对应 {@link MetricCatalog} 里的一个已登记指标,
|
||||
* 方法上标注的指标编码即其口径来源。
|
||||
*
|
||||
* <h2>为什么每个查询都显式写 {@code deleted_at IS NULL}</h2>
|
||||
*
|
||||
* <p><b>Phase 20 D12</b>:{@code BaseEntity.deletedAt} **没有** {@code @TableLogic} 注解,
|
||||
* 项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。这意味着
|
||||
* {@code selectCount(null)} 生成的是 {@code SELECT COUNT(*) FROM t} ——
|
||||
* **软删除的数据会被完整计入统计**。
|
||||
*
|
||||
* <p>实测影响:用户注销/被删、Banner 下架删除、活动软删之后,
|
||||
* 所有看板数字依然把它们算在内。这类偏差不会有任何报错,
|
||||
* 只会让"累计注册用户数"永远只增不减。因此本层所有查询统一显式过滤。
|
||||
*
|
||||
* <p><b>关于性能</b>:少量需要"跨域去重"的指标(如服务用户数)仍采用
|
||||
* 「查出各域 user_id 再内存去重」的写法。这在单区县体量下无问题;
|
||||
* 若数据量增长到十万级,应改为数据库侧 {@code UNION} 去重聚合。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsMetricsCalculator {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final MessageMapper messageMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
private final ActivityMutualConsentMapper activityMutualConsentMapper;
|
||||
private final ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final ServiceProgramMapper serviceProgramMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final ContentArticleMapper contentArticleMapper;
|
||||
private final HomeBannerMapper homeBannerMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
|
||||
// ==================== 用户域 ====================
|
||||
|
||||
/** {@code user.registered.total} —— 累计注册用户数(不含软删)。 */
|
||||
public long registeredUsers() {
|
||||
return userMapper.selectCount(new LambdaQueryWrapper<User>().isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code user.identity.verified} —— 实名认证通过用户数。
|
||||
*
|
||||
* <p><b>Phase 20 D1 修复点</b>:此前该口径被写成
|
||||
* {@code user_profiles.profile_status != 'rejected'}("资料没被退回"),
|
||||
* 把 in_progress(资料还在填)、need_edit(被退回待改)等状态全都算成了"已实名",
|
||||
* 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不符。
|
||||
*/
|
||||
public long realNameVerifiedUsers() {
|
||||
return userIdentityMapper.countVerifiedUsers();
|
||||
}
|
||||
|
||||
/** {@code user.profile.approved} —— 资料审核通过人数(不含软删)。 */
|
||||
public long profileApprovedUsers() {
|
||||
return userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.eq(UserProfile::getProfileStatus, "approved")
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code user.active.7d} —— 近 7 日活跃用户数(不含软删)。 */
|
||||
public long activeUsers7d() {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))
|
||||
.isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
// ==================== 推荐 / 匹配域 ====================
|
||||
|
||||
/** {@code like.total} —— 用户 Like 总数(单向行为)。 */
|
||||
public long likes() {
|
||||
return recommendationRecordMapper.countByAction("liked");
|
||||
}
|
||||
|
||||
/** {@code recommendation.shown.total} —— 已展示推荐总数。 */
|
||||
public long recommendationsShown() {
|
||||
return recommendationRecordMapper.countShown();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code recommendation.shown.matchmaker} —— 红娘发出的推荐数。
|
||||
*
|
||||
* <p>按 {@code source='matchmaker'} 过滤,避免把用户端自动推荐(daily / discover)
|
||||
* 算进红娘业绩。
|
||||
*/
|
||||
public long matchmakerRecommendations() {
|
||||
return recommendationRecordMapper.countShownBySource("matchmaker");
|
||||
}
|
||||
|
||||
/** {@code match.total} —— Match 总数(全来源)。 */
|
||||
public long matches() {
|
||||
return matchMapper.countAllMatches();
|
||||
}
|
||||
|
||||
/** 按来源统计 Match 数({@code user_like} / {@code activity_mutual} / {@code matchmaker})。 */
|
||||
public long matchesBySource(String source) {
|
||||
return matchMapper.countBySource(source);
|
||||
}
|
||||
|
||||
/** {@code match.successful} —— 成功牵手数。 */
|
||||
public long successfulMatches() {
|
||||
return matchMapper.countByStatus("successful");
|
||||
}
|
||||
|
||||
/** {@code match.effective}(PROVISIONAL)—— 已建 Match 且有过真人消息的配对数。 */
|
||||
public long effectiveMatches() {
|
||||
// PROVISIONAL:口径未确认(「有效」是否要求见面/持续 N 天由业务方裁决)。
|
||||
// 当前取「真人会话数」与「Match 数」的较小值作为保守估计,
|
||||
// 绝不可当作正式成功率对外引用。
|
||||
return Math.min(messageMapper.countConversationsWithRealMessages(), matchMapper.countAllMatches());
|
||||
}
|
||||
|
||||
/** {@code chat.started} —— 开启聊天会话数(排除 system / risk_notice)。 */
|
||||
public long chatsStarted() {
|
||||
return messageMapper.countConversationsWithRealMessages();
|
||||
}
|
||||
|
||||
/** {@code chat.conversation.total} —— 会话总数(不含软删)。 */
|
||||
public long conversations() {
|
||||
return conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
|
||||
.isNull(com.danyang.match.platform.chat.entity.Conversation::getDeletedAt));
|
||||
}
|
||||
|
||||
// ==================== 活动域 ====================
|
||||
|
||||
/** {@code activity.total} —— 活动总数(不含软删)。 */
|
||||
public long activities() {
|
||||
return activityMapper.selectCount(new LambdaQueryWrapper<Activity>().isNull(Activity::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code activity.registration.total} —— 活动报名人次(不去重)。 */
|
||||
public long activityRegistrations() {
|
||||
return activityRegistrationMapper.countAllRegistrations();
|
||||
}
|
||||
|
||||
/** {@code activity.registration.approved} —— 报名通过人次。 */
|
||||
public long approvedActivityRegistrations() {
|
||||
return activityRegistrationMapper.countRegistrationsByStatus("approved");
|
||||
}
|
||||
|
||||
/** {@code activity.registration.checked_in} —— 实际签到人次。 */
|
||||
public long checkedInRegistrations() {
|
||||
return activityRegistrationMapper.countCheckedIn();
|
||||
}
|
||||
|
||||
/** {@code activity.mutual.both_confirmed} —— 活动互选成立对数(严格双确认)。 */
|
||||
public long mutualBothConfirmed() {
|
||||
return activityMutualConsentMapper.countBothConfirmed();
|
||||
}
|
||||
|
||||
/** {@code activity.mutual.match} —— 互选后建立 Match 数。 */
|
||||
public long mutualMatches() {
|
||||
return matchMapper.countBySource("activity_mutual");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code activity.mutual.successful} —— 互选后成功牵手数。
|
||||
*
|
||||
* <p>与 {@link #mutualBothConfirmed()} **严格区分**:
|
||||
* 10 对互选成立 ≠ 10 对成功,中间还有聊天、相处、确认关系。
|
||||
*/
|
||||
public long mutualSuccessful() {
|
||||
return matchMapper.countBySourceAndStatus("activity_mutual", "successful");
|
||||
}
|
||||
|
||||
// ==================== 公益服务域 ====================
|
||||
|
||||
/** {@code service.appointment.total} —— 服务预约人次(不含已取消、不含软删)。 */
|
||||
public long serviceAppointments() {
|
||||
return serviceAppointmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code counseling.total} —— 婚恋咨询受理量(不含软删)。 */
|
||||
public long counselingServices() {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code service.program.member.approved} —— 专项项目在册成员数(不含软删)。 */
|
||||
public long approvedProgramMembers() {
|
||||
return serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.visit.total} —— 公益服务人次(**不去重**)。
|
||||
*
|
||||
* <p>与 {@link #uniqueServiceUsers()} 是**两个不同的指标**:
|
||||
* 本指标衡量服务承载量,同一用户来 3 次算 3 人次。
|
||||
*/
|
||||
public long serviceVisits() {
|
||||
return serviceAppointments()
|
||||
+ counselingServices()
|
||||
+ approvedProgramMembers()
|
||||
+ localEligibilityCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.user.unique} —— 服务用户数(**跨域去重**)。
|
||||
*
|
||||
* <p>同一用户既预约又咨询又参加项目,只算 1 人。与 {@link #serviceVisits()}
|
||||
* 的比值即人均服务次数,因此两者绝不可混用或互相替代。
|
||||
*/
|
||||
public long uniqueServiceUsers() {
|
||||
Set<String> users = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> users.add(a.getUserId()));
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> users.add(c.getUserId()));
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> users.add(m.getUserId()));
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt))
|
||||
.forEach(e -> users.add(e.getUserId()));
|
||||
users.remove(null);
|
||||
return users.size();
|
||||
}
|
||||
|
||||
private long localEligibilityCount() {
|
||||
return localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 专项项目总数(不含软删),供服务仪表盘使用。 */
|
||||
public long servicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 状态为 active 的专项项目数(不含软删)。 */
|
||||
public long activeServicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>()
|
||||
.eq(ServiceProgram::getStatus, "active")
|
||||
.isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.published.total} —— 已发布内容数(不含软删)。 */
|
||||
public long publishedArticles() {
|
||||
return contentArticleMapper.selectCount(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.views.total} —— 内容累计阅读量。 */
|
||||
public long contentViews() {
|
||||
return contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt))
|
||||
.stream()
|
||||
.mapToLong(a -> a.getViewCount() != null ? a.getViewCount() : 0)
|
||||
.sum();
|
||||
}
|
||||
|
||||
// ==================== Banner 域 ====================
|
||||
|
||||
/** {@code banner.total} —— Banner 总数(不含软删)。 */
|
||||
public long banners() {
|
||||
return homeBannerMapper.selectCount(
|
||||
new LambdaQueryWrapper<HomeBanner>().isNull(HomeBanner::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code banner.active} —— 生效中的 Banner 数(不含软删)。 */
|
||||
public long activeBanners() {
|
||||
Instant now = Instant.now();
|
||||
return homeBannerMapper.selectCount(
|
||||
new LambdaQueryWrapper<HomeBanner>()
|
||||
.eq(HomeBanner::getStatus, "active")
|
||||
.isNull(HomeBanner::getDeletedAt)
|
||||
.le(HomeBanner::getStartAt, now)
|
||||
.ge(HomeBanner::getEndAt, now));
|
||||
}
|
||||
|
||||
// ==================== 安全域 ====================
|
||||
|
||||
/** {@code risk.report.pending} —— 待处理举报数(自定义 SQL 已含软删过滤)。 */
|
||||
public long pendingReports() {
|
||||
return reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
|
||||
}
|
||||
|
||||
/** {@code risk.report.resolved} —— 已处理举报数(自定义 SQL 已含软删过滤)。 */
|
||||
public long resolvedReports() {
|
||||
return reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
|
||||
}
|
||||
|
||||
/** {@code risk.user.high_risk} —— 高风险用户数(生效中的封禁或临时停用,不含软删)。 */
|
||||
public long highRiskUsers() {
|
||||
return riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")
|
||||
.isNull(RiskAction::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 仅封禁用户数(高风险用户的子集)。 */
|
||||
public long bannedUsers() {
|
||||
return riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")
|
||||
.isNull(RiskAction::getDeletedAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全部统计指标的注册表 —— Phase 20 的**单一事实来源**。
|
||||
*
|
||||
* <p>修复前的状态:同一个概念在 {@code AnalyticsService} 里有 2~3 份独立实现
|
||||
* ({@code userBasicStats()} / {@code getUserFunnel()} / {@code getV11ServiceDashboard()}
|
||||
* 各算一遍),且**同名不同义**(Dashboard 叫 {@code totalLikes},漏斗叫 {@code totalLiked};
|
||||
* Dashboard 叫 {@code verifiedUsers},漏斗叫 {@code verifiedCount})。
|
||||
* Phase 20 的 Gate 明确要求「Dashboard 和专项页面相同指标必须一致」,
|
||||
* 靠"记得同步改两处"是守不住的,必须有唯一来源。
|
||||
*
|
||||
* <p>本类同时是 {@code docs/ANALYTICS_METRICS.md} 的生成依据,
|
||||
* 由 {@code MetricCatalogGuardTest} 校验「文档覆盖全部指标」与「口径不自相矛盾」。
|
||||
*/
|
||||
public final class MetricCatalog {
|
||||
|
||||
/** 本项目统计口径统一时区(丹阳本地时间;跨时区部署会改变"某天"的边界)。 */
|
||||
public static final String TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
/** 当前口径版本;任何口径变更都必须递增,否则历史数据不可比。 */
|
||||
public static final String VERSION = "v1";
|
||||
|
||||
private MetricCatalog() {
|
||||
}
|
||||
|
||||
private static final List<MetricDefinition> ALL = List.of(
|
||||
|
||||
// ==================== 用户域 ====================
|
||||
new MetricDefinition(
|
||||
"user.registered.total", "累计注册用户数",
|
||||
"所有已注册用户账号数。含未完善资料、未实名、被封禁的账号(只要账号行存在即计入)。",
|
||||
"COUNT(users.id)", "—", "users.id", "users.created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "users"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.identity.verified", "实名认证通过用户数",
|
||||
"实名认证状态为 verified 的用户数。不含 verifying(认证中)、failed(失败)、"
|
||||
+ "manual_review(人工复核)、not_verified。**不等于**资料审核通过数。",
|
||||
"COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'",
|
||||
"—", "user_identities.user_id", "user_identities.verified_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "user_identities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.profile.approved", "资料审核通过人数",
|
||||
"资料审核状态为 approved 的用户数。仅表示资料通过审核,**不表示已实名**。",
|
||||
"COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'",
|
||||
"—", "user_profiles.user_id", "user_profiles.updated_at", TIMEZONE, VERSION, "审核员",
|
||||
MetricStatus.OFFICIAL, "user_profiles"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.active.7d", "近 7 日活跃用户数",
|
||||
"最近 7 天内登录过的用户数。",
|
||||
"COUNT(users.id) WHERE last_login_at >= NOW() - 7d", "—", "users.id",
|
||||
"users.last_login_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "users"),
|
||||
|
||||
// ==================== 推荐 / 匹配域 ====================
|
||||
new MetricDefinition(
|
||||
"recommendation.shown.total", "已展示推荐总数",
|
||||
"系统向用户展示过的推荐记录数(含全部来源 daily/discover/matchmaker/activity)。",
|
||||
"COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL", "—",
|
||||
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"recommendation.shown.matchmaker", "红娘发出的推荐数",
|
||||
"来源为 matchmaker 的推荐记录数 = 红娘实际推给用户的候选数。"
|
||||
+ "**不含**红娘在候选池里浏览看过但未发出的候选,也不含用户端自动推荐。",
|
||||
"COUNT(recommendation_records.id) WHERE source='matchmaker' AND shown_at IS NOT NULL", "—",
|
||||
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "红娘主管",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"like.total", "用户 Like 总数",
|
||||
"action='liked' 的推荐记录数(单向行为,A 喜欢 B 记 1 条)。"
|
||||
+ "两条 Like(A→B 与 B→A)才可能形成 1 个 Match,因此 Like 数约为 Mutual Match 数的 2 倍量级。",
|
||||
"COUNT(recommendation_records.id) WHERE action='liked'", "—",
|
||||
"recommendation_records.id", "recommendation_records.action_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.total", "Match 总数",
|
||||
"全部来源的 Match 记录数(去重键为 matches.id,同一对用户只有一行,重新牵手递增 episode 而不新增行)。",
|
||||
"COUNT(matches.id)", "—", "matches.id", "matches.matched_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.user_like", "用户互相 Like 形成的 Match 数",
|
||||
"source='user_like' 的 Match 数。与 Match 总数分开统计,"
|
||||
+ "避免把红娘促成、活动互选的量算进「用户自主匹配」的成果。",
|
||||
"COUNT(matches.id) WHERE source='user_like'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.activity_mutual", "活动互选形成的 Match 数",
|
||||
"source='activity_mutual' 的 Match 数。注意:它 ≤ 活动互选对数,"
|
||||
+ "且**不等于**活动互选的成功数(见 activity.mutual.successful)。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "活动管理员", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.matchmaker", "红娘促成的 Match 数",
|
||||
"source='matchmaker' 的 Match 数。",
|
||||
"COUNT(matches.id) WHERE source='matchmaker'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "红娘主管", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.successful", "成功牵手数",
|
||||
"status='successful' 的 Match 数。仅表示双方在平台上确认了关系结果,"
|
||||
+ "不涉及线下真实性核验。",
|
||||
"COUNT(matches.id) WHERE status='successful'", "—", "matches.id",
|
||||
"matches.successful_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.effective", "有效交友匹配数",
|
||||
"**口径未确认(provisional)**。当前近似定义为:已建立 Match 且双方在会话中"
|
||||
+ "产生过真人消息(排除 system/risk_notice)的配对数。"
|
||||
+ "「有效」到哪一步算数(是否要求见面、是否要求持续 N 天)业务方尚未裁决,"
|
||||
+ "**禁止作为正式成功率口径,禁止对外宣传**。",
|
||||
"COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') "
|
||||
+ "AND EXISTS(真人消息)", "match.total", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.PROVISIONAL, "matches"),
|
||||
|
||||
// ==================== 聊天域 ====================
|
||||
new MetricDefinition(
|
||||
"chat.started", "开启聊天会话数",
|
||||
"**至少存在一条真人消息**(message_type 不在 system/risk_notice 内)的会话数。"
|
||||
+ "系统通知(如「你们已互相喜欢」)不作为开启聊天的依据。",
|
||||
"COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')",
|
||||
"—", "messages.conversation_id", "messages.created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "messages"),
|
||||
|
||||
new MetricDefinition(
|
||||
"chat.conversation.total", "会话总数",
|
||||
"已创建的会话数(含仅由系统消息开头的会话)。与 chat.started 的差额即"
|
||||
+ "「建了会话但双方从未真正开口」的数量。",
|
||||
"COUNT(conversations.id)", "—", "conversations.id", "conversations.created_at",
|
||||
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "conversations"),
|
||||
|
||||
// ==================== 活动域 ====================
|
||||
new MetricDefinition(
|
||||
"activity.total", "活动总数",
|
||||
"全部已创建活动数(含草稿、已结束)。",
|
||||
"COUNT(activities.id)", "—", "activities.id", "activities.created_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.total", "活动报名人次",
|
||||
"报名记录总数(含 pending/approved/rejected/cancelled)。"
|
||||
+ "同一用户报多个活动计多次 —— 这是**人次**而非人数。",
|
||||
"COUNT(activity_registrations.id)", "—", "activity_registrations.id",
|
||||
"activity_registrations.registered_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.approved", "活动报名通过人次",
|
||||
"status='approved' 的报名记录数。",
|
||||
"COUNT(activity_registrations.id) WHERE status='approved'", "—",
|
||||
"activity_registrations.id", "activity_registrations.reviewed_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.checked_in", "活动实际签到人次",
|
||||
"checkin_status='checked_in' 的报名记录数。与报名通过人次的差额即活动到场流失。",
|
||||
"COUNT(activity_registrations.id) WHERE checkin_status='checked_in'", "—",
|
||||
"activity_registrations.id", "activity_registrations.checkin_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.both_confirmed", "活动互选成立对数",
|
||||
"两侧均 accepted 的互选记录数(严格互选,**不是**「有人点了同意」)。"
|
||||
+ "这是互选环节的产出。",
|
||||
"COUNT(activity_mutual_connection_consents.id) WHERE user_a_status='accepted' "
|
||||
+ "AND user_b_status='accepted'", "—", "activity_mutual_connection_consents.id",
|
||||
"activity_mutual_connection_consents.both_confirmed_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "activity_mutual_connection_consents"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.match", "互选后建立 Match 数",
|
||||
"source='activity_mutual' 的 Match 数。应当 ≤ 活动互选成立对数;"
|
||||
+ "若大于,说明存在未经双确认就建 Match 的路径。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual'", "activity.mutual.both_confirmed",
|
||||
"matches.id", "matches.matched_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.successful", "互选后成功牵手数",
|
||||
"source='activity_mutual' 且 status='successful' 的 Match 数。"
|
||||
+ "**明确区别于**活动互选成立对数:10 对互选成立远不等于 10 对成功,"
|
||||
+ "中间还要经过聊天、相处、确认关系。绝不可互相替代引用。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'",
|
||||
"activity.mutual.both_confirmed", "matches.id", "matches.successful_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
// ==================== 公益服务域 ====================
|
||||
new MetricDefinition(
|
||||
"service.visit.total", "公益服务人次",
|
||||
"全部公益服务记录的条数之和(预约 + 咨询 + 专项项目参与 + 本地资格办理)。"
|
||||
+ "**不去重** —— 同一用户来 3 次就是 3 人次。这是服务承载量的口径。",
|
||||
"Σ(预约, 咨询, 项目参与, 资格办理) 记录数", "—", "各域记录 id(**不去重**)",
|
||||
"各域 created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
|
||||
+ "service_program_members + user_local_eligibilities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.user.unique", "服务用户数",
|
||||
"**跨全部公益服务域去重**后的用户数。同一用户既预约又咨询又参加项目,只算 1 人。"
|
||||
+ "这是服务覆盖面的口径,与 service.visit.total 是**两个不同的指标**,"
|
||||
+ "两者的比值即人均服务次数。",
|
||||
"COUNT(DISTINCT user_id) over (预约 ∪ 咨询 ∪ 项目参与 ∪ 资格办理)", "—",
|
||||
"user_id(**跨域去重**)", "各域 created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
|
||||
+ "service_program_members + user_local_eligibilities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.appointment.total", "服务预约人次",
|
||||
"服务站预约记录数(不含已取消)。",
|
||||
"COUNT(service_appointments.id) WHERE status<>'cancelled'", "—", "service_appointments.id",
|
||||
"service_appointments.created_at", TIMEZONE, VERSION, "服务站运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments"),
|
||||
|
||||
new MetricDefinition(
|
||||
"counseling.total", "婚恋咨询受理量",
|
||||
"婚恋咨询记录数(含婚前辅导)。",
|
||||
"COUNT(counseling_services.id)", "—", "counseling_services.id",
|
||||
"counseling_services.created_at", TIMEZONE, VERSION, "咨询管理员",
|
||||
MetricStatus.OFFICIAL, "counseling_services"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.program.member.approved", "专项服务项目在册成员数",
|
||||
"status='approved' 的项目成员记录数。成员身份**不得**被用作对外的用户价值标签。",
|
||||
"COUNT(service_program_members.id) WHERE status='approved'", "—",
|
||||
"service_program_members.id", "service_program_members.created_at", TIMEZONE, VERSION,
|
||||
"平台运营", MetricStatus.OFFICIAL, "service_program_members"),
|
||||
|
||||
new MetricDefinition(
|
||||
"content.published.total", "已发布内容数",
|
||||
"status='published' 的内容文章数(婚恋课堂 / 反诈 / 公益信息)。",
|
||||
"COUNT(content_articles.id) WHERE status='published'", "—", "content_articles.id",
|
||||
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "content_articles"),
|
||||
|
||||
new MetricDefinition(
|
||||
"content.views.total", "内容累计阅读量",
|
||||
"已发布文章的 view_count 之和。",
|
||||
"SUM(content_articles.view_count) WHERE status='published'", "—", "content_articles.id",
|
||||
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "content_articles"),
|
||||
|
||||
// ==================== Banner 域 ====================
|
||||
new MetricDefinition(
|
||||
"banner.total", "Banner 总数",
|
||||
"全部 Banner 数(含草稿与已下架,不含软删)。",
|
||||
"COUNT(home_banners.id) WHERE deleted_at IS NULL", "—", "home_banners.id",
|
||||
"home_banners.created_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "home_banners"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.active", "生效中的 Banner 数",
|
||||
"status='active' 且当前时间落在 [start_at, end_at] 区间内的 Banner 数。",
|
||||
"COUNT(home_banners.id) WHERE status='active' AND NOW() BETWEEN start_at AND end_at", "—",
|
||||
"home_banners.id", "NOW()", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "home_banners"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.impression", "Banner 曝光量",
|
||||
"Banner 在小程序首页被渲染展示的次数。"
|
||||
+ "**当前不可用**:项目内不存在任何曝光埋点表或上报端点,"
|
||||
+ "修复前该指标被硬编码返回 0 —— 会被误读成「真的没人看到」,"
|
||||
+ "故改为显式标记为未埋点,绝不返回 0 冒充真实值。",
|
||||
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.click", "Banner 点击量",
|
||||
"Banner 被点击进入落地页的次数。**当前不可用**,原因同 banner.impression。",
|
||||
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
|
||||
|
||||
// ==================== 安全域 ====================
|
||||
new MetricDefinition(
|
||||
"risk.report.pending", "待处理举报数",
|
||||
"状态为待处理的举报记录数。",
|
||||
"COUNT(reports.id) WHERE status='pending'", "—", "reports.id", "reports.created_at",
|
||||
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
|
||||
|
||||
new MetricDefinition(
|
||||
"risk.report.resolved", "已处理举报数",
|
||||
"状态为已处理的举报记录数。",
|
||||
"COUNT(reports.id) WHERE status='resolved'", "—", "reports.id", "reports.resolved_at",
|
||||
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
|
||||
|
||||
new MetricDefinition(
|
||||
"risk.user.high_risk", "高风险用户数",
|
||||
"存在生效中封禁或临时停用处置的用户数。",
|
||||
"COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'",
|
||||
"—", "risk_actions.id", "risk_actions.created_at", TIMEZONE, VERSION, "风控人员",
|
||||
MetricStatus.OFFICIAL, "risk_actions")
|
||||
);
|
||||
|
||||
private static final Map<String, MetricDefinition> BY_CODE = ALL.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(MetricDefinition::code, Function.identity()));
|
||||
|
||||
/** 全部指标定义(顺序稳定,供文档生成与守卫遍历)。 */
|
||||
public static List<MetricDefinition> all() {
|
||||
return ALL;
|
||||
}
|
||||
|
||||
/** 按编码取定义;编码不存在时抛异常(调用处写错编码必须立刻暴露,而不是静默拿到 null)。 */
|
||||
public static MetricDefinition of(String code) {
|
||||
MetricDefinition definition = BY_CODE.get(code);
|
||||
if (definition == null) {
|
||||
throw new IllegalArgumentException("未登记的指标编码: " + code
|
||||
+ "(新指标必须先加入 MetricCatalog 并同步 docs/ANALYTICS_METRICS.md)");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** 口径未确认、禁止对外宣传的指标。 */
|
||||
public static List<MetricDefinition> provisional() {
|
||||
return ALL.stream().filter(MetricDefinition::provisional).toList();
|
||||
}
|
||||
|
||||
/** 因缺少埋点而当前无法计算的指标。 */
|
||||
public static List<MetricDefinition> notInstrumented() {
|
||||
return ALL.stream().filter(MetricDefinition::notInstrumented).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 单个指标的完整定义(Phase 20 指标字典的代码化载体)。
|
||||
*
|
||||
* <p>Phase 20 计划原文要求:「为每个指标登记名称、定义、分子分母、去重键、时间字段、
|
||||
* 时区、版本和负责人」。这些字段此前散落在各统计方法里(大多数情况下**根本不存在**,
|
||||
* 口径只存在于写代码人的脑子里),导致:
|
||||
* <ul>
|
||||
* <li>同一个概念在不同接口里各算一遍,结果不一致(Dashboard 与专项页对不上);</li>
|
||||
* <li>「实名人数」被误算成「资料没被退回的人数」,没人能在评审时发现;</li>
|
||||
* <li>「服务用户数」与「服务人次」混用,去重与否无从判断。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>把定义变成结构化数据后,{@code MetricCatalogGuardTest} 才能把
|
||||
* 「每个对外指标都必须有定义」「文档必须覆盖全部指标」「provisional 状态不得缺失」
|
||||
* 变成可执行断言,而不是靠人肉 review。
|
||||
*
|
||||
* @param code 指标唯一编码(如 {@code user.identity.verified}),命名空间用点分层
|
||||
* @param name 中文名(后台与文档展示用)
|
||||
* @param definition 口径的自然语言定义(必须说明"算什么"以及"不算什么")
|
||||
* @param numerator 分子
|
||||
* @param denominator 分母(非比率指标填 {@code "—"})
|
||||
* @param dedupeKey 去重键(说明该指标的"1 个单位"是什么,防止口径混淆)
|
||||
* @param timeField 时间字段(决定"属于哪一天")
|
||||
* @param timezone 时区(本项目统一 {@code Asia/Shanghai})
|
||||
* @param version 口径版本(口径变更必须递增,否则历史数据不可比)
|
||||
* @param owner 口径负责人(业务侧角色,口径争议时的裁决人)
|
||||
* @param status 口径状态,见 {@link MetricStatus}
|
||||
* @param sourceTable 数据来源表(便于追溯与索引优化)
|
||||
*/
|
||||
public record MetricDefinition(
|
||||
String code,
|
||||
String name,
|
||||
String definition,
|
||||
String numerator,
|
||||
String denominator,
|
||||
String dedupeKey,
|
||||
String timeField,
|
||||
String timezone,
|
||||
String version,
|
||||
String owner,
|
||||
MetricStatus status,
|
||||
String sourceTable
|
||||
) {
|
||||
|
||||
/** 是否为口径未确认的临时指标(禁止对外宣传)。 */
|
||||
public boolean provisional() {
|
||||
return status == MetricStatus.PROVISIONAL;
|
||||
}
|
||||
|
||||
/** 是否因缺少埋点而当前无法计算。 */
|
||||
public boolean notInstrumented() {
|
||||
return status == MetricStatus.NOT_INSTRUMENTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 指标口径状态(Phase 20 指标字典的一部分)。
|
||||
*
|
||||
* <p>存在的理由:统计接口最大的风险不是"算错",而是**算出来的数字被当成别的意思用了**。
|
||||
* 「有效交友匹配」在口径确认前如果被后台当成"成功数"展示、被运营写进宣传稿,
|
||||
* 错误就从代码扩散到了对外承诺。把状态显式建模,可让守卫测试把
|
||||
* 「provisional 指标不得标记为正式」变成可执行的不变量。
|
||||
*/
|
||||
public enum MetricStatus {
|
||||
|
||||
/**
|
||||
* 口径已确认,可作为正式统计口径使用。
|
||||
*/
|
||||
OFFICIAL,
|
||||
|
||||
/**
|
||||
* 口径尚未确认,禁止当作正式成功口径,禁止对外宣传。
|
||||
*
|
||||
* <p>Phase 20 计划:「有效交友匹配在口径未确认前标为 provisional,
|
||||
* 不能当正式成功或对外宣传」。
|
||||
*/
|
||||
PROVISIONAL,
|
||||
|
||||
/**
|
||||
* 数据源尚未埋点,当前**无法**计算。
|
||||
*
|
||||
* <p>此状态的意义在于**诚实**:宁可明确返回「不可用」,
|
||||
* 也不能像修复前那样硬编码返回 0 —— 0 会被读成"真的没人点击",
|
||||
* 而实际是"根本没有记录点击的地方",两者对决策的含义完全相反。
|
||||
*/
|
||||
NOT_INSTRUMENTED
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.danyang.match.platform.appeal.controller;
|
||||
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
|
||||
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
|
||||
import com.danyang.match.platform.appeal.service.AppealService;
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,6 +37,7 @@ public class AppealController {
|
||||
* 提交申诉
|
||||
* POST /api/v1/appeals
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Object>> submitAppeal(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||