Compare commits

103 Commits
Author SHA1 Message Date
jackyu66gitandCursor 7155b8b53a feat(api): 接入微信登录并原生实现咨询域(ECR-049/050)
小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 00:26:29 +08:00
jackyu66gitandCursor db4d118f9a fix(h5): 小程序 nh=1 模式保留子页返回按钮
原生顶栏隐藏 logo 顶栏时,非首页/我的仍显示 BackButton。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:43:07 +08:00
jackyu66gitandCursor fc53be22f8 fix(h5): 统一 nh=1 与 sbh 顶距逻辑,与 index.html 首屏一致
applyMpEmbedDom 在原生顶栏模式读取 session sbh;补充 nh+sbh 单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:21:52 +08:00
jackyu66gitandCursor e7290ed726 fix(h5): 首屏清 title + 桃色顶区对齐小程序 nav-bar(nh=1)
index.html 在 Vue 前清 document.title 并预应用 mp-native-header;
CSS 桃色顶区、隐藏 AppHeader,减轻黑字与渐变冲顶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:09:12 +08:00
jackyu66git db3669c595 Revert "fix(h5): 小程序 nh=1 原生顶栏模式与 sbh 顶距(配 202608278)"
This reverts commit 580ba85b61.
2026-08-25 03:29:37 +08:00
jackyu66gitandCursor 580ba85b61 fix(h5): 小程序 nh=1 原生顶栏模式与 sbh 顶距(配 202608278)
H5 在 nh=1 时隐藏 AppHeader,支持 sbh 控制内容顶距,首屏 inline 预应用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 03:24:42 +08:00
jackyu66gitandCursor 57687de360 feat(h5): 小程序原生顶栏 + 本地联调与一键部署
nh=1 隐藏 H5 顶栏并由 cover 承载 logo;H5 仅 4px 顶距避免重复留白。新增 dev:mp、deploy:h5 与联调文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:38:32 +08:00
jackyu66gitandCursor f4bd57ce1b fix(h5): 修复 WebView 顶栏 logo 与内容重叠
embed 模式 header 改文档流、移除负 margin;持久化 sbh 并在路由中保留,避免胶囊区与档案条重叠。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:22:39 +08:00
jackyu66git 9d4fb52d29 fix(h5): 小程序 WebView 通过 sbh 参数铺满刘海区
WebView 内 safe-area-inset-top 常为 0,改由小程序传入 statusBarHeight;
全屏洗底层上延并同步原生页背景色,修复刘海露白。
2026-08-25 01:18:16 +08:00
jackyu66gitandCursor 96292d0e95 fix(h5): 小程序 WebView 刘海区背景与顶栏安全区
Logo 下移 safe-area、embed 隐藏 HTML 标题;暖色渐变铺满刘海,页面背景上延与全屏洗底层对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:14:07 +08:00
jackyu66gitandCursor 635a83fe11 fix(h5): 修复小程序 WebView 内 logo 与备案号展示
Logo 改用 BASE_URL 路径并在 embed 模式始终显示顶部栏;备案号改为页面底部文档流,避免悬浮遮挡内容。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:04:18 +08:00
jackyu66gitandCursor ebc2d3ad80 docs: 补充 Ubuntu+MySQL 并存与 PostgreSQL 部署指南
DEPLOY-MINI-PROGRAM-H5 增加 PG 安装三种方式、Go API systemd、Checklist 与双库架构说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 00:46:58 +08:00
jackyu66gitandCursor 8d856fb56c feat(h5): 小程序 embed 模式与全站 ICP 备案页脚
H5 支持 ?mp=1 嵌入微信 WebView;user-h5/admin-h5 底部展示蜀ICP备2025140386号-2 并链至工信部;补充小程序+H5 部署说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 00:41:14 +08:00
jackyu66gitandCursor d33a25a663 docs(BD-2026-044): rhythm-config-write 标记 Implemented · Closed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:49:28 +08:00
jackyu66gitandCursor c693ac4bbc docs(ECR-048): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:51:42 +08:00
jackyu66gitandCursor e0f605558b feat(ECR-048): ReportTemplate 写面闭环并 Closed
growth.write POST/PUT · migration 000057 · 无新 C 端 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:51:42 +08:00
jackyu66gitandCursor aa197b719b docs(ECR-047): Freeze/Review 收口 → FROZEN WAIT
单刀授权硬边界 · 禁自动 ECR-048 · origin 齐平后冻结

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:32:25 +08:00
jackyu66gitandCursor 580f5ea3f0 docs(ECR-047): Human authorize Closed(不含 push)
Unauthorized Scope 经 authorize ECR-047 Closed 纳入正式 Closed;Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:21:54 +08:00
jackyu66gitandCursor 69cb99600f docs(ECR-047): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:14:21 +08:00
jackyu66gitandCursor 1db5fcc018 feat(ECR-047): FunnelDefinition 写面闭环并 Closed
growth.write POST/PUT · migration 000056 · 无新 C 端 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:14:21 +08:00
jackyu66gitandCursor 258deb0184 docs(ECR-046): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:56:11 +08:00
jackyu66gitandCursor b3fe4363fd feat(ECR-046): ScaleDefinition 元数据写面闭环并 Closed
explore.write POST/PUT · create→draft · status 仍 ECR-008 · migration 000055 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:56:11 +08:00
jackyu66gitandCursor fa8b0ba963 docs(ECR-045): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:29:43 +08:00
jackyu66gitandCursor 0158036341 feat(ECR-045): ImageCardDeck 写面闭环并 Closed
复用 admin.explore.write、POST/PUT+审计、C端 GET /cards/decks、
H5 无 active 空态/失败回退;migration 000054。无牌面内容编辑。
ExploreConfig Loop STOP,禁自动 ECR-046。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:29:43 +08:00
jackyu66gitandCursor dc93999f9e docs(ECR-044): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:43:08 +08:00
jackyu66gitandCursor 87b463b2bb feat(ECR-044): RhythmConfig 写面闭环并 Closed
复用 admin.explore.write、POST/PUT+审计、C端 GET /rhythm/configs、
H5 无 active 空态/失败回退;migration 000053。ExploreConfig Loop STOP,禁自动 ECR-045。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:43:08 +08:00
jackyu66gitandCursor fcc7667ba3 docs(ECR-044): Candidate Review 批准 RhythmConfig
首刀定为 RhythmConfig;Spec/Loop/Coding 未开;Auth 草案 PENDING;
排除 Prompt/Knowledge/Chunk,不预设 SystemPrompt。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:37:35 +08:00
jackyu66gitandCursor 19091bff12 docs(ECR-043): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:24:50 +08:00
jackyu66gitandCursor 77907f79ee feat(ECR-043): StarConfig 写面闭环并 Closed
加法权限 admin.explore.write、POST/PUT+审计、C端 GET /star/configs、
H5 无 active 空态/失败回退;migration 000052。ExploreConfig Loop STOP,禁自动 ECR-044。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:23:38 +08:00
jackyu66gitandCursor 2b295472a4 docs: feature-spec 索引补 ops-star-config-write
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:08:17 +08:00
jackyu66gitandCursor f492088237 docs(ECR-043): StarConfig 写面 Spec Ready(先不写码)
Human 批准 ExploreConfig Loop 仅限 043;Closed 后 STOP 禁自动 044;边界排除 Rhythm/Card/Scale/Prompt。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:07:58 +08:00
jackyu66gitandCursor cd3ac2c9ab docs: 恢复 TRACEABILITY 中 ECR-041/042 Closed 行
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:04:47 +08:00
jackyu66gitandCursor ec20e9744e docs(ECR-043): ExploreConfig 写面候选评审;收窄 Continuous Loop
CMS 041–042 锁定 Closed;下一刀不进 SystemPrompt;推荐首刀 StarConfig;禁止直接写码。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:04:36 +08:00
jackyu66gitandCursor aff6621ff8 docs(ECR-042): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:02:01 +08:00
jackyu66gitandCursor 56f661748a feat(ECR-042): OpsCMS FeedSlot 薄写面
Admin POST/PUT 复用 admin.cms.write;C 端 GET /home/feed-slots;首页无 active 槽隐藏推荐区、失败回退展示;无新 migration。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:02:01 +08:00
jackyu66gitandCursor 655141980a docs(ECR-041): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:55:27 +08:00
jackyu66gitandCursor 9707b72808 feat(ECR-041): OpsCMS Banner 薄写面(Write-Wave 首刀)
Admin POST/PUT + admin.cms.write/审计;C 端 GET /home/banners;首页投影回退静态 homeFeeds;migration 000051;不碰 FeedSlot/支付/UGC。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:55:27 +08:00
jackyu66gitandCursor ad45d17dff chore(repo): 仓侧治理门禁 — ECR/migration 身份唯一
将编号与 schema 完整性留在 Repository Governance,不扩 ESS/Loop;TRACEABILITY 锚定 Next ECR=041、Max Migration=000050。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:26:30 +08:00
jackyu66gitandCursor d81e8364cc fix(h5): 星座分享卡置顶;首页头像进个人档案
星座 ShareSheet/落地页卡片改到上方;首页顶栏个人入口从愈心解码改为 /profile。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 02:34:58 +08:00
jackyu66gitandCursor b7b0b18802 fix(db): 重编号合并后撞号 migration,并修 admin e2e base
将 015–023 双文件冲突线性化为 015–050;提供已有库 schema_migrations 修复脚本;admin Playwright 对齐 /psy/admin/ 预览路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 02:14:52 +08:00
jackyu66gitandCursor 0d50c0ee73 fix(admin): 收紧 isSuper、plan-prices RBAC 与封禁状态机
避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:56:00 +08:00
jackyu66gitandCursor 62cd8c45dd chore: 合入 stash Ops hardening 与 migration 000041
Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:46:34 +08:00
jackyu66gitandCursor 4889ff5916 merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:45:53 +08:00
jackyu66gitandCursor 1026cd0596 docs(ECR-040): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:20:42 +08:00
jackyu66gitandCursor e9c11cdf1d feat(ECR-040): ExploreConfig ScaleDefinition 只读投影并 Closed
复用 scales 表只读投影;admin-h5 /catalogs 聚合 026–040 目录;Loop 队列 STOP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:20:42 +08:00
jackyu66git 432c090046 docs(ECR-039): TEST_REPORT 补 commit sha 2026-08-08 03:17:12 +08:00
jackyu66git e955f1e6a2 feat(ECR-039): GrowthInsights FunnelDefinition 只读并 Closed
FunnelDefinition catalog (000040) · Loop continuous.
2026-08-08 03:17:12 +08:00
jackyu66git 63742c1d66 docs(ECR-038): TEST_REPORT 补 commit sha 2026-08-08 03:16:57 +08:00
jackyu66git 5de9a7bbe5 feat(ECR-038): GrowthInsights ReportTemplate 只读并 Closed
ReportTemplate catalog (000039) · Loop continuous.
2026-08-08 03:16:57 +08:00
jackyu66git b34b4f281f docs(ECR-037): TEST_REPORT 补 commit sha 2026-08-08 03:16:42 +08:00
jackyu66git 5c462ecb2a feat(ECR-037): ExploreConfig ImageCardDeck 只读并 Closed
ImageCardDeck catalog (000038) · Loop continuous.
2026-08-08 03:16:42 +08:00
jackyu66git 0fa6da2446 docs(ECR-036): TEST_REPORT 补 commit sha 2026-08-08 03:16:26 +08:00
jackyu66git bd9e13497f feat(ECR-036): ExploreConfig RhythmConfig 只读并 Closed
RhythmConfig catalog (000037) · Loop continuous.
2026-08-08 03:16:26 +08:00
jackyu66git 57acfa6105 docs(ECR-035): TEST_REPORT 补 commit sha 2026-08-08 03:16:12 +08:00
jackyu66git ebc93b6c7e feat(ECR-035): ExploreConfig StarConfig 只读并 Closed
StarConfig catalog (000036) · Loop continuous.
2026-08-08 03:16:12 +08:00
jackyu66git 09b9c4daab docs(ECR-034): TEST_REPORT 补 commit sha 2026-08-08 03:15:59 +08:00
jackyu66git 45e98f6872 feat(ECR-034): AdminGovernance PrivacyRequest 只读并 Closed
PrivacyRequest catalog (000035) · Loop continuous.
2026-08-08 03:15:59 +08:00
jackyu66git aad6cdf7ea docs(ECR-033): TEST_REPORT 补 commit sha 2026-08-08 03:15:45 +08:00
jackyu66git 487bee78d1 feat(ECR-033): AskOperations HandoffCase 只读并 Closed
HandoffCase catalog (000034) · Loop continuous.
2026-08-08 03:15:45 +08:00
jackyu66git 01063208a1 docs(ECR-032): TEST_REPORT 补 commit sha 2026-08-08 03:15:30 +08:00
jackyu66git 447fb6da16 feat(ECR-032): CrisisCare InterventionOutcome 只读并 Closed
InterventionOutcome catalog (000033) · Loop continuous.
2026-08-08 03:15:30 +08:00
jackyu66git 58eef7c02b docs(ECR-031): TEST_REPORT 补 commit sha 2026-08-08 03:15:16 +08:00
jackyu66git 24a115297b feat(ECR-031): CrisisCare CrisisEvent 只读并 Closed
CrisisEvent catalog (000032) · Loop continuous.
2026-08-08 03:15:16 +08:00
jackyu66git 0d4bc5054e docs(ECR-030): TEST_REPORT 补 commit sha 2026-08-08 03:15:00 +08:00
jackyu66git b904e6b04f feat(ECR-030): ContentSafety ModerationCase 只读并 Closed
ModerationCase catalog (000031) · Loop continuous.
2026-08-08 03:15:00 +08:00
jackyu66git 271e312669 docs(ECR-029): TEST_REPORT 补 commit sha 2026-08-08 03:14:45 +08:00
jackyu66git 5846d39d97 feat(ECR-029): ContentSafety BlockPolicy 只读并 Closed
BlockPolicy catalog (000030) · Loop continuous.
2026-08-08 03:14:45 +08:00
jackyu66git e6740a0326 docs(ECR-028): TEST_REPORT 补 commit sha 2026-08-08 03:14:30 +08:00
jackyu66git 00d9ed5610 feat(ECR-028): AICoreConfig ToolDefinition 只读并 Closed
ToolDefinition catalog (000029) · Loop continuous.
2026-08-08 03:14:30 +08:00
jackyu66git 1f572c4151 docs(ECR-027): TEST_REPORT 补 commit sha 2026-08-08 03:14:17 +08:00
jackyu66git c1a8a58488 feat(ECR-027): AICoreConfig KnowledgeChunk 只读并 Closed
KnowledgeChunk catalog (000028) · Loop continuous.
2026-08-08 03:14:17 +08:00
jackyu66gitandCursor 78857d5510 docs(ECR-026): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:13:36 +08:00
jackyu66gitandCursor b91806ba80 feat(ECR-026): OpsCMS ScheduledPublication 只读并 Closed
定时发布目录(ops_scheduled_publications),并加固 catalog 生成器。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:13:36 +08:00
jackyu66gitandCursor 8c50b3d925 docs(ECR-025): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:08:40 +08:00
jackyu66gitandCursor e32466357d feat(ECR-025): OpsCMS FeedSlot 只读并 Closed
栏目位目录(ops_feed_slots + /cms),复用 admin.cms.read。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:08:40 +08:00
jackyu66gitandCursor f51b2524c5 docs(ECR-024): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:05:55 +08:00
jackyu66gitandCursor 32ac559385 feat(ECR-024): OpsCMS Banner 只读并 Closed
运营横幅目录(ops_banners + admin /cms),锁定 024–040 全队列。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 03:05:55 +08:00
jackyu66gitandCursor ac1aec857d docs(ECR-023): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 01:49:58 +08:00
jackyu66gitandCursor bf1ae8bc53 feat(ECR-023): AICoreConfig KnowledgeSource 只读并 Closed
运营可观测知识源目录(knowledge_sources + admin /ai),不含 Chunk/Embedding。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 01:49:58 +08:00
jackyu66gitandCursor dd4d644eaa docs(ECR-022): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 01:28:45 +08:00
jackyu66gitandCursor de224184a6 feat(ECR-022): CrisisCare CrisisPolicy 只读并 Closed
新增 crisis_policies、admin.crisis.read、列表/试匹配 API 与 admin-h5「危机」页;禁 CrisisEvent 写/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 01:28:45 +08:00
jackyu66gitandCursor 4b5e5bc63d docs(ECR-021): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 00:27:17 +08:00
jackyu66gitandCursor 140b08ca01 feat(ECR-021): AICoreConfig SystemPrompt 只读并 Closed
新增 system_prompts 目录、admin.ai_config.read 与 admin-h5「AI」页;本切片不改运行时 Prompt、禁写发布/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 00:27:17 +08:00
jackyu66gitandCursor 22f9e358ae docs(ECR-020): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:02:13 +08:00
jackyu66gitandCursor 93227d3316 feat(ECR-020): QualityFeedback 问答质量反馈并 Closed
新增 ask_quality_feedback、运营/C端评分 API 与 admin-h5 问答反馈区;禁改消息/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:02:13 +08:00
jackyu66gitandCursor e270a38393 docs(ECR-019): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:13:23 +08:00
jackyu66gitandCursor cae3380cbf feat(ECR-019): ContentSafety FilterRule 只读并 Closed
新增 filter_rules、admin.content_safety.read、列表/详情/试匹配 API 与 admin-h5「安全」页;禁审核写/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:13:22 +08:00
jackyu66gitandCursor de025d72bc docs(ECR-018): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:26:16 +08:00
jackyu66gitandCursor c81f57a7d1 feat(ECR-018): Entitlement 用户权益只读并 Closed
聚合 GET /admin/users/:id/entitlements(Membership∪DeepAccess∪问答额度)与 admin-h5 权益 Tab;无 migration / 无真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:26:15 +08:00
jackyu66gitandCursor 37b91e51b8 docs(ECR-017): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:39:38 +08:00
jackyu66gitandCursor 00a798bd85 feat(ECR-017): AskOperations 问答会话只读并 Closed
新增 admin.ask.read、GET /admin/ask/threads*(AskSessionView)与 admin-h5「问答」页;禁改消息/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:39:37 +08:00
jackyu66gitandCursor 1c157a0e46 docs(ECR-016): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:31:40 +08:00
jackyu66gitandCursor 61ae3b0451 feat(ECR-016): UserIntelligence 用户洞察只读切片并 Closed
聚合 GET /admin/users/:id/insight(报告类型/派生标签/行为快照),admin-h5 洞察 Tab;无 migration / 无 UGC / 无真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:31:06 +08:00
jackyu66gitandCursor 1afda1d389 docs: 队列指向 ECR-016 UserIntelligence
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:17:06 +08:00
jackyu66gitandCursor 1eeb0b00e7 feat(ECR-015): RedemptionCode 兑换码并 Closed
批次生成/作废、C 端兑码延长会员;admin-h5 /codes。
Loop continuous。Next:ECR-016 UserIntelligence。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:16:13 +08:00
jackyu66gitandCursor 0e26aabef8 docs: 同步 ECR-014 Closed 与 Loop 队列指向 ECR-015
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:04:43 +08:00
jackyu66gitandCursor 882c01d81a feat(ECR-014): MembershipPlan 套餐配置并 Closed
membership_plans 表、admin 套餐页、Grant/CreateOrder 读表;
Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:03:38 +08:00
jackyu66gitandCursor e25cd94b0c feat(ECR-013B): AccountLifecycle Closed;启用 Loop 连续执行
UserStatus 迁移、DeviceAuth 拒绝非 active、admin-h5 CTA;
Reviewer Closed。Human 授权 LOOP_AUTHORIZATION(免逐闸确认)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:47:18 +08:00
jackyu66gitandCursor f75b42397f docs(ECR-013B): 启动 AccountLifecycle 契约定义
Human Start Authorization:UserStatus 状态机 Spec/BD/contract_diff;
禁止 coding,待 Approve + coding Start Authorization。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:23:04 +08:00
jackyu66gitandCursor b5a05941d9 feat(ECR-013A): Admin RBAC 实现并 Closed
角色权限、RequirePermission、/me permissions 与 migration 000015;
Reviewer Approve → Closed。Next:ECR-013B Contract Definition。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:19:45 +08:00
jackyu66gitandCursor 85ed0901bb docs: record WAVE0 Human Review FREEZE
Human Decision=FREEZE on foundation 27f27a1; next gate ECR-013A contract only after Start Authorization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:31:42 +08:00
jackyu66gitandCursor 27f27a1cb2 docs: freeze WAVE0 domain foundation and contract governance
Mark WAVE0 FROZEN CANDIDATE for Human Review; docs + .ai domain/product only — no apps or migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:20:11 +08:00
jackyu66gitandCursor d33c8fdfe9 feat(ECR-012): 星座对齐收口,并 Closed ECR-007/008
对齐 outlook/分享 type=star/报告页运势面板与测试;流程上关闭 Ops-B/C 两张 ECR。真支付仍后置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:48:19 +08:00
860 changed files with 35179 additions and 477 deletions
+29
View File
@@ -0,0 +1,29 @@
# ADR-0008 — Go 为唯一用户后台,咨询域过渡反代
- Status: Accepted
- Date: 2026-09-14
- Tags: identity, miniprogram, consult, gateway
## Context
愈心谷 C 端同时打两套后台:咨询/测评在 Java + MySQL,愈心魔方在 Go + PostgreSQL。
`wx.login``code` 只能用一次,无法先后换两套 token。小程序需要单一身份与单一请求入口。
## Decision
1. **Go + PostgreSQL 是唯一用户身份源。** 微信 openid / 手机号写在 `users`
2. **Java 过渡期为被调方**Go 用 openid/phone 调内网 `issue-mini-app` 换 Java Redis token,并反代 `/app-api``/admin-api`
3. **咨询业务表本 ADR 不迁**;支付与档期另开 ECR。
4. **Admin 登录独立**,不走本决策。
## Consequences
- 小程序只配置 Go 域名(现网经 `/psy/api/` 反代)。
- 下线 Java 必须另开 ECR,并先迁支付。
- 历史魔方账密用户按手机号合并到 `wx_openid`
## Alternatives considered
- 只做 SSO、双库长期并存:否决(用户要求合库合服务)。
- 以 Java 吸收魔方:否决(栈已锁定 Go + PG)。
- 客户端双 token:否决(code 单次使用,且拖住前端统一接入)。
+6 -1
View File
@@ -3,5 +3,10 @@
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| [../domain.md](../domain.md) | Tech ↔ 用户名 | | [../domain.md](../domain.md) | Tech ↔ 用户名 |
| [domain-map.md](domain-map.md) | Bounded contexts冻结 | | [domain-map.md](domain-map.md) | Bounded contextsP1 冻结 + Ops Wave 0 Draft |
| [erd.md](erd.md) | P1 表结构草案(冻结) | | [erd.md](erd.md) | P1 表结构草案(冻结) |
| [boundary-rules.md](boundary-rules.md) | BC owns / allowed / forbidden**P0** · Wave 0 |
| [glossary.yaml](glossary.yaml) | 工程/API 唯一词(与 lexicon 双轨) |
| [entity-catalog.md](entity-catalog.md) | 概念级实体目录(无 migration |
Ops Contract-First 归档:`docs/WAVE0/`
+239
View File
@@ -0,0 +1,239 @@
# Bounded Context Boundary Rules
> Wave 0 · **P0 资产** · AI / Engineer 只读;变更须 Architect + Human
> 运行时权威副本:`.ai/domain/boundary-rules.md`
Status: `Draft`Human Review 后 Frozen
---
## 如何读
- **owns**:该 BC **唯一写权威**(修改入口)。
- **does_not_own**:明示禁止「顺手改」。
- **allowed**:允许的依赖(多为读或经门面)。
- **forbidden**:禁止的依赖(含跨 BC 写支付/会员等)。
违反本文件 = Reviewer **BLOCK**(架构回归)。
---
## Admin_Auth_Audit
```yaml
Admin_Auth_Audit:
owns:
- AdminAccount
- AdminSession
- AuditLog
does_not_own:
- UserStatus
- MembershipPlan
- Entitlement
allowed:
- Admin_RBAC.read
forbidden:
- Payment.write
- C端业务聚合根的隐式写入
```
## Admin_RBAC
```yaml
Admin_RBAC:
owns:
- AdminRole
- AdminPermission
does_not_own:
- User
- Profile
- Membership
allowed:
- Admin_Auth_Audit.write_audit
forbidden:
- Payment
- Membership.write
- DeviceAuth.user_token_issue
```
## Identity_Profile
```yaml
Identity_Profile:
owns:
- User
- Profile
- DeviceIdentity
- UserStatus
- BehaviorSnapshot
- PsychologicalTagSet
does_not_own:
- MembershipPlan
- Entitlement
- Order
- Payment
- RedemptionCode
allowed:
- Analytics_OpsB.read
forbidden:
- Payment
- Membership.write
```
## Membership_Orders
```yaml
Membership_Orders:
owns:
- MembershipPlan
- Entitlement
- Membership
- Order
- DeepAccess
- RedemptionCode
- Payment # mock 今;真支付适配器后置,仍归本 BC
does_not_own:
- UserStatus
- AdminRole
- FilterRule
allowed:
- Identity_Profile.read
forbidden:
- Ask_Ops.write_prompt
- Content_Safety.rule_mutate_via_order
```
## Analytics_OpsB
```yaml
Analytics_OpsB:
owns:
- AnalyticsSession
- AnalyticsEvent
does_not_own:
- User
- Membership
allowed:
- Identity_Profile.read_ids_only
forbidden:
- Payment
- 采集 PII 正文(生日/问答全文等)
```
## Ops_Content
```yaml
Ops_Content:
owns:
- HomeTool
- ScalePublishState
does_not_own:
- Scale 题目正文编辑器(未开 ECR 前禁止扩张)
allowed:
- Explore_Reports.publish_gate
forbidden:
- Community
- Payment
```
## Account_Risk
```yaml
Account_Risk:
owns:
- RiskFlag
- BanRecord
does_not_own:
- MembershipPlan
allowed:
- Identity_Profile.UserStatus.transition # 经明确应用服务
- Admin_Auth_Audit.write_audit
forbidden:
- Payment.refund_silent
```
## Content_Safety
```yaml
Content_Safety:
owns:
- FilterRule
- ModerationCase
- BlockPolicy
- CrisisEvent
- CrisisPolicy
does_not_own:
- Membership
- Payment
- AdminRole
allowed:
- Identity_Profile.read
- Ask_Ops.read_session_meta
forbidden:
- Membership.write
- Payment
```
## Ask_Ops
```yaml
Ask_Ops:
owns:
- AskSessionView
- QualityFeedback
- HandoffCase
- SystemPrompt
- KnowledgeSource
- KnowledgeChunk
- ToolDefinition
does_not_own:
- Payment
- MembershipPlan
- UserStatus
allowed:
- Identity_Profile.read
- Content_Safety.evaluate
forbidden:
- Payment
- Membership.write
- Admin_RBAC.write
```
## Ops_CMS_NoUGC
```yaml
Ops_CMS_NoUGC:
owns:
- Banner
- FeedSlot
- ScheduledPublication
does_not_own:
- UGC Post
- CommentGraph
- TrustScore
allowed:
- Admin_RBAC.check
forbidden:
- Community
- 用户生成内容入库为主路径
```
## Community
```yaml
Community:
status: Forbidden
owns: []
note: feature-map UGC 广场 [No];开启须 L3 ADR + 新 Capability
```
---
## 反模式(禁止)
```text
AskService → load User → update Membership → charge Payment
UserService.updateMembership()
Content_Safety 直接改 Entitlement
Ops_CMS 引入「用户帖子」表却声称 NoUGC
```
+20
View File
@@ -23,6 +23,26 @@
--- ---
## Ops Platform Bounded ContextsWave 0 Draft
> 详细 owns/forbidden[`boundary-rules.md`](boundary-rules.md) · 词表:[`glossary.yaml`](glossary.yaml) · 实体:[`entity-catalog.md`](entity-catalog.md)
> 归档:`docs/WAVE0/domain/` · Capability`docs/WAVE0/capability/ops-capability-map.md`
> **不修改**上方 P1 不变量;Ops BC 实现须独立 ECR。
| Context | Capability | Status |
|---|---|---|
| Admin_Auth_Audit | AdminGovernance | Shipped Ops-A |
| Admin_RBAC | AdminGovernance | Draft → ECR-013A |
| Analytics_OpsB | GrowthInsights / UserIntelligence(read) | Shipped Ops-B |
| Ops_Content | ExploreConfig (partial) | Shipped Ops-C |
| Account_Risk | AccountLifecycle | Spec via ECR-013BIdentity_Profile owns UserStatus |
| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**CrisisCare **ECR-022**CrisisPolicy 只读) |
| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017/020**AICoreConfig **ECR-021**SystemPrompt 只读) |
| Ops_CMS_NoUGC | OpsCMS | Draft |
| Community | — | **Forbidden** |
---
## Context diagram ## Context diagram
``` ```
+43
View File
@@ -0,0 +1,43 @@
# Entity CatalogWave 0 · 概念级)
> 仅登记名称、归属 BC、Capability、生命周期备注。**不建表、不写 migration。**
> 与 [`glossary.yaml`](glossary.yaml) · [`boundary-rules.md`](boundary-rules.md) 一致。
Status: `Draft`
| Entity | BC | Capability | Notes |
|--------|----|------------|-------|
| AdminAccount | Admin_Auth_Audit | AdminGovernance | 已存在 Ops-A |
| AdminSession | Admin_Auth_Audit | AdminGovernance | 已存在 |
| AuditLog | Admin_Auth_Audit | AdminGovernance | 已存在;只追加 |
| AdminRole | Admin_RBAC | AdminGovernance | **ECR-013A** |
| AdminPermission | Admin_RBAC | AdminGovernance | **ECR-013A** |
| User | Identity_Profile | AccountLifecycle | 已存在 |
| Profile | Identity_Profile | UserIntelligence | 已存在 |
| UserStatus | Identity_Profile | AccountLifecycle | **ECR-013B** 状态机 |
| AccountStateTransition | Identity_Profile | AccountLifecycle | **ECR-013B** |
| BehaviorSnapshot | Identity_Profile | UserIntelligence | **ECR-016** 读模型(analytics 聚合) |
| PsychologicalTagSet | Identity_Profile | UserIntelligence | **ECR-016** 由报告 type 派生 |
| MembershipPlan | Membership_Orders | CommerceEntitlement | **ECR-014 Closed** |
| Entitlement | Membership_Orders | CommerceEntitlement | **ECR-018** 读模型(MembershipDeepAccess |
| RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** |
| Membership | Membership_Orders | CommerceEntitlement | 已存在 |
| Order | Membership_Orders | CommerceEntitlement | 已存在 |
| DeepAccess | Membership_Orders | CommerceEntitlement | 已存在 |
| Payment | Membership_Orders | CommerceEntitlement | mock;真支付最后 |
| HomeTool | Ops_Content | ExploreConfig | 已存在 Ops-C |
| ScalePublishState | Ops_Content | ExploreConfig | 已存在 |
| AnalyticsSession / Event | Analytics_OpsB | GrowthInsights | 已存在 Ops-B |
| FilterRule | Content_Safety | ContentSafety | **ECR-019** 只读 + evaluate |
| ModerationCase | Content_Safety | ContentSafety | 后置 |
| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | **ECR-022** CrisisPolicy 只读;CrisisEvent 后置 |
| SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | **ECR-021** SystemPrompt 只读;Knowledge/Tools 后置 |
| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView**ECR-020** QualityFeedback |
| Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 |
| UGC* | Community | — | **Forbidden** |
## 状态机(ECR-013B · 契约已开)
`UserStatus`: 见 `.ai/product/feature-spec/ops-account-lifecycle.md`
`deleted` soft-delete **不在 013B**(另开)。
实现轮前:**禁止** migration / DeviceAuth 改动。
+116
View File
@@ -0,0 +1,116 @@
# domain glossary — engineering / API unique terms
# Wave 0 Draft. Runtime copy: .ai/domain/glossary.yaml
# User-facing copy stays in .ai/product/lexicon.md
# Conflict: Architect + Human resolve; do not invent synonyms in code.
version: "0.1.0"
status: draft
terms:
AdminRole:
zh: 管理员角色
bc: Admin_RBAC
forbidden: [admin_group, privilege_group]
AdminPermission:
zh: 管理员权限点
bc: Admin_RBAC
forbidden: [acl_flag, authz_bit]
AuditLog:
zh: 操作审计日志
bc: Admin_Auth_Audit
forbidden: [admin_history]
UserStatus:
zh: 用户账户状态
bc: Identity_Profile
values: [active, disabled, banned, suspended]
forbidden: [user_flag, account_level]
AccountStateTransition:
zh: 账户状态迁移
bc: Identity_Profile
forbidden: [status_patch_log]
MembershipPlan:
zh: 会员套餐
bc: Membership_Orders
forbidden: [package, vip_level, 订阅档, sku_vip]
Entitlement:
zh: 权益
bc: Membership_Orders
forbidden: [privilege_pack, benefit_bundle]
RedemptionCode:
zh: 兑换码
bc: Membership_Orders
forbidden: [gift_card, activation_key_alias]
DeepAccess:
zh: 深度版
bc: Membership_Orders
forbidden: [Unlock, unlock_report]
BehaviorSnapshot:
zh: 行为快照
bc: Identity_Profile
forbidden: [user_track_dump]
PsychologicalTagSet:
zh: 心理标签集
bc: Identity_Profile
forbidden: [psy_labels_raw]
FilterRule:
zh: 过滤规则
bc: Content_Safety
forbidden: [badword_only]
ModerationCase:
zh: 审核工单
bc: Content_Safety
forbidden: [review_ticket_ugc]
CrisisEvent:
zh: 危机事件
bc: Content_Safety
forbidden: [suicide_flag_public]
CrisisPolicy:
zh: 危机策略
bc: Content_Safety
forbidden: [kill_switch_generic]
SystemPrompt:
zh: 系统提示词
bc: Ask_Ops
forbidden: [god_prompt]
KnowledgeSource:
zh: 知识源
bc: Ask_Ops
forbidden: [rag_file]
KnowledgeChunk:
zh: 知识块
bc: Ask_Ops
forbidden: [embedding_row_ui]
ToolDefinition:
zh: 工具定义
bc: Ask_Ops
forbidden: [function_call_config_loose]
Banner:
zh: 运营横幅
bc: Ops_CMS_NoUGC
forbidden: [story_feed_ugc]
Community:
zh: (禁止能力)
bc: Community
status: forbidden
forbidden: [ugc_plaza, 社区广场]
+10 -8
View File
@@ -228,15 +228,17 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
## 7. 运营后台(内部 · 非 C 端 Tab) ## 7. 运营后台(内部 · 非 C 端 Tab)
详见 Spec[feature-spec/ops-admin.md](feature-spec/ops-admin.md) · ECR-006 详见 Spec[feature-spec/ops-admin.md](feature-spec/ops-admin.md) · ECR-006
**演进规范(Wave 0):** [`docs/WAVE0/`](../../docs/WAVE0/) · Capability Map · boundary-rules · Contract-First(禁止 Feature 堆砌进实现)
| 分期 | 内容 | | 分期 | 内容 |
|---|---| |---|---|
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`ECR-006 Closed | | Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`ECR-006 Closed |
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Implemented** · Spec `ops-analytics.md` | | Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007** · Spec `ops-analytics.md` |
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Implemented** · Spec `ops-content.md` | | Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008** · Spec `ops-content.md` |
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md` | | Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md` |
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md` | | Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md` |
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵(各开独立 ECR) | | Phase D+ Contract | **Contract-First** 扩展目录:**ECR-013A…040**RBAC/兑换码/问答运营/安全/危机/CMS/ExploreConfig 等)。见 `docs/WAVE0/` |
| Phase F+ / Write-Wave | **写面加深**(首刀 **ECR-041 Banner** · `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`);封禁细策略 · 真推送;**UGC 仍 `[No]`****真支付最后** |
不计入 P1 Complete;不进入五 Tab。 不计入 P1 Complete;不进入五 Tab。
@@ -258,11 +260,11 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
| `/scales/:slug` | 探索测试作答 | | | `/scales/:slug` | 探索测试作答 | |
| `/reports` | 成长报告列表 | | | `/reports` | 成长报告列表 | |
| `/reports/:id` | 成长报告详情 | | | `/reports/:id` | 成长报告详情 | |
| `/star` | 星座 | P2 | | `/star` | 星座 | P2 Complete |
| `/synastry` | 合盘 | P2 | | `/synastry` | 合盘 | P2 Complete |
| `/synastry/invite/:token` | 合盘邀请 | P2 | | `/synastry/invite/:token` | 合盘邀请 | P2 Complete |
| `/rhythm` | 身心节律 | P2 | | `/rhythm` | 身心节律 | P2 Complete |
| `/cards` | 意象卡片 | P2 | | `/cards` | 意象卡片 | P2 Complete |
兼容重定向:`/decode``/portrait`(仅技术兼容,UI 不出现 decode 文案)。 兼容重定向:`/decode``/portrait`(仅技术兼容,UI 不出现 decode 文案)。
+37 -2
View File
@@ -21,11 +21,46 @@
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 Complete | | [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 Complete |
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 Complete | | [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 Complete |
| [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A | | [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A |
| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Implemented | | [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 |
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Implemented | | [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 |
| [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 | | [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 |
| [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 | | [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 |
| [input-compliance.md](input-compliance.md) | 用户文字输入合规 | 横切 · 安全 | 各写接口 · code 40060 | P2 · ECR-012 | | [input-compliance.md](input-compliance.md) | 用户文字输入合规 | 横切 · 安全 | 各写接口 · code 40060 | P2 · ECR-012 |
| [ops-rbac.md](ops-rbac.md) | 运营 RBAC | §7 | `admin-h5` `/me` permissions · `/admin/roles*` | Ops · **ECR-013A** |
| [ops-redemption-code.md](ops-redemption-code.md) | 兑换码 RedemptionCode | §7 | `admin-h5` `/codes` · `POST /membership/redeem` | Ops · **ECR-015(local)** |
| [ops-user-intelligence.md](ops-user-intelligence.md) | 用户洞察 UserIntelligence | §7 | `admin-h5` 用户详情「洞察」 | Ops · **ECR-016(local)** |
| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` | Ops · **ECR-017** |
| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」 | Ops · **ECR-018** |
| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` | Ops · **ECR-019** |
| [ops-quality-feedback.md](ops-quality-feedback.md) | 问答质量反馈 QualityFeedback | §7 | `admin-h5` `/ask` | Ops · **ECR-020** |
| [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` | Ops · **ECR-021** |
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` | Ops · **ECR-022** |
| [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` | Ops · **ECR-023** |
| [ops-banner.md](ops-banner.md) | OpsCMS Banner(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-024** |
| [ops-banner-write.md](ops-banner-write.md) | OpsCMS Banner **写面** | §7 | admin CMS 写 · `GET /home/banners` | Write-Wave · **ECR-041** |
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-025** |
| [ops-feed-slot-write.md](ops-feed-slot-write.md) | OpsCMS FeedSlot **写面** | §7 | admin CMS 写 · `GET /home/feed-slots` | Write-Wave · **ECR-042** |
| [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `/admin/cms/publications*` | Ops · **ECR-026** |
| [ops-knowledge-chunk.md](ops-knowledge-chunk.md) | AICoreConfig KnowledgeChunk | §7 | `/admin/ai/knowledge-chunks*` | Ops · **ECR-027** |
| [ops-tool-definition.md](ops-tool-definition.md) | AICoreConfig ToolDefinition | §7 | `/admin/ai/tools*` | Ops · **ECR-028** |
| [ops-block-policy.md](ops-block-policy.md) | ContentSafety BlockPolicy | §7 | `/admin/content-safety/block-policies*` | Ops · **ECR-029** |
| [ops-moderation-case.md](ops-moderation-case.md) | ContentSafety ModerationCase | §7 | `/admin/content-safety/cases*` | Ops · **ECR-030** |
| [ops-crisis-event.md](ops-crisis-event.md) | CrisisCare CrisisEvent | §7 | `/admin/crisis/events*` | Ops · **ECR-031** |
| [ops-intervention-outcome.md](ops-intervention-outcome.md) | CrisisCare InterventionOutcome | §7 | `/admin/crisis/interventions*` | Ops · **ECR-032** |
| [ops-handoff-case.md](ops-handoff-case.md) | AskOperations HandoffCase | §7 | `/admin/ask/handoffs*` | Ops · **ECR-033** |
| [ops-privacy-request.md](ops-privacy-request.md) | AdminGovernance PrivacyRequest | §7 | `/admin/privacy/requests*` | Ops · **ECR-034** |
| [ops-star-config.md](ops-star-config.md) | ExploreConfig StarConfig(只读) | §7 | `/admin/explore/star-configs*` | Ops · **ECR-035** |
| [ops-star-config-write.md](ops-star-config-write.md) | StarConfig **写面** | §7 | admin 写 · `GET /star/configs` | ExploreConfig · **ECR-043** |
| [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig(只读) | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** |
| [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** |
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck(只读) | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
| [ops-image-card-deck-write.md](ops-image-card-deck-write.md) | ImageCardDeck **写面** | §7 | admin 写 · `GET /cards/decks` | ExploreConfig · **ECR-045** |
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate(只读) | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
| [ops-report-template-write.md](ops-report-template-write.md) | ReportTemplate **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-048** |
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition(只读) | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** |
| [ops-funnel-definition-write.md](ops-funnel-definition-write.md) | FunnelDefinition **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-047** |
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition(只读) | §7 | `/admin/explore/scales*` | Ops · **ECR-040** |
| [ops-scale-definition-write.md](ops-scale-definition-write.md) | ScaleDefinition **写面** | §7 | admin POST/PUT · status 仍 ECR-008 | ExploreConfig · **ECR-046** |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
+13 -10
View File
@@ -10,13 +10,13 @@
| 字段 | 内容 | | 字段 | 内容 |
|---|---| |---|---|
| Name | 账号登录(手机号+密码 | | Name | 账号登录(小程序微信;H5 过渡账密 |
| Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 | | Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 |
| Business Goal | 数据归属清晰;为会员与深度版付费打底 | | Business Goal | 数据归属清晰;咨询与魔方同一人;为会员与深度版付费打底 |
| In | Out | | In | Out |
|---|---| |---|---|
| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) | | 小程序微信登录 / 登出 / me | 短信 OTP |
| 设备身份绑定到已注册账号 | 游客可看完整报告 | | 设备身份绑定到已注册账号 | 游客可看完整报告 |
| Bearer Session | 运营 Admin 登录(独立) | | Bearer Session | 运营 Admin 登录(独立) |
@@ -45,7 +45,7 @@
```text ```text
进入结果页或建档 进入结果页或建档
未登录? → /login 注册或登录 未登录? → 小程序 login-pop(微信)或 H5 /login(过渡账密)
Device 绑定到账号 User Device 绑定到账号 User
@@ -58,7 +58,8 @@ Device 绑定到账号 User
| 路由 | 页面 | | 路由 | 页面 |
|---|---| |---|---|
| `/login` | LoginPage注册/登录切换 | | `/login` | H5 LoginPage过渡账密;小程序已删除此页 |
| 小程序 `login-pop` | 微信手机号授权(唯一 C 端登录) |
| `/mine` | 展示账号手机号尾号 + 退出 | | `/mine` | 展示账号手机号尾号 + 退出 |
--- ---
@@ -77,8 +78,8 @@ Device 绑定到账号 User
| ID | Rule | | ID | Rule |
|---|---| |---|---|
| R1 | **临时开放** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB | | R1 | **小程序** 微信 `code` + 手机号授权登录;按 `wx_openid` 命中,否则按手机号合并历史账密账号;未命中则建号 |
| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 | | R2 | **H5 过渡:** OpenLogin(任意非空手机号+密码)仍可用,避免 H5 断服;小程序不再提供账密页。正式关掉账密须另改本 Spec |
| R3 | 登录成功后:签发 sessiondevice_identities.user_id 改绑到账号 | | R3 | 登录成功后:签发 sessiondevice_identities.user_id 改绑到账号 |
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 | | R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
| R5 | 登出作废 session;设备可再登录其他账号 | | R5 | 登出作废 session;设备可再登录其他账号 |
@@ -91,7 +92,7 @@ Device 绑定到账号 User
## 8. 数据模型影响 ## 8. 数据模型影响
- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` · `users.avatar_url` - `users.phone` UNIQUE · `users.wx_openid` UNIQUE · `users.wx_unionid` · `users.java_platform_user_id` · `users.password_hash` · `users.nickname` · `users.avatar_url`
- `user_sessions(token, user_id, expires_at)` - `user_sessions(token, user_id, expires_at)`
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径) - 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
@@ -101,8 +102,9 @@ Device 绑定到账号 User
| Method | Path | 说明 | | Method | Path | 说明 |
|---|---|---| |---|---|---|
| POST | `/api/v1/auth/register` | 注册(可带 nickname | | POST | `/api/v1/auth/wechat` | 小程序微信登录 `{ code, encryptedData, iv }` |
| POST | `/api/v1/auth/login` | 登录 | | POST | `/api/v1/auth/register` | H5 过渡注册(可带 nickname |
| POST | `/api/v1/auth/login` | H5 过渡登录 |
| POST | `/api/v1/auth/logout` | 登出 | | POST | `/api/v1/auth/logout` | 登出 |
| GET | `/api/v1/auth/me` | 当前账号(含 avatar_url | | GET | `/api/v1/auth/me` | 当前账号(含 avatar_url |
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` | | PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
@@ -159,3 +161,4 @@ Device 绑定到账号 User
- [x] lexicon:登录/注册/账号 - [x] lexicon:登录/注册/账号
- [x] 不碰 Admin Auth - [x] 不碰 Admin Auth
- [x] 咨询域见 Spec `consult-miniprogram`ECR-050
@@ -0,0 +1,22 @@
# Feature Spec: 小程序咨询域(原生 Go)
> Status: `Active` · ECR-050
## 1. 功能定义
小程序首页/咨询/测评/订单/我的资料不再打 Java。Go 用 PostgreSQL 提供与原 `/psychic/*` 相同的字段合同。
## 2. Business Rules
| ID | Rule |
|---|---|
| C1 | 未登录可看 banner、资讯、测评列表、咨询师、档期、协议 |
| C2 | 登录后才能交卷、关注、下单、改资料、反馈 |
| C3 | 下单锁时段;取消未支付订单释放时段 |
| C4 | 金额单位:分;支付 openid = `users.wx_openid` |
| C5 | 测评仅单选计分,按分数区间匹配结果 |
| C6 | 协议路径保持历史拼写 `procotol` |
## 3. API
全部挂在 `/api/v1/psychic/*`,字段与原 Java App VO 一致(见 ECR-050 BD)。
@@ -0,0 +1,156 @@
# Feature Spec: 账户生命周期 / UserStatusOps · ECR-013B
> Status: `Active`**Closed** · Feature Complete)· Map: `§7 运营后台` · Phase: `Ops-D`
> Parent: **WAVE0-FROZEN** (`27f27a1`) · Predecessor: **ECR-013A Closed**
> ESS: `docs/ECR/ECR-013B-account-lifecycle.md`**Closed**
> Capability: `AccountLifecycle` · BC: `Identity_Profile`
> 模板:`docs/WAVE0/contracts/OPS-CONTRACT-TEMPLATE.md`
---
## ESS 门禁
1. Change Level = **L2** → 已 Approved + Coding auth + Implemented + **Closed**
2. ~~Contract Definition only~~ — 已完成
3. 实现证据:`docs/TEST_REPORT/ECR-013B.md` · `docs/CODE_REVIEW/ECR-013B.md`
---
## L0 Capability
| 字段 | 内容 |
|------|------|
| Capability ID | `AccountLifecycle` |
| Purpose | 以可审计状态机管理 C 端用户账户启停与封禁,阻断违规会话 |
| Why now | `users.status` 字段已存在但无运营迁移与强制拒绝;013A 权限面就绪后可挂状态写权限 |
| Non-goals | soft-delete / `deleted`;UGC 社区封禁;真支付;Admin 账号启停(已有 admin.status);推送通知 |
---
## L1 Bounded Context
| 字段 | 内容 |
|------|------|
| Primary BC | `Identity_Profile` |
| owns | `User` · `UserStatus` · `AccountStateTransition` |
| does_not_own | `AdminRole` · `Membership` · `Payment` · `BanRecord`Account_Risk 后置加深) |
| allowed | `Admin_Auth_Audit.write_audit`(经 admin 调用) |
| forbidden | `Payment` · `Membership.write` · soft-delete User |
权威:`.ai/domain/boundary-rules.md` · `Account_Risk` 仅允许经明确服务触发 `UserStatus.transition`
---
## 1. 功能定义
| 字段 | 内容 |
|------|------|
| Name | Account Lifecycle / UserStatus |
| Purpose | 运营可迁移用户状态;非 active 会话在 C 端被拒绝 |
| Business Goal | 风险处置最小闭环,不引入社区/UGC |
| In | Out |
|---|---|
| 状态机 `active/disabled/banned/suspended` | soft-delete / GDPR 擦除 |
| Admin 迁移 API + AuditLog + Transition 记录 | 站内推送 / 短信 |
| DeviceAuth / Bearer 对非 active 拒绝 | AdminAccount.status013A 外) |
| 权限码 `admin.users.status.write` | 行级数据 ACL |
---
## L2 Domain
| Entity | 不变式 / 状态机 |
|--------|----------------|
| `UserStatus` | 取值冻结:`active` · `disabled` · `banned` · `suspended`;非法值拒写 |
| `User.status` | 与 `UserStatus` 同值;默认 `active`(已有列) |
| `AccountStateTransition` | 只追加;记录 from→to · admin_id · reason · created_at |
### 合法迁移
```text
active → disabled | banned | suspended
disabled → active | banned
suspended → active | banned | disabled
banned → active | disabled
```
其它边 → **400**。同状态写 → **400**(幂等拒绝,避免空审计噪音)。
### C 端效应
| Status | DeviceAuth / 已登录 Bearer |
|--------|------------------------------|
| `active` | 放行 |
| `disabled` / `banned` / `suspended` | **401**(或 403 统一码,实现轮定一)+ 不可发新 session |
---
## L3 API Contract(意图 · 实现轮同步 OpenAPI)
前缀:`/api/v1/admin` · AdminAuth · 信封 `{code,message,data}`
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| POST | `/users/:id/status` | `admin.users.status.write` | 迁移 UserStatus;写 Transition + AuditLog |
| GET | `/users/:id/status-transitions` | `admin.users.read` | 最近迁移列表(limit |
既有 `GET /users` · `GET /users/:id` 已暴露 `status` — 保持;实现轮确认枚举文档化。
`contract_diff``docs/CONTRACT_DIFF/ECR-013B.yaml`
RBAC catalog **additive**`admin.users.status.write` → 种子写入 `super_admin`migration)。
---
## L4 Acceptance Criteria
### Functional
| ID | Given | When | Then |
|----|-------|------|------|
| AC-F-01 | 用户 `active` | POST status=`banned` + reason | 200GET user.status=`banned` |
| AC-F-02 | 用户 `banned` | POST status=`active` | 200;可再次 DeviceAuth |
| AC-F-03 | 非法边(如 `active``active` | POST | **400** |
| AC-F-04 | GET status-transitions | — | 含最近 from/to/admin/reason |
### Security
| ID | Given | When | Then |
|----|-------|------|------|
| AC-S-01 | Admin 无 `admin.users.status.write` | POST status | **403** + deny audit |
| AC-S-02 | 用户 `banned` | C 端带原 Bearer 访问受保护 API | **401/403** |
| AC-S-03 | 仅 DeviceAuth 无 Admin | POST `/admin/users/:id/status` | **401** |
| AC-S-04 | 无 Admin session | GET transitions | **401** |
### Performance
| ID | Given | When | Then |
|----|-------|------|------|
| AC-P-01 | transitions ≤1000 行/用户 | GET transitions limit=50 | 本机 P95 **&lt; 500ms** |
### Observability
| ID | Given | When | Then |
|----|-------|------|------|
| AC-O-01 | 迁移成功 | — | AuditLog action=`users.status.transition` |
| AC-O-02 | 迁移成功 | — | `account_state_transitions` 有对应行 |
---
## Forbidden(本切片)
- soft-delete / `deleted` 状态
- 真支付 · UGC · Prompt · 兑换码 · Crisis 深化
- 改 Admin RBAC 模型(仅 **additive** 一枚 permission
- 自动开下一 ECR
---
## Implementation Notes(实现轮才执行)
| 项 | 内容 |
|----|------|
| Migration | YEStransitions 表;permission 种子;必要时 CHECK/注释枚举) |
| Packages | service account/lifecycle · DeviceAuth/session 拒绝 · admin handler · admin-h5 用户详情最小 CTA |
| Depends | ECR-013A Closedpermission middleware |
+15 -3
View File
@@ -208,7 +208,19 @@ Phase A 可先 `console`/本地;不挡验收。
| Phase | 内容 | | Phase | 内容 |
|---|---| |---|---|
| **A(本 ECR** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 | | **A(本 ECR** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 |
| **BECR-007** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` | | **BECR-007 Closed** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` |
| **CECR-008** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md`Approved | | **CECR-008 Closed** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md` |
| **EECR-013A Closed** | Admin RBAC — Spec `ops-rbac.md` · Parent WAVE0-FROZEN |
| **FECR-013B Closed** | AccountLifecycle — Spec `ops-account-lifecycle.md` |
| **GECR-014 Closed** | MembershipPlan — Spec `ops-membership-plan.md` |
| **HECR-015 Closed** | RedemptionCode — Spec `ops-redemption-code.md` |
| **IECR-016 Closed** | UserIntelligence — Spec `ops-user-intelligence.md` |
| **JECR-017 Closed** | AskOperations — Spec `ops-ask-operations.md` |
| **KECR-018 Closed** | Entitlement — Spec `ops-entitlement.md` |
| **LECR-019 Closed** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
| **MECR-020 Closed** | QualityFeedback — Spec `ops-quality-feedback.md` |
| **NECR-021 Closed** | AICoreConfig SystemPrompt — Spec `ops-ai-core-config.md` |
| **OECR-022 Closed** | CrisisCare CrisisPolicy — Spec `ops-crisis-care.md` |
| 后置 | Knowledge·Tools 写面 / CrisisEvent / 真支付(Loop 续跑) |
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
| E | RBAC · 封禁 · 推送占位(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 |
@@ -0,0 +1,41 @@
# Feature Spec: AICoreConfig · SystemPromptOps · ECR-021
> Status: `Active`Loop continuous · **ECR-021 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-020 Closed
> Capability: `AICoreConfig` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
Prompt 在线编辑/发布 · KnowledgeSource/Chunk · ToolDefinition · 切换运行时引擎 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `SystemPrompt` | code 唯一;body 为模板文本;active/system;本切片只读 |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ai/system-prompts` | `admin.ai_config.read` | 列表 |
| GET | `/admin/ai/system-prompts/:id` | 同上 | 详情(含 body |
## Migration
`000022``system_prompts` + 种子 `ask_default` + 授予权限
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含 ask_default |
| AC-F-02 | get 返回 body 非空 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 ai_config.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-021.yaml`
+1 -1
View File
@@ -1,7 +1,7 @@
# Feature Spec: 运营行为分析(Ops-B # Feature Spec: 运营行为分析(Ops-B
> Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-B` > Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-B`
> ESS`docs/ECR/ECR-007-ops-analytics.md`Approved · L2)· BD-2026-007 Approved > ESS`docs/ECR/ECR-007-ops-analytics.md`**Closed** · L2)· BD-2026-007 Approved
> 关联:[ops-admin.md](ops-admin.md) · [analytics.md](analytics.md) · [cece-frontend-re/complete-design/40-ops-commerce.md](cece-frontend-re/complete-design/40-ops-commerce.md) > 关联:[ops-admin.md](ops-admin.md) · [analytics.md](analytics.md) · [cece-frontend-re/complete-design/40-ops-commerce.md](cece-frontend-re/complete-design/40-ops-commerce.md)
--- ---
@@ -0,0 +1,42 @@
# Feature Spec: AskOperationsOps · ECR-017
> Status: `Active`Loop continuous · **ECR-017 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-016 Closed
> Capability: `AskOperations` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
改写/删除消息正文 · SystemPrompt/Knowledge 配置 · QualityFeedback 写回 · AICoreConfig · 真支付 · UGC
## L2 读模型
| 概念 | 语义 |
|------|------|
| `AskSessionView` | 线程元数据 + message_count(列表) |
| 会话详情 | 只读消息序列(role/content/created_at |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ask/threads` | `admin.ask.read` | 列表(可选 `user_id` |
| GET | `/admin/ask/threads/:id` | `admin.ask.read` | 会话 meta(不含原文) |
| GET | `/admin/ask/threads/:id/messages` | `admin.ask.transcript.read` | 对话原文(审计 `ask.transcript.read` |
## Migration
`000019`:为 `super_admin` 授予 `admin.ask.read`(无新业务表)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | 有消息线程 → list 含该 thread · message_count≥1 |
| AC-F-02 | detail 返回 user+assistant 消息 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 ask.read → 403 |
| AC-P-01 | list &lt; 500ms 本机 |
| AC-O-01 | N/A 只读(无写审计强制) |
contract_diff: `docs/CONTRACT_DIFF/ECR-017.yaml`
@@ -0,0 +1,112 @@
# Feature Spec: OpsCMS · Banner 写面(Ops · ECR-041
> Status: `Active`**ECR-041 Closed** · Feature Complete
> Map: `§7 运营后台` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> Parent: WAVE0-FROZEN · Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
> Predecessor: ECR-024 ClosedBanner 只读)
> 关联:[ops-banner.md](ops-banner.md)(只读基线)· [ops-content.md](ops-content.md) · [home.md](home.md)
---
## 1. 目标
让运营可 **创建 / 更新 / 上下架** BannerC 端首页对 `placement=home` 的 active Banner **生效**;写操作鉴权 + 审计;失败可回退静态。
验证 Write-Wave 首刀闭环,**不**做 FeedSlot、真支付、UGC。
## 2. In / Out
| In | Out |
|----|-----|
| AdminBanner create / update / 设 `active` | **FeedSlot** 一切写与 API |
| 加法权限 `admin.cms.write`(既有 RBAC | soft-delete 物理删行(`system=true` 禁止删) |
| C 端:`GET` active banners(至少 `placement=home` | ScheduledPublication 写 · 外链任意 URL |
| 首页推荐区:API Banner 投影优先,空则回退静态 `homeFeeds` | 把 FeedSlot 与 Banner 合并为一 ECR |
| `admin_audit_logs` 记录写操作 | Crisis / Handoff / ContentSafety 写 · 真支付 · UGC |
| OpenAPI · contract_diff · integration | 新权限子系统 · 改引擎分层 |
### 与 `homeFeeds` 的关系(诚实边界)
- ECR-008 将「首页 Feeds CMS」后置;表意信息流位属 **FeedSlot**ECR-025 只读,写面另 ECR)。
- **本切片**:用 **Bannerplacement=home** 驱动首页推荐区展示(投影字段见下);**不**写 `ops_feed_slots`
- 完整 Feeds 槽位运营(多 slot、排期)→ 后续 FeedSlot 写面 ECR。
## 3. Domain
| 概念 | 语义 |
|------|------|
| `Banner` | `code` 唯一;`placement` ∈ {home,explore,ask}`active``system` |
| 写语义 | upsert 字段;`active=false` = 下架;禁止 soft-delete 列/物理删 system 行 |
| C 端投影 | 仅 `active=true`;按 `sort_order` 升序 |
### 首页投影映射(Banner → 原 homeFeeds 卡)
| homeFeeds 字段 | 来源 |
|----------------|------|
| `to` | `link_path`(须站内 `/` 相对路径) |
| `title` | `title` |
| `meta` | 可选:无则空串或固定「运营推荐」 |
| `icon` / `tone` / `stat` / `tag` | 本切片可用缺省;**不**扩表除非 migration 明确(默认缺省,避免 scope creep |
## 4. API
### Admin(既有前缀 `/api/v1/admin/cms`
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/banners` · `/banners/{id}` | `admin.cms.read` | 保持 ECR-024 |
| POST | `/banners` | `admin.cms.write` | 创建(code 唯一) |
| PUT | `/banners/{id}` | `admin.cms.write` | 更新 title/placement/image_url/link_path/sort_order/active |
| — | DELETE | — | **不做**(下架用 `active=false` |
校验:`link_path` 若非空须以 `/` 开头、禁 `http(s):``placement` 枚举;`system=true` 行不可改 `code`、不可删。
### C 端
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| GET | `/api/v1/home/banners` | DeviceAuth(与 `/home/tools` 同级) | 仅 `active`query `placement` 默认 `home` |
## 5. Permission / Audit
- Migration 授予 `super_admin``admin.cms.write`(加法码,非新 RBAC 模型)。
- 每次 POST/PUT 写 `admin_audit_logs`actor · action · target id/code · diff 摘要)。
## 6. Migration
- 若仅需权限种子:`000051_ops_banner_write`(以 TRACEABILITY Max+1 为准,编码时再锁)。
- **表结构**:默认复用 `ops_banners`;本切片 **不**为 FeedSlot 改表。若投影缺省不够且 Human 未扩 scope,不得私自加列。
## 7. UI
| 端 | 行为 |
|----|------|
| admin-h5 `/cms` | Banner 列表 + 新建/编辑/上下架(只动 Banner;FeedSlot 仍只读展示可保留) |
| user-h5 首页 | `GET /home/banners` → 推荐区;失败或空 → 静态 `homeFeeds` |
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST 合法 Banner → list/get 可见 |
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
| AC-F-03 | PUT `active=true` + placement=home → `GET /home/banners` 含该项 |
| AC-F-04 | 重复 `code` → 4xx |
| AC-F-05 | 非法 `link_path`(外链)→ 4xx |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 仅 read 无 write → POST/PUT 403 |
| AC-A-01 | 写操作产生审计行 |
| AC-C-01 | H5 首页:有 active home Banner 时推荐区非纯静态 |
| AC-C-02 | API 失败时首页不白屏(回退静态) |
| AC-O-01 | 不触碰 FeedSlot 写路径 · 无 soft-delete · 无支付 |
## 9. Non-goalsSTOP 对齐)
FeedSlot 写 · ScheduledPublication 写 · UGC · 真支付 · soft-delete · Crisis/Handoff/ContentSafety 写 · 扩大为本 ECR 合并多概念。
## 10. Trace
- ECR: `docs/ECR/ECR-041-banner-write.md`
- BD: `docs/BACKEND_DESIGN/BD-2026-041-banner-write.md`
- PRODUCT_SPEC · CONTRACT_DIFF · TEST_REPORT · CODE_REVIEW(实现阶段)
- Auth: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
+42
View File
@@ -0,0 +1,42 @@
# Feature Spec: OpsCMS · BannerOps · ECR-024 只读基线)
> Status: `Active`**ECR-024 Closed** · 只读)
> Parent: WAVE0-FROZEN · Predecessor: ECR-023 Closed
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> 授权(只读队列):`docs/WAVE0/LOOP_AUTHORIZATION.md`
> **写面:** [ops-banner-write.md](ops-banner-write.md)**ECR-041** · Write-Wave
## Non-goals(本只读 Spec
Banner 写发布(见 ECR-041)· FeedSlot · ScheduledPublication · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `Banner` | code 唯一;placement ∈ {home,explore,ask}active/system;本切片只读 |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/cms/banners` | `admin.cms.read` | 只读 |
| GET | `/admin/cms/banners/{id}` | `admin.cms.read` | 只读 |
## Migration
`000025`:表 + 种子(若有) + 授予 admin.cms.read
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-024.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: ContentSafety · BlockPolicyOps · ECR-029
> Status: `Active`Loop continuous · **ECR-029 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-028 Closed
> Capability: `ContentSafety` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
策略写发布 · 用户侧硬拦截上线 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `BlockPolicy` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/content-safety/block-policies` | `admin.content_safety.read` | 只读 |
| GET | `/admin/content-safety/block-policies/{id}` | `admin.content_safety.read` | 只读 |
## Migration
`000030`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-029.yaml`
@@ -0,0 +1,42 @@
# Feature Spec: ContentSafety · FilterRuleOps · ECR-019
> Status: `Active`Loop continuous · **ECR-019 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-018 Closed
> Capability: `ContentSafety` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
ModerationCase 写回 · CrisisPolicy 配置 · 真 NLP/厂商审核 · UGC · 真支付 · 用户侧硬拦截上线(本切片仅运营只读 + 试匹配)
## L2 Domain
| 概念 | 语义 |
|------|------|
| `FilterRule` | code 唯一;category ∈ {crisis,abuse,spam,pii}action ∈ {flag,block,escalate}system 种子不可本切片删除 |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/content-safety/filter-rules` | `admin.content_safety.read` | 列表 |
| GET | `/admin/content-safety/filter-rules/:id` | 同上 | 详情 |
| POST | `/admin/content-safety/evaluate` | 同上 | 试匹配(不写工单) |
## Migration
`000020``filter_rules` + 种子规则 + 授予 `admin.content_safety.read`
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含 system 种子 ≥1 |
| AC-F-02 | evaluate 命中种子 pattern → matches 非空 |
| AC-F-03 | get 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 content_safety.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | evaluate 不写 Audit(只读试匹配) |
contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml`
+2 -2
View File
@@ -1,7 +1,7 @@
# Feature Spec: 运营内容配置(Ops-C # Feature Spec: 运营内容配置(Ops-C
> Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-C` > Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-C`
> ESS`docs/ECR/ECR-008-ops-content.md`Approved · L2)· BD-2026-008 Approved > ESS`docs/ECR/ECR-008-ops-content.md`**Closed** · L2)· BD-2026-008 Approved
> 关联:[ops-admin.md](ops-admin.md) · [ops-analytics.md](ops-analytics.md) · [home.md](home.md) · [explore-test.md](explore-test.md) > 关联:[ops-admin.md](ops-admin.md) · [ops-analytics.md](ops-analytics.md) · [home.md](home.md) · [explore-test.md](explore-test.md)
--- ---
@@ -27,7 +27,7 @@
| In | Out | | In | Out |
|---|---| |---|---|
| 首页宫格 CRUD(最多约 24 项;默认 12 | 首页 Feeds 信息流 CMS(本 ECR 后置,仍用静态 `homeFeeds` | | 首页宫格 CRUD(最多约 24 项;默认 12 | 首页 Feeds/推荐区运营:ECR-008 后置;**Banner 写面见 ECR-041**;完整 FeedSlot 写另 ECR |
| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 | | 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 |
| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS | | scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS |
| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E | | Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E |
@@ -0,0 +1,42 @@
# Feature Spec: CrisisCare · CrisisPolicyOps · ECR-022
> Status: `Active`Loop continuous · **ECR-022 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-021 Closed
> Capability: `CrisisCare` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
CrisisEvent 入库工单 · InterventionOutcome · 策略在线编辑 · 医疗诊断结论 · UGC · 真支付 · 用户侧硬熔断上线
## L2 Domain
| 概念 | 语义 |
|------|------|
| `CrisisPolicy` | code 唯一;severity ∈ {high,critical}action ∈ {escalate,block,show_helpline}helpline_text 可选;本切片只读 |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/crisis/policies` | `admin.crisis.read` | 列表 |
| GET | `/admin/crisis/policies/:id` | 同上 | 详情 |
| POST | `/admin/crisis/evaluate` | 同上 | 试匹配(不写 CrisisEvent |
## Migration
`000023``crisis_policies` + 种子 + 授予权限
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含 system 种子 ≥1 |
| AC-F-02 | evaluate 命中 pattern → matches 非空 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 crisis.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | evaluate 不写 Audit/Event |
contract_diff: `docs/CONTRACT_DIFF/ECR-022.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: CrisisCare · CrisisEventOps · ECR-031
> Status: `Active`Loop continuous · **ECR-031 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-030 Closed
> Capability: `CrisisCare` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
事件写入工单流 · 医疗诊断 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `CrisisEvent` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/crisis/events` | `admin.crisis.read` | 只读 |
| GET | `/admin/crisis/events/{id}` | `admin.crisis.read` | 只读 |
## Migration
`000032`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-031.yaml`
@@ -0,0 +1,36 @@
# Feature Spec: EntitlementOps · ECR-018
> Status: `Active`Loop continuous · **ECR-018 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-017 Closed
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
真支付 · 改订单/退款 · 新建权益矩阵表 · UGC · 改报告正文 · ask_pack SKU
## L2 读模型(无 migration
| 概念 | 来源 |
|------|------|
| `Entitlement` | Membership + DeepAccess 列表 + ask_paid_quota 聚合 |
| flags | `report_detail_via_membership` · `deep_access_count` |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/users/:id/entitlements` | `admin.users.read` | 用户权益只读视图 |
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | 授予会员后 flags.report_detail_via_membership=true |
| AC-F-02 | 有 deep_access 时 deep_accesses 非空或 count≥1 |
| AC-F-03 | 无会员无深度 → membership.active=false 且 count=0 仍 200 |
| AC-S-01 | 无 Admin → 401 |
| AC-P-01 | GET &lt; 500ms 本机 |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-018.yaml`
@@ -0,0 +1,40 @@
# Feature Spec: OpsCMS · FeedSlot 写面(Ops · ECR-042
> Status: `Active`**ECR-042 Closed**
> Map: `§7` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
> Predecessor: ECR-025 Closed · ECR-041 Closed
## Goal
运营可创建/更新/上下架 FeedSlot;C 端可读 active 槽位;首页主信息流区按 `home.main`(或 placement=home 的 active 槽)显隐;失败回退「仍展示推荐区」。
## In / Out
| In | Out |
|----|-----|
| Admin POST/PUT feed-slots · `admin.cms.write` | Banner 再扩 · ScheduledPublication 写 |
| C 端 GET `/home/feed-slots` | UGC 内容流正文 CMS · soft-delete |
| 首页:无 active home 槽 → 隐藏推荐区;API 失败 → 仍显示 | 真支付 · Crisis/Handoff · 新权限模型 |
## Domain
复用 `ops_feed_slots``code` 唯一 · `slot_key` · `placement` ∈ {home,explore,ask} · `active` · `system`system 不可改 code)。
## API
| Method | Path | Perm |
|--------|------|------|
| GET | `/admin/cms/feed-slots*` | cms.read |
| POST/PUT | `/admin/cms/feed-slots*` | cms.write |
| GET | `/home/feed-slots?placement=home` | DeviceAuth |
无 DELETE;下架 `active=false`
## AC
AC-F create/update/conflict/invalid · AC-S 401/403 · AC-A audit · AC-C 首页显隐与失败回退 · AC-O 无 Banner 合并 / 无 soft-delete
## Migration
无新表;权限已在 `000051`。本切片 **不新增 migration 文件**Max 保持 `000051`)。
+41
View File
@@ -0,0 +1,41 @@
# Feature Spec: OpsCMS · FeedSlotOps · ECR-025
> Status: `Active`Loop continuous · **ECR-025 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-024 Closed
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
FeedSlot 写发布 · ScheduledPublication · UGC · 真支付 · Banner 写
## L2 Domain
| 概念 | 语义 |
|------|------|
| `FeedSlot` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/cms/feed-slots` | `admin.cms.read` | 只读 |
| GET | `/admin/cms/feed-slots/{id}` | `admin.cms.read` | 只读 |
## Migration
`000026`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-025.yaml`
@@ -0,0 +1,72 @@
# Feature Spec: GrowthInsights · FunnelDefinition 写面(Ops · ECR-047
> Status: `Active`**Implemented · ECR-047 Closed**
> Map: `§7` · Capability: `GrowthInsights` · BC: `Analytics_OpsB`
> Auth: `docs/WAVE0/ECR-047_WRITE_AUTHORIZATION.md`**仅 ECR-047**
> Predecessor: ECR-039 Closed(只读)· ECR-046 Closed
> 关联:[ops-funnel-definition.md](ops-funnel-definition.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `FunnelDefinition`(漏斗目录);写鉴权 + 审计;**无**新 C 端路由。
**Closed 后立即 STOP**,不自动 ECR-048。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/analytics/funnel-definitions*` | ReportTemplate 写 |
| 加法 `admin.growth.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `funnel_definitions` | soft-delete · 真支付 · UGC |
| 审计 | 题干 · Crisis/Handoff |
| | 自动 ECR-048 · 新 C 端路由 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` **不可改 code** |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/analytics/funnel-definitions` · `/{id}` | `admin.growth.read` |
| POST | `/admin/analytics/funnel-definitions` | `admin.growth.write` |
| PUT | `/admin/analytics/funnel-definitions/{id}` | `admin.growth.write` |
无 DELETE · 无新 C 端路径。
## 5. Permission / Migration
- Migration `000056_ops_funnel_definition_write`:授予 `super_admin``admin.growth.write`
- 编码时占号(Max=`000055``000056`
## 6. UI
admin-h5 漏斗定义页:列表 + 新建/编辑/上下架。
## 7. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → list 仍可见且 active=false |
| AC-F-03 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 growth.read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | 无 soft-delete · 无 ReportTemplate/Prompt 写路径 |
## 8. Closed 后
**立即 STOP**。ECR-048 须新 Candidate Review + Human 拍板。
## 9. Trace
ECR-047 · BD-2026-047 · CONTRACT_DIFF · ECR-047_WRITE_AUTHORIZATION
@@ -0,0 +1,42 @@
# Feature Spec: GrowthInsights · FunnelDefinitionOps · ECR-039
> Status: `Active`Loop continuous · **ECR-039 Closed**
> **写面:** [ops-funnel-definition-write.md](ops-funnel-definition-write.md)**ECR-047 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-038 Closed
> Capability: `GrowthInsights` · BC: `Analytics_OpsB`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
漏斗写配置 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `FunnelDefinition` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/analytics/funnel-definitions` | `admin.growth.read` | 只读 |
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.growth.read` | 只读 |
## Migration
`000040`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-039.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: AskOperations · HandoffCaseOps · ECR-033
> Status: `Active`Loop continuous · **ECR-033 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-032 Closed
> Capability: `AskOperations` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
转人工写流 · 顾问执业 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `HandoffCase` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ask/handoffs` | `admin.ask.read` | 只读 |
| GET | `/admin/ask/handoffs/{id}` | `admin.ask.read` | 只读 |
## Migration
`000034`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-033.yaml`
@@ -0,0 +1,95 @@
# Feature Spec: ExploreConfig · ImageCardDeck 写面(Ops · ECR-045
> Status: `Active`**Implemented · ECR-045 Closed**
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md`Human 批准 · **仅 ECR-045**
> Predecessor: ECR-037 Closed(只读)· ECR-044 ClosedRhythm 写)
> 关联:[ops-image-card-deck.md](ops-image-card-deck.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `ImageCardDeck`(牌组目录);写操作鉴权 + 审计;C 端可读 active deck 并影响 `/cards` 可用性;失败回退「现网可抽」。
**Closed 后立即 STOP**,不自动 ECR-046。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/explore/image-card-decks*` | **牌面/单卡内容 CRUD** · Star / Rhythm / Scale |
| 复用 `admin.explore.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `image_card_decks` | soft-delete · 真支付 · UGC |
| C 端 GET active decks | Crisis / Handoff 写 |
| `/cards`:无 active → 空态禁抽卡 CTA;失败 → 回退可抽 | 自动 ECR-046 |
| 审计 | 新权限子系统 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` 不可改 code |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
### Admin
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/explore/image-card-decks` · `/{id}` | `admin.explore.read` |
| POST | `/admin/explore/image-card-decks` | `admin.explore.write` |
| PUT | `/admin/explore/image-card-decks/{id}` | `admin.explore.write` |
无 DELETE。
### C 端
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| GET | `/api/v1/cards/decks` | DeviceAuth | 仅 `active=true`;按 code 排序 |
## 5. C 端生效
| 条件 | user-h5 `/cards` |
|------|------------------|
| ≥1 active | 保持现网抽卡 |
| 成功且 0 active | 「意象牌组暂未开放」空态;禁用抽卡 CTA |
| 请求失败 | **回退**不阻断抽卡 |
## 6. Permission / Migration
- Migration `000054_ops_image_card_deck_write`:幂等确认 `super_admin``admin.explore.write`
- 编码时占号(Max=`000053`
## 7. UI
| 端 | 行为 |
|----|------|
| admin-h5 意象牌组页 | 列表 + 新建/编辑/上下架(仅 Deck) |
| user-h5 `/cards` | 按 §5 |
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → C 端不含 |
| AC-F-03 | 有 active → C 端非空 |
| AC-F-04 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-C-01 | 全下架后空态/禁 CTA |
| AC-C-02 | API 失败回退可抽 |
| AC-O-01 | 无牌面写路径 · 无 soft-delete |
## 9. Closed 后
**立即 STOP**。ECR-046 须新 Candidate Review + Human 拍板。
## 10. Trace
ECR-045 · BD-2026-045 · CONTRACT_DIFF · ECR-045_WRITE_AUTHORIZATION
@@ -0,0 +1,42 @@
# Feature Spec: ExploreConfig · ImageCardDeckOps · ECR-037
> Status: `Active`Loop continuous · **ECR-037 Closed**
> **写面:** [ops-image-card-deck-write.md](ops-image-card-deck-write.md)**ECR-045 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-036 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
牌组写发布 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ImageCardDeck` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/explore/image-card-decks` | `admin.explore.read` | 只读 |
| GET | `/admin/explore/image-card-decks/{id}` | `admin.explore.read` | 只读 |
## Migration
`000038`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-037.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: CrisisCare · InterventionOutcomeOps · ECR-032
> Status: `Active`Loop continuous · **ECR-032 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-031 Closed
> Capability: `CrisisCare` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
干预写回 · 医疗诊断 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `InterventionOutcome` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/crisis/interventions` | `admin.crisis.read` | 只读 |
| GET | `/admin/crisis/interventions/{id}` | `admin.crisis.read` | 只读 |
## Migration
`000033`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-032.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: AICoreConfig · KnowledgeChunkOps · ECR-027
> Status: `Active`Loop continuous · **ECR-027 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-026 Closed
> Capability: `AICoreConfig` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
Embedding · 上传切块 · 运行时 RAG 接线 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `KnowledgeChunk` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ai/knowledge-chunks` | `admin.ai_config.read` | 只读 |
| GET | `/admin/ai/knowledge-chunks/{id}` | `admin.ai_config.read` | 只读 |
## Migration
`000028`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-027.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: AICoreConfig · KnowledgeSourceOps · ECR-023
> Status: `Active`Loop continuous · **ECR-023 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-022 Closed
> Capability: `AICoreConfig` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
KnowledgeChunk / Embedding · 源文件上传 · 在线编辑发布 · ToolDefinition · 运行时 RAG 接线 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `KnowledgeSource` | code 唯一;source_kind ∈ {faq,policy,guide}active/system;本切片只读目录,不含 Chunk |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ai/knowledge-sources` | `admin.ai_config.read` | 列表 |
| GET | `/admin/ai/knowledge-sources/:id` | 同上 | 详情 |
## Migration
`000024``knowledge_sources` + 种子 `ask_grounding`(权限已由 000022 授予)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含 ask_grounding |
| AC-F-02 | get 返回 source_kind 合法 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 ai_config.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-023.yaml`
@@ -0,0 +1,77 @@
# Feature Spec: 会员套餐 MembershipPlanOps · ECR-014
> Status: `Active`Loop continuous · Approved · coding)· Map: `§7` · Phase: `Ops-D`
> Parent: **WAVE0-FROZEN** · Predecessor: **ECR-013B Closed**
> ESS: `docs/ECR/ECR-014-membership-plan.md`
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
---
## ESS 门禁
1. L2 · Loop continuous:契约齐 → 自动 Approve + coding
2. **不含** 真支付 · 兑换码 · Entitlement 矩阵 · ask_pack 价目表
---
## L0 Capability
| 字段 | 内容 |
|------|------|
| Capability ID | `CommerceEntitlement` |
| Purpose | 运营可配置成长会员套餐时长与标价(mock 履约仍用既有支付) |
| Why now | planDays/金额硬编码;013B 完成后进入 Commerce 配置面最小切片 |
| Non-goals | 真支付网关 · RedemptionCode · Entitlement 细权 · ask_pack |
---
## L1 Bounded Context
| Primary BC | `Membership_Orders` |
| owns | `MembershipPlan` |
| does_not_own | `UserStatus` · `Payment` 适配器 · `AdminRole` |
| allowed | `Admin_Auth_Audit.write_audit` |
| forbidden | 真支付 · UGC |
---
## L2 Domain
| Entity | 不变式 |
|--------|--------|
| `MembershipPlan` | `code` ∈ {month,quarter,year} 本切片冻结;`duration_days`>0`amount_cents`≥0`active` 布尔 |
---
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/membership-plans` | `admin.membership.plans.read` | 列表 |
| GET | `/admin/membership-plans/:code` | `admin.membership.plans.read` | 详情 |
| PUT | `/admin/membership-plans/:code` | `admin.membership.plans.write` | 更新 title/days/amount/active + AuditLog |
履约:`GrantMembership` / membership `CreateOrder` 读表(缺行回退旧硬编码)。
`contract_diff``docs/CONTRACT_DIFF/ECR-014.yaml`
---
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | GET plans 含 month/quarter/year |
| AC-F-02 | PUT month days/amount → GET 一致 |
| AC-F-03 | GrantMembership 使用表内 duration_days |
| AC-S-01 | 无 write 权限 PUT → 403 |
| AC-S-02 | 无 Admin → 401 |
| AC-P-01 | GET list P95 &lt; 500ms 本机 |
| AC-O-01 | PUT 成功 → AuditLog `membership.plans.update` |
---
## Implementation Notes
Migration `membership_plans` + RBAC additive permissions · admin-h5 最小列表编辑页
@@ -0,0 +1,41 @@
# Feature Spec: ContentSafety · ModerationCaseOps · ECR-030
> Status: `Active`Loop continuous · **ECR-030 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-029 Closed
> Capability: `ContentSafety` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
审核写回 · 真 NLP 厂商 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ModerationCase` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/content-safety/cases` | `admin.content_safety.read` | 只读 |
| GET | `/admin/content-safety/cases/{id}` | `admin.content_safety.read` | 只读 |
## Migration
`000031`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-030.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: AdminGovernance · PrivacyRequestOps · ECR-034
> Status: `Active`Loop continuous · **ECR-034 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-033 Closed
> Capability: `AdminGovernance` · BC: `Admin_Auth_Audit`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
隐私请求履约写 · soft-delete · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `PrivacyRequest` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/privacy/requests` | `admin.privacy.read` | 只读 |
| GET | `/admin/privacy/requests/{id}` | `admin.privacy.read` | 只读 |
## Migration
`000035`:表 + 种子(若有) + 授予 admin.privacy.read
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-034.yaml`
@@ -0,0 +1,42 @@
# Feature Spec: QualityFeedbackOps · ECR-020
> Status: `Active`Loop continuous · **ECR-020 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-019 Closed
> Capability: `AskOperations` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
改写消息正文 · SystemPrompt/Knowledge · HandoffCase 全量 · AICoreConfig · UGC 广场 · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `QualityFeedback` | 绑定 thread(可选 message);rating 15tag ∈ {helpful,off_topic,unsafe,other}source ∈ {admin,user} |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ask/feedback` | `admin.ask.read` | 列表 |
| POST | `/admin/ask/threads/:id/feedback` | `admin.ask.feedback.write` | 运营提交 |
| POST | `/ask/threads/:id/feedback` | DeviceAuth 用户 | C 端评分 |
## Migration
`000021``ask_quality_feedback` + 授予 `admin.ask.feedback.write`
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | admin POST feedback → 200 · list 可见 |
| AC-F-02 | user POST feedback → 200 |
| AC-F-03 | 无效 rating → 400 |
| AC-S-01 | 无 Admin → admin list 401 |
| AC-S-02 | 无 feedback.write → admin POST 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | admin 提交写 AuditLog `ask.feedback.create` |
contract_diff: `docs/CONTRACT_DIFF/ECR-020.yaml`
+151
View File
@@ -0,0 +1,151 @@
# Feature Spec: 运营后台 RBACOps · ECR-013A
> Status: `Active`**Closed** · Feature Complete)· Map: `§7 运营后台` · Phase: `Ops-D`
> Parent: **WAVE0-FROZEN** (`27f27a1`)
> ESS: `docs/ECR/ECR-013A-admin-rbac.md`**Closed**
> Capability: `AdminGovernance` · BC: `Admin_RBAC`
> 模板:`docs/WAVE0/contracts/OPS-CONTRACT-TEMPLATE.md`
---
## ESS 门禁
1. Change Level = **L2** → 已 Approved + Coding auth + Implemented + **Closed**
2. ~~Contract Definition only~~ — 已完成
3. 实现证据:`docs/TEST_REPORT/ECR-013A.md` · `docs/CODE_REVIEW/ECR-013A.md`
---
## L0 Capability
| 字段 | 内容 |
|------|------|
| Capability ID | `AdminGovernance` |
| Purpose | 以最小权限原则约束管理员可执行的运营动作 |
| Why now | Ops-A 全员等价权限;进入 M1+ 前必须有权限面,否则越权不可治理 |
| Non-goals | 用户账户启停(ECR-013B);兑换码;CMSAsk Prompt;真支付;UGC |
---
## L1 Bounded Context
| 字段 | 内容 |
|------|------|
| Primary BC | `Admin_RBAC` |
| owns | `AdminRole` · `AdminPermission` |
| does_not_own | `User` · `Profile` · `UserStatus` · `Membership` · `Payment` |
| allowed | `Admin_Auth_Audit.write_audit` |
| forbidden | `Payment` · `Membership.write` · `DeviceAuth.user_token_issue` |
权威:`.ai/domain/boundary-rules.md`
---
## 1. 功能定义
| 字段 | 内容 |
|------|------|
| Name | Admin RBAC |
| Purpose | 角色与权限点绑定;API 级强制鉴权;变更可审计 |
| Business Goal | 支撑多运营角色,而不扩大 C 端攻击面 |
| In | Out |
|---|---|
| Role CRUD(最小:list/get/update permissions | UserStatus / 封禁(013B |
| `GET /admin/me` 返回 permissions | 细粒度数据行级 ACL |
| 既有 admin 写操作挂 permission 检查 | 新业务模块权限爆炸式新增(另 ECR) |
---
## L2 Domain
| Entity | 不变式 |
|--------|--------|
| `AdminRole` | `name` 唯一;系统角色 `super_admin` 不可删除 |
| `AdminPermission` | 稳定字符串码(见下表);只增不改语义 |
| `AdminAccount.role_id` | 每个账号恰好一个角色(本切片);无角色视为拒绝写操作 |
### Permission catalog(本切片冻结)
| Code | 覆盖既有能力 |
|------|----------------|
| `admin.users.read` | GET users / users/:id / stats |
| `admin.users.membership.grant` | POST membership/grant |
| `admin.users.ask_quota.grant` | POST ask-quota/grant |
| `admin.orders.read` | GET orders |
| `admin.audit.read` | GET audit-logs |
| `admin.analytics.read` | GET analytics/*Ops-B |
| `admin.content.write` | home tools / scales publishOps-C |
| `admin.roles.read` | GET roles |
| `admin.roles.write` | 变更角色权限 |
`super_admin` 种子拥有上表全部。
---
## L3 API Contract(意图 · 实现轮同步 OpenAPI)
前缀:`/api/v1/admin` · AdminAuth · 信封 `{code,message,data}`
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/roles` | `admin.roles.read` | 角色列表 |
| GET | `/roles/:id` | `admin.roles.read` | 角色 + permissions[] |
| PUT | `/roles/:id/permissions` | `admin.roles.write` | 全量替换权限集;写 AuditLog |
| GET | `/me` | (已登录) | **扩展**返回 `role` + `permissions[]` |
既有写接口:在实现轮挂上表 permission;缺权 → **403** + AuditLog(尝试记录)。
`contract_diff``docs/CONTRACT_DIFF/ECR-013A.yaml`
---
## L4 Acceptance Criteria
### Functional
| ID | Given | When | Then |
|----|-------|------|------|
| AC-F-01 | super_admin 已登录 | GET `/roles` | 200,含 `super_admin` |
| AC-F-02 | 角色存在 | PUT permissions 合法集合 | 200;再次 GET 一致 |
| AC-F-03 | GET `/me` | — | data 含 `permissions` 数组 |
### Security
| ID | Given | When | Then |
|----|-------|------|------|
| AC-S-01 | Admin 无 `admin.roles.write` | PUT `/roles/:id/permissions` | **403**;AuditLog 有拒绝或尝试记录 |
| AC-S-02 | Admin 无 `admin.users.membership.grant` | POST membership/grant | **403** |
| AC-S-03 | 仅 DeviceAuth | 访问 `/admin/roles` | **401** |
| AC-S-04 | 删除 `super_admin` 角色 | 任意 API | **拒绝**4xx |
### Performance
| ID | Given | When | Then |
|----|-------|------|------|
| AC-P-01 | 角色数 N≤100 | GET `/roles` | 本机 integration 环境下 P95 **&lt; 500ms** |
### Observability
| ID | Given | When | Then |
|----|-------|------|------|
| AC-O-01 | PUT permissions 成功 | — | AuditLog action 含 `roles.permissions.update` |
| AC-O-02 | AC-S-01 触发 | — | AuditLog 可追溯 admin_id + path |
---
## Forbidden(本切片)
- UserStatus / AccountLifecycle**ECR-013B**
- 真支付 · UGC · Prompt/RAG · 兑换码 · CMS Banner 新表
- 改 DeviceAuth / C 端五 Tab
---
## Implementation Notes(实现轮才执行)
| 项 | 内容 |
|----|------|
| Migration | YESroles / permissions 关联;accounts.role_id;种子 super_admin |
| Packages | `service/admin` · repo · middleware permission check · admin-h5 只读展示权限(最小) |
| Parent | WAVE0-FROZEN |
@@ -0,0 +1,43 @@
# Feature Spec: 兑换码 RedemptionCodeOps · ECR-015
> Status: `Active`Loop continuous · Approved · coding
> Parent: WAVE0-FROZEN · Predecessor: ECR-014 Closed
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
真支付 · Entitlement 细权 · ask_pack 兑换 · UGC
## L2
| Entity | 不变式 |
|--------|--------|
| `RedemptionBatch` | label · plan_code∈membership_plans · quantity 1..100 |
| `RedemptionCode` | code 唯一;status unused→redeemed\|disabledredeemed 不可再兑 |
## L3 API
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| POST | `/admin/redemption-batches` | `admin.membership.codes.write` | 批量生成 |
| GET | `/admin/redemption-batches` | `admin.membership.codes.read` | 批次列表 |
| GET | `/admin/redemption-batches/:id/codes` | `admin.membership.codes.read` | 码列表 |
| POST | `/admin/redemption-codes/:id/disable` | `admin.membership.codes.write` | 作废 unused |
| POST | `/membership/redeem` | DeviceAuth+已注册 | 兑码→延长会员 |
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | POST batch quantity=3 → 3 unused codes |
| AC-F-02 | C端 redeem → membership active;码=redeemed |
| AC-F-03 | 再兑同一码 → 400 |
| AC-F-04 | disable unused → status=disabled;兑 → 400 |
| AC-S-01 | 无 write → POST batch 403 |
| AC-S-02 | 未登录兑码 → 401 |
| AC-P-01 | GET batches &lt; 500ms |
| AC-O-01 | 生成 AuditLog `redemption.batch.create` |
| AC-O-02 | 兑换可追溯 redeemed_by |
contract_diff: `docs/CONTRACT_DIFF/ECR-015.yaml`
@@ -0,0 +1,73 @@
# Feature Spec: GrowthInsights · ReportTemplate 写面(Ops · ECR-048
> Status: `Active`**Implemented · ECR-048 Closed**
> Map: `§7` · Capability: `GrowthInsights` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/ECR-048_WRITE_AUTHORIZATION.md`**仅 ECR-048**
> Predecessor: ECR-038 Closed(只读)· ECR-047 ClosedFunnel 写)
> 关联:[ops-report-template.md](ops-report-template.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `ReportTemplate`(报告模板目录);写鉴权 + 审计;**无**新 C 端路由。
**Closed 后立即 STOP**,不自动 ECR-049。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/growth/report-templates*` | Funnel 再写 |
| 复用 `admin.growth.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `report_templates` | soft-delete · 真支付 · UGC |
| 审计 | 题干 · Crisis/Handoff |
| | 自动 ECR-049 · 新 C 端路由 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` **不可改 code** |
| `title` | 必填 · ≤128 |
| `scene` | 必填;`^[a-z][a-z0-9_]{0,30}$` · ≤32 |
| `active` | `false` = 下架 |
| `system` | 种子行;禁止物理删除 |
## 4. API
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/growth/report-templates` · `/{id}` | `admin.growth.read` |
| POST | `/admin/growth/report-templates` | `admin.growth.write` |
| PUT | `/admin/growth/report-templates/{id}` | `admin.growth.write` |
无 DELETE · 无新 C 端路径。
## 5. Permission / Migration
- Migration `000057_ops_report_template_write`:幂等确认 `super_admin``admin.growth.write`
- 编码时占号(Max=`000056``000057`
## 6. UI
admin-h5 报告模板页:列表 + 新建/编辑/上下架。
## 7. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → list 仍可见且 active=false |
| AC-F-03 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 growth.read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | 无 soft-delete · 无 Funnel/Prompt 写路径 |
## 8. Closed 后
**立即 STOP**。ECR-049 须新 Candidate Review + Human 拍板。
## 9. Trace
ECR-048 · BD-2026-048 · CONTRACT_DIFF · ECR-048_WRITE_AUTHORIZATION
@@ -0,0 +1,42 @@
# Feature Spec: GrowthInsights · ReportTemplateOps · ECR-038
> Status: `Active`Loop continuous · **ECR-038 Closed**
> **写面:** [ops-report-template-write.md](ops-report-template-write.md)**ECR-048 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-037 Closed
> Capability: `GrowthInsights` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
模板写发布 · 广告投放 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ReportTemplate` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/growth/report-templates` | `admin.growth.read` | 只读 |
| GET | `/admin/growth/report-templates/{id}` | `admin.growth.read` | 只读 |
## Migration
`000039`:表 + 种子(若有) + 授予 admin.growth.read
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-038.yaml`
@@ -0,0 +1,98 @@
# Feature Spec: ExploreConfig · RhythmConfig 写面(Ops · ECR-044
> Status: `Active`**Implemented · ECR-044 Closed**
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/ECR-044_WRITE_AUTHORIZATION.md`Human 批准 · **仅 ECR-044**
> Predecessor: ECR-036 Closed(只读)· ECR-043 ClosedStarConfig 写)
> 关联:[ops-rhythm-config.md](ops-rhythm-config.md) · [life-rhythm.md](life-rhythm.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `RhythmConfig`;写操作鉴权 + 审计;C 端可读 active 配置并影响节律入口可用性;失败回退「现网可生成」。
验证 ExploreConfig 写面同构复用;**Closed 后立即 STOP**,不自动 ECR-045。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/explore/rhythm-configs*` | **StarConfig / ImageCardDeck / ScaleDefinition** 一切 |
| 复用权限 `admin.explore.write` | SystemPrompt · KnowledgeSource · Chunk · ToolDefinition |
| 复用表 `rhythm_configs`code/title/active/system | soft-delete · 改节律引擎 · 真支付 · UGC |
| C 端 GET active rhythm configs | Crisis / Handoff / ContentSafety 写 |
| 节律页:无 active → 温和不可用态;API 失败 → 回退可生成 | 合并多概念 · 自动开 ECR-045 |
| 审计 `admin_audit_logs` | 新权限子系统 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` 不可改 code |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
### Admin
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/explore/rhythm-configs` · `/{id}` | `admin.explore.read`(保持) |
| POST | `/admin/explore/rhythm-configs` | `admin.explore.write` |
| PUT | `/admin/explore/rhythm-configs/{id}` | `admin.explore.write` |
无 DELETE。
### C 端
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| GET | `/api/v1/rhythm/configs` | DeviceAuth | 仅 `active=true`;按 code 排序 |
## 5. C 端生效(可观察)
| 条件 | user-h5 `/rhythm` |
|------|-------------------|
| 至少 1 条 active | 保持现网生成路径 |
| 成功且 0 条 active | 展示「节律配置暂未开放」类空态;禁用主生成 CTA |
| 请求失败 | **回退**:不阻断现网生成 |
不改节律引擎、不改报告 JSON 契约。
## 6. Permission / Migration
- Migration `000053_ops_rhythm_config_write`:幂等确认 `super_admin``admin.explore.write`043 已授)
- 编码时占号(当前 Max=`000052`
## 7. UI
| 端 | 行为 |
|----|------|
| admin-h5 节律配置页 | 列表 + 新建/编辑/上下架(仅 RhythmConfig |
| user-h5 `/rhythm` | 按 §5 显隐 CTA |
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST 合法 → list/get 可见 |
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
| AC-F-03 | 有 active → C 端 list 非空;节律页可生成 |
| AC-F-04 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-C-01 | 全下架后节律页空态/禁 CTA |
| AC-C-02 | C 端 API 失败不白屏、仍可走回退生成 |
| AC-O-01 | 无 Star/Card/Scale/Prompt 写路径;无 soft-delete |
## 9. Closed 后
**立即 STOP** Continuous Loop。
ECR-045 必须重新 Candidate Review + Human 单独拍板。
## 10. Trace
ECR-044 · BD-2026-044 · PRODUCT_SPEC · CONTRACT_DIFF · ECR-044_WRITE_AUTHORIZATION
@@ -0,0 +1,42 @@
# Feature Spec: ExploreConfig · RhythmConfigOps · ECR-036
> Status: `Active`Loop continuous · **ECR-036 Closed**
> **写面:** [ops-rhythm-config-write.md](ops-rhythm-config-write.md)**ECR-044**
> Parent: WAVE0-FROZEN · Predecessor: ECR-035 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
配置写发布 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `RhythmConfig` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/explore/rhythm-configs` | `admin.explore.read` | 只读 |
| GET | `/admin/explore/rhythm-configs/{id}` | `admin.explore.read` | 只读 |
## Migration
`000037`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-036.yaml`
@@ -0,0 +1,68 @@
# Feature Spec: ExploreConfig · ScaleDefinition 写面(Ops · ECR-046
> Status: `Active`**Implemented · ECR-046 Closed**
> Auth: `docs/WAVE0/ECR-046_WRITE_AUTHORIZATION.md`**仅 ECR-046**
> Predecessor: ECR-040 只读 · ECR-008 status PATCH · ECR-045 Closed
> 关联:[ops-scale-definition.md](ops-scale-definition.md) · [ops-content.md](ops-content.md)
## 1. 目标
运营可在 Explore 侧 **创建/更新量表元数据**slug/title/description);**上下架仍走 ECR-008**。Closed 后 STOP。
## 2. In / Out
| In | Out |
|----|-----|
| POST/PUT `/admin/explore/scales*` | **PATCH status**ECR-008 独占) |
| 复用 `admin.explore.write` | 题干 / scale_questions |
| 复用表 `scales` | soft-delete · Prompt/Knowledge/Chunk |
| 创建默认 `status=draft` | 支付 · UGC · 自动 ECR-047 |
| 审计 | 新权限子系统 |
## 3. 双通道矩阵(强制)
| API | 权限 | 字段 |
|-----|------|------|
| `POST/PUT /admin/explore/scales*` | `admin.explore.write` | slug · title · description |
| `PATCH /admin/scales/{id}` | `admin.content.write` | **仅 status**(既有) |
## 4. Domain
| 字段 | 规则 |
|------|------|
| `slug` | 唯一;`^[a-z][a-z0-9-]{1,62}$` |
| `title` | 必填 · ≤128 |
| `description` | ≤512 · 可空串 |
| `status` | 创建固定 `draft`;本刀 PUT **不得**改 status |
## 5. C 端可观察
| 条件 | 行为 |
|------|------|
| 新建 draft | C 端 published list **不含** |
| ECR-008 PATCH → published | C 端 list **含**(既有 `/scales` |
| 不新增 C 端路由 | — |
## 6. Migration
`000054``000055_ops_scale_definition_write`:幂等确认 `admin.explore.write`
## 7. UI
admin-h5:量表元数据页(新建/编辑 slug·title·description);上下架仍在「内容」页。
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 · status=draft |
| AC-F-02 | PUT 改 title → get 反映;status 不变 |
| AC-F-03 | 重复 slug → 409 |
| AC-F-04 | draft 不在 C 端 published listPATCH published 后出现 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | explore PUT 带 status 被忽略或不接受;无题干写;无 soft-delete |
## 9. Closed 后
立即 STOP · 禁自动 ECR-047
@@ -0,0 +1,43 @@
# Feature Spec: ExploreConfig · ScaleDefinitionOps · ECR-040
> Status: `Active`Loop continuous · **ECR-040 Closed**
> **写面:** [ops-scale-definition-write.md](ops-scale-definition-write.md)**ECR-046 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-039 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
PATCH status(已有 content.write)· 题干编辑 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ScaleDefinition` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/explore/scales` | `admin.explore.read``admin.content.write` | 只读 |
| GET | `/admin/explore/scales/{id}` | `admin.explore.read``admin.content.write` | 只读 |
| GET | `/admin/scales` | `admin.explore.read``admin.content.write` | 只读(与 explore 同源) |
## Migration
Migration NO:复用 `scales` 表只读投影
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-040.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: OpsCMS · ScheduledPublicationOps · ECR-026
> Status: `Active`Loop continuous · **ECR-026 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-025 Closed
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
定时发布写操作 · UGC · 真支付 · Banner/FeedSlot 写
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ScheduledPublication` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/cms/publications` | `admin.cms.read` | 只读 |
| GET | `/admin/cms/publications/{id}` | `admin.cms.read` | 只读 |
## Migration
`000027`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-026.yaml`
@@ -0,0 +1,99 @@
# Feature Spec: ExploreConfig · StarConfig 写面(Ops · ECR-043
> Status: `Active`**Implemented · ECR-043 Closed**
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md`Human 批准 · **仅 ECR-043**
> Predecessor: ECR-035 Closed(只读)· CMS 041042 Closed
> 关联:[ops-star-config.md](ops-star-config.md) · [star-profile.md](star-profile.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `StarConfig`;写操作鉴权 + 审计;C 端可读 active 配置并影响星座入口可用性;失败回退「现网可生成」。
验证 ExploreConfig 写面与 CMS 同类闭环;**Closed 后立即 STOP**,不自动 ECR-044。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/explore/star-configs*` | **RhythmConfig / ImageCardDeck / ScaleDefinition** 一切 |
| 加法权限 `admin.explore.write` | SystemPrompt · KnowledgeSource · Chunk · ToolDefinition |
| 复用表 `star_configs`code/title/active/system | soft-delete · 改引擎/宫位制 · 真支付 · UGC |
| C 端 GET active star configs | Crisis / Handoff / ContentSafety 写 |
| 星座页:无 active → 温和不可用态;API 失败 → 回退可生成 | 合并多概念 · 自动开 ECR-044 |
| 审计 `admin_audit_logs` | 新权限子系统 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` 不可改 code |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
### Admin
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/explore/star-configs` · `/{id}` | `admin.explore.read`(保持) |
| POST | `/admin/explore/star-configs` | `admin.explore.write` |
| PUT | `/admin/explore/star-configs/{id}` | `admin.explore.write` |
无 DELETE。
### C 端
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| GET | `/api/v1/star/configs` | DeviceAuth | 仅 `active=true`;按 code 排序 |
## 5. C 端生效(可观察)
| 条件 | user-h5 `/star` |
|------|-----------------|
| 至少 1 条 active | 保持现网生成路径 |
| 成功且 0 条 active | 展示「星座配置暂未开放」类空态;禁用主生成 CTA |
| 请求失败 | **回退**:不阻断现网生成(与 Banner/FeedSlot 失败回退同哲学) |
不改星历引擎、不改报告 JSON 契约。
## 6. Permission / Migration
- Migration `000052_ops_star_config_write`:授予 `super_admin``admin.explore.write`
- 编码时占号(当前 Max=`000051`
- `knownPermissions` 注册加法码
## 7. UI
| 端 | 行为 |
|----|------|
| admin-h5 探索/星座配置页 | 列表 + 新建/编辑/上下架(仅 StarConfig |
| user-h5 `/star` | 按 §5 显隐 CTA |
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST 合法 → list/get 可见 |
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
| AC-F-03 | 有 active → C 端 list 非空;星座页可生成 |
| AC-F-04 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-C-01 | 全下架后星座页空态/禁 CTA |
| AC-C-02 | C 端 API 失败不白屏、仍可走回退生成 |
| AC-O-01 | 无 Rhythm/Card/Scale/Prompt 写路径;无 soft-delete |
## 9. Closed 后
**立即 STOP** Continuous Loop。
ECR-044 必须重新 Candidate Review + Human 单独拍板。
## 10. Trace
ECR-043 · BD-2026-043 · PRODUCT_SPEC · CONTRACT_DIFF(实现时)· EXPLORE_CONFIG_WRITE_AUTHORIZATION
@@ -0,0 +1,42 @@
# Feature Spec: ExploreConfig · StarConfigOps · ECR-035 只读基线)
> Status: `Active`**ECR-035 Closed** · 只读)
> **写面:** [ops-star-config-write.md](ops-star-config-write.md)**ECR-043 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-034 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权(只读队列):`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals(本只读 Spec
配置写发布(见 ECR-043)· 引擎改分层 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `StarConfig` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/explore/star-configs` | `admin.explore.read` | 只读 |
| GET | `/admin/explore/star-configs/{id}` | `admin.explore.read` | 只读 |
## Migration
`000036`:表 + 种子(若有) + 授予 admin.explore.read
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-035.yaml`
@@ -0,0 +1,41 @@
# Feature Spec: AICoreConfig · ToolDefinitionOps · ECR-028
> Status: `Active`Loop continuous · **ECR-028 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-027 Closed
> Capability: `AICoreConfig` · BC: `Ask_Ops`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
工具在线编辑 · 运行时绑定 · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ToolDefinition` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ai/tools` | `admin.ai_config.read` | 只读 |
| GET | `/admin/ai/tools/{id}` | `admin.ai_config.read` | 只读 |
## Migration
`000029`:表 + 种子(若有)(权限复用)
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-028.yaml`
@@ -0,0 +1,39 @@
# Feature Spec: 用户洞察 UserIntelligenceOps · ECR-016
> Status: `Active`Loop continuous · **ECR-016 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-015 Closed
> Capability: `UserIntelligence` · BC: `Identity_Profile`(读)+ `Analytics_OpsB`(读)
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
写标签 · 改报告正文 · Ask 全文运营编辑 · UGC · 真支付 · 新表(本切片纯读模型聚合)
## L2 读模型(无 migration
| 概念 | 来源 |
|------|------|
| `UserProfileView` | profiles 摘要(已有 detail |
| `InsightReport` | growth_reports 类型计数 + 最近列表 |
| `PsychologicalTagSet` | 由报告 type 派生的稳定标签(非 NLP) |
| `BehaviorSnapshot` | analytics_events 最近页面/事件(若有) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/users/:id/insight` | `admin.users.read` | 聚合洞察只读 |
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | 有报告用户 → insight.reports_by_type 非空或 reports 列表 |
| AC-F-02 | tags 含报告类型映射标签 |
| AC-F-03 | 无埋点时 behavior.events=[] 仍 200 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 users.read → 403 |
| AC-P-01 | GET insight &lt; 500ms 本机 |
| AC-O-01 | N/A 只读(无写审计) |
contract_diff: `docs/CONTRACT_DIFF/ECR-016.yaml`
+3 -1
View File
@@ -169,7 +169,7 @@
**Given** Self 有生日 **Given** Self 有生日
**When** 生成星座报告 **When** 生成星座报告
**Then** 展示轮盘、太阳/月亮/上升、相位速览、fortune 日运与行运;无「占卜」「算命」恐吓 **Then** 展示轮盘、太阳/月亮/上升、相位速览、outlook 日/周/月/年/一生与行运;无「占卜」「算命」恐吓
**Given** 两份档案 **Given** 两份档案
**When** POST synastry **When** POST synastry
@@ -214,3 +214,5 @@
| Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` | | Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` |
| 星历 | Swiss EphemerisCGO,默认 Moshier);热带 + Whole Sign | | 星历 | Swiss EphemerisCGO,默认 Moshier);热带 + Whole Sign |
| 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite | | 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite |
| Gaps | ECR-012 收口:报告页复用 StarFortunePanel;分享 type=starfortuneBundle 仅读 outlook 且剥离 luckye2e mock 含 outlook |
| ESS | `docs/ECR/ECR-012-star-alignment.md`**Approved** · L2)· BD-2026-012 Approved |
+53
View File
@@ -0,0 +1,53 @@
{
"ecr": "ECR-012",
"result": "BLOCK",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": false,
"tests": false
},
"violations": [
{
"rule": "TR-REVIEW-001",
"description": "Backend change requires CODE_REVIEW / code-review handoff artifact",
"fix_required": [
"Write reviewer decision artifact",
"Re-run gate"
],
"detail": "ECR-012",
"check": "traceability-check"
},
{
"rule": "TR-COMMIT-001",
"description": "Trace chain requires implementation commit evidence",
"fix_required": [
"Record commit sha in TRACEABILITY or TEST_REPORT",
"Re-run gate"
],
"detail": "ECR-012",
"check": "traceability-check"
},
{
"rule": "TE-MISSING-001",
"description": "Missing TEST_REPORT for implemented change",
"fix_required": [
"Add docs/TEST_REPORT/ECR-xxx.md",
"Re-run gate"
],
"detail": "ECR-012",
"check": "test-evidence-check"
}
],
"errors": [
"[traceability-check] BLOCK [TR-REVIEW-001]: Backend change requires CODE_REVIEW / code-review handoff artifact \u2014 ECR-012",
"[traceability-check] BLOCK [TR-COMMIT-001]: Trace chain requires implementation commit evidence \u2014 ECR-012",
"[test-evidence-check] BLOCK [TE-MISSING-001]: Missing TEST_REPORT for implemented change \u2014 ECR-012"
],
"warnings": [],
"timestamp": "2026-08-07T06:51:10Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-016",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T10:30:16Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-017",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T10:39:01Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-018",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T11:26:01Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-019",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T14:13:03Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-020",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T15:01:53Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-021",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T16:26:53Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-022",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T17:28:27Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-023",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T17:48:29Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-024",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:05:41Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-025",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:08:40Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-026",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:13:36Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-027",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:17Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-028",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:30Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-029",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:44Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-030",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:00Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-031",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:15Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-032",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:30Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-033",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:45Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-034",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:59Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-035",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:12Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-036",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:26Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-037",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:42Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-038",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:57Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-039",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:17:12Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-040",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:20:29Z"
}
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
.venv/ .venv/
*.log *.log
.env .env
deploy.env
.DS_Store .DS_Store
# ESS — machine-local root pointer(仓库内用 scripts 包装器) # ESS — machine-local root pointer(仓库内用 scripts 包装器)
+34 -3
View File
@@ -21,7 +21,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
await ok({ await ok({
token: 'adm_e2e_token', token: 'adm_e2e_token',
expires_at: new Date(Date.now() + 3600_000).toISOString(), expires_at: new Date(Date.now() + 3600_000).toISOString(),
admin: { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' }, admin: {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
username: 'admin',
role: 'super_admin',
permissions: [
'admin.users.read',
'admin.users.membership.grant',
'admin.users.ask_quota.grant',
'admin.orders.read',
'admin.audit.read',
'admin.analytics.read',
'admin.content.write',
'admin.roles.read',
'admin.roles.write',
],
},
}) })
return return
} }
@@ -55,7 +70,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
return return
} }
if (url.endsWith('/me') || url.includes('/me?')) { if (url.endsWith('/me') || url.includes('/me?')) {
await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' }) await ok({
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
username: 'admin',
role: 'super_admin',
permissions: [
'admin.users.read',
'admin.users.membership.grant',
'admin.users.ask_quota.grant',
'admin.orders.read',
'admin.audit.read',
'admin.analytics.read',
'admin.content.write',
'admin.roles.read',
'admin.roles.write',
],
})
return return
} }
if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') { if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') {
@@ -131,7 +161,8 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
await ok({}) await ok({})
}) })
await page.goto('/login') // Leading `/login` would resolve to host root and miss Vite base `/psy/admin/`.
await page.goto('login')
await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible() await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible()
await page.locator('input[type="password"]').fill('change-me') await page.locator('input[type="password"]').fill('change-me')
await page.getByRole('button', { name: '登录' }).click() await page.getByRole('button', { name: '登录' }).click()
+4 -2
View File
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
/** /**
* Ops admin L3 smoke: Vite preview + mocked /api/v1/admin (no Go required). * Ops admin L3 smoke: Vite preview + mocked /api/v1/admin (no Go required).
* Build uses base `/psy/admin/` — baseURL must include that prefix.
* Run: npm run test:e2e -w @yuxingu/admin-h5 * Run: npm run test:e2e -w @yuxingu/admin-h5
*/ */
export default defineConfig({ export default defineConfig({
@@ -10,7 +11,8 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
use: { use: {
baseURL: 'http://127.0.0.1:4174', // Trailing slash so relative goto('login') resolves under /psy/admin/
baseURL: 'http://127.0.0.1:4174/psy/admin/',
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
projects: [ projects: [
@@ -24,7 +26,7 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174', command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174',
url: 'http://127.0.0.1:4174', url: 'http://127.0.0.1:4174/psy/admin/',
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
}, },
+704 -4
View File
@@ -31,7 +31,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
headers, headers,
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}) })
const env = (await res.json()) as ApiEnvelope<T> const text = await res.text()
let env: ApiEnvelope<T>
try {
env = JSON.parse(text) as ApiEnvelope<T>
} catch {
throw new Error(
res.status === 404
? `接口不存在或后端未更新(${path}`
: `响应不是 JSONHTTP ${res.status}`,
)
}
if (!res.ok || env.code !== 0) { if (!res.ok || env.code !== 0) {
throw new Error(env.message || `HTTP ${res.status}`) throw new Error(env.message || `HTTP ${res.status}`)
} }
@@ -96,7 +106,12 @@ export type UserDetail = {
export type AdminRole = 'super' | 'ops' export type AdminRole = 'super' | 'ops'
export type AdminMe = { id: string; username: string; role: AdminRole } export type AdminMe = {
id: string
username: string
role?: string
permissions?: string[]
}
export type PushJob = { export type PushJob = {
id: string id: string
@@ -125,12 +140,61 @@ export const adminApi = {
}), }),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'), logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<AdminMe>('GET', '/me'), me: () => request<AdminMe>('GET', '/me'),
roles: () =>
request<{ items: Array<{ id: string; name: string; system: boolean }> }>('GET', '/roles'),
role: (id: string) =>
request<{ id: string; name: string; system: boolean; permissions: string[] }>('GET', `/roles/${id}`),
stats: () => request<DashboardStats>('GET', '/stats'), stats: () => request<DashboardStats>('GET', '/stats'),
users: (q = '') => users: (q = '') =>
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`), request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`), user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`), setUserStatus: (id: string, status: string, reason: string) =>
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`), request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
statusTransitions: (id: string) =>
request<{
items: Array<{
id: string
from_status: string
to_status: string
admin_id: string
reason: string
created_at: string
}>
}>('GET', `/users/${id}/status-transitions`),
userInsight: (id: string) =>
request<{
user_id: string
profiles_count: number
reports_by_type: Array<{ type: string; count: number }>
recent_reports: Array<{ id: string; type: string; created_at: string }>
tags: Array<{ code: string; label: string }>
behavior: {
events: Array<{ name: string; page_path?: string; received_at: string }>
ask_thread_count: number
}
}>('GET', `/users/${id}/insight`),
userEntitlements: (id: string) =>
request<{
user_id: string
membership: {
plan?: string
status: string
expires_at?: string
ask_quota_left?: number
active: boolean
}
ask_paid_quota_left: number
flags: {
report_detail_via_membership: boolean
deep_access_count: number
}
deep_accesses: Array<{
id: string
report_id: string
report_type?: string
created_at: string
}>
}>('GET', `/users/${id}/entitlements`),
grant: (id: string, plan: string) => grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }), request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
grantAskQuota: (id: string, delta: number) => grantAskQuota: (id: string, delta: number) =>
@@ -145,6 +209,642 @@ export const adminApi = {
request<PushJob>('POST', '/push-jobs', body), request<PushJob>('POST', '/push-jobs', body),
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) => patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
request<PushJob>('PATCH', `/push-jobs/${id}`, body), request<PushJob>('PATCH', `/push-jobs/${id}`, body),
membershipPlans: () =>
request<{
items: Array<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
updated_at: string
}>
}>('GET', '/membership-plans'),
updateMembershipPlan: (
code: string,
body: { title: string; duration_days: number; amount_cents: number; active: boolean },
) =>
request<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) =>
request<{
batch: { id: string; label: string; plan_code: string; quantity: number }
codes: Array<{ id: string; code: string; status: string }>
}>('POST', '/redemption-batches', body),
redemptionBatches: () =>
request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>(
'GET',
'/redemption-batches',
),
redemptionCodes: (batchId: string) =>
request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>(
'GET',
`/redemption-batches/${batchId}/codes`,
),
disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`),
askThreads: (userId = '') => {
const q = userId ? `?user_id=${encodeURIComponent(userId)}` : ''
return request<{
items: Array<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
}>
}>('GET', `/ask/threads${q}`)
},
askThread: (id: string) =>
request<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
}>('GET', `/ask/threads/${id}`),
askThreadMessages: (id: string) =>
request<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
messages: Array<{ id: string; role: string; content: string; created_at: string }>
}>('GET', `/ask/threads/${id}/messages`),
filterRules: () =>
request<{
items: Array<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/content-safety/filter-rules'),
filterRule: (id: string) =>
request<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/content-safety/filter-rules/${id}`),
evaluateContent: (text: string) =>
request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>(
'POST',
'/content-safety/evaluate',
{ text },
),
askFeedback: () =>
request<{
items: Array<{
id: string
thread_id: string
message_id?: string
source: string
rating: number
tag?: string
note?: string
created_at: string
}>
}>('GET', '/ask/feedback'),
createAskFeedback: (threadId: string, body: { rating: number; tag?: string; note?: string; message_id?: string }) =>
request<{
id: string
thread_id: string
rating: number
source: string
}>('POST', `/ask/threads/${threadId}/feedback`, body),
systemPrompts: () =>
request<{
items: Array<{
id: string
code: string
title: string
scene?: string
body: string
version: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/ai/system-prompts'),
systemPrompt: (id: string) =>
request<{
id: string
code: string
title: string
scene?: string
body: string
version: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/ai/system-prompts/${id}`),
knowledgeSources: () =>
request<{
items: Array<{
id: string
code: string
title: string
description?: string
source_kind: string
version: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/ai/knowledge-sources'),
knowledgeSource: (id: string) =>
request<{
id: string
code: string
title: string
description?: string
source_kind: string
version: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/ai/knowledge-sources/${id}`),
crisisPolicies: () =>
request<{
items: Array<{
id: string
code: string
title: string
severity: string
pattern: string
action: string
helpline_text?: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/crisis/policies'),
crisisPolicy: (id: string) =>
request<{
id: string
code: string
title: string
severity: string
pattern: string
action: string
helpline_text?: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/crisis/policies/${id}`),
evaluateCrisis: (text: string) =>
request<{
matches: Array<{
code: string
title: string
severity: string
action: string
helpline_text?: string
}>
}>('POST', '/crisis/evaluate', { text }),
banners: () =>
request<{
items: Array<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/cms/banners'),
banner: (id: string) =>
request<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/cms/banners/${id}`),
createBanner: (body: {
code: string
title: string
placement: string
image_url?: string | null
link_path?: string | null
sort_order: number
active: boolean
}) =>
request<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>('POST', '/cms/banners', body),
updateBanner: (
id: string,
body: {
code: string
title: string
placement: string
image_url?: string | null
link_path?: string | null
sort_order: number
active: boolean
},
) =>
request<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>('PUT', `/cms/banners/${id}`, body),
feedSlots: () =>
request<{
items: Array<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/cms/feed-slots'),
feedSlot: (id: string) =>
request<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/cms/feed-slots/${id}`),
createFeedSlot: (body: {
code: string
title: string
slot_key: string
placement: string
active: boolean
}) =>
request<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/cms/feed-slots', body),
updateFeedSlot: (
id: string,
body: {
code: string
title: string
slot_key: string
placement: string
active: boolean
},
) =>
request<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/cms/feed-slots/${id}`, body),
publications: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
publication: (id: string) =>
request<Record<string, unknown>>('GET', `/cms/publications/${id}`),
knowledgeChunks: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/knowledge-chunks'),
knowledgeChunk: (id: string) =>
request<Record<string, unknown>>('GET', `/ai/knowledge-chunks/${id}`),
tools: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/tools'),
tool: (id: string) =>
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
blockPolicies: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/block-policies'),
blockPolicy: (id: string) =>
request<Record<string, unknown>>('GET', `/content-safety/block-policies/${id}`),
cases: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/cases'),
case: (id: string) =>
request<Record<string, unknown>>('GET', `/content-safety/cases/${id}`),
events: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/events'),
event: (id: string) =>
request<Record<string, unknown>>('GET', `/crisis/events/${id}`),
interventions: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/interventions'),
intervention: (id: string) =>
request<Record<string, unknown>>('GET', `/crisis/interventions/${id}`),
handoffs: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ask/handoffs'),
handoff: (id: string) =>
request<Record<string, unknown>>('GET', `/ask/handoffs/${id}`),
requests: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/privacy/requests'),
privacyRequest: (id: string) =>
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
starConfigs: () =>
request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/explore/star-configs'),
starConfig: (id: string) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/explore/star-configs/${id}`),
createStarConfig: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/explore/star-configs', body),
updateStarConfig: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/explore/star-configs/${id}`, body),
rhythmConfigs: () =>
request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/explore/rhythm-configs'),
rhythmConfig: (id: string) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/explore/rhythm-configs/${id}`),
createRhythmConfig: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/explore/rhythm-configs', body),
updateRhythmConfig: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/explore/rhythm-configs/${id}`, body),
imageCardDecks: () =>
request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/explore/image-card-decks'),
imageCardDeck: (id: string) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/explore/image-card-decks/${id}`),
createImageCardDeck: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/explore/image-card-decks', body),
updateImageCardDeck: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/explore/image-card-decks/${id}`, body),
reportTemplates: () =>
request<{
items: Array<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/growth/report-templates'),
reportTemplate: (id: string) =>
request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/growth/report-templates/${id}`),
createReportTemplate: (body: {
code: string
title: string
scene: string
active: boolean
}) =>
request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/growth/report-templates', body),
updateReportTemplate: (
id: string,
body: { code: string; title: string; scene: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/growth/report-templates/${id}`, body),
funnelDefinitions: () =>
request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/analytics/funnel-definitions'),
funnelDefinition: (id: string) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/analytics/funnel-definitions/${id}`),
createFunnelDefinition: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/analytics/funnel-definitions', body),
updateFunnelDefinition: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/analytics/funnel-definitions/${id}`, body),
exploreScales: () =>
request<{
items: Array<{
id: string
slug: string
title: string
description: string
status: string
}>
}>('GET', '/explore/scales'),
exploreScale: (id: string) =>
request<{
id: string
slug: string
title: string
description: string
status: string
}>('GET', `/explore/scales/${id}`),
createExploreScale: (body: { slug: string; title: string; description: string }) =>
request<{
id: string
slug: string
title: string
description: string
status: string
}>('POST', '/explore/scales', body),
updateExploreScale: (
id: string,
body: { slug: string; title: string; description: string },
) =>
request<{
id: string
slug: string
title: string
description: string
status: string
}>('PUT', `/explore/scales/${id}`, body),
orders: (params?: { orders: (params?: {
status?: string status?: string
kind?: string kind?: string
@@ -0,0 +1,25 @@
<template>
<footer class="icp-footer">
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
</footer>
</template>
<script setup lang="ts">
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
</script>
<style scoped>
.icp-footer {
text-align: center;
padding: 1rem 0 0.5rem;
}
.icp-link {
font-size: 12px;
line-height: 1.4;
color: var(--muted, #8a7f7b);
text-decoration: none;
}
.icp-link:hover {
color: var(--accent, #e54d42);
}
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
export type Entitlement = Awaited<ReturnType<typeof adminApi.userEntitlements>>
defineProps<{
data: Entitlement
}>()
const typeLabel: Record<string, string> = {
portrait: '愈心解码',
star: '星座',
rhythm: '节律',
relation: '关系',
synastry: '合盘',
image_card: '意象卡',
}
function fmtTime(iso?: string | null) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
</script>
<template>
<div>
<div class="card block">
<h2>权益概览</h2>
<div class="kv">
<div>
<span>报告 detail会员</span>
<strong>{{ data.flags.report_detail_via_membership ? '是' : '否' }}</strong>
</div>
<div>
<span>DeepAccess 份数</span>
<strong>{{ data.flags.deep_access_count }}</strong>
</div>
<div>
<span>已购问答余量</span>
<strong>{{ data.ask_paid_quota_left }}</strong>
</div>
</div>
</div>
<div class="card block">
<h2>成长会员</h2>
<p v-if="data.membership">
{{ data.membership.active ? '有效' : '无效' }} ·
{{ data.membership.plan || '' }} ·
会员问答 {{ data.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(data.membership.expires_at) }}
</p>
<p v-else class="muted">无会员记录</p>
</div>
<div class="card block">
<h2>深度版DeepAccess</h2>
<p v-if="!data.deep_accesses?.length" class="muted">无单份深度版</p>
<table v-else>
<thead>
<tr><th>类型</th><th>报告</th><th>时间</th></tr>
</thead>
<tbody>
<tr v-for="d in data.deep_accesses" :key="d.id">
<td>{{ typeLabel[d.report_type || ''] || d.report_type || '—' }}</td>
<td><code>{{ d.report_id.slice(0, 8) }}</code></td>
<td>{{ fmtTime(d.created_at) }}</td>
</tr>
</tbody>
</table>
<p class="hint">
<RouterLink :to="`/users/${data.user_id}`">返回基础信息可授予会员 / 问答额度</RouterLink>
</p>
</div>
</div>
</template>
<style scoped>
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.kv {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.kv span { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.15rem; }
.hint { margin-top: 0.75rem; font-size: 0.85rem; }
.hint a { color: var(--accent); }
</style>
+29 -3
View File
@@ -2,6 +2,7 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue' import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -28,19 +29,37 @@ async function onLogout() {
<RouterLink to="/analytics">数据</RouterLink> <RouterLink to="/analytics">数据</RouterLink>
<RouterLink to="/content">内容</RouterLink> <RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink> <RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/ask">问答</RouterLink>
<RouterLink to="/safety">安全</RouterLink>
<RouterLink to="/ai">AI</RouterLink>
<RouterLink to="/crisis">危机</RouterLink>
<RouterLink to="/cms">CMS</RouterLink>
<RouterLink to="/star-configs">星座配置</RouterLink>
<RouterLink to="/rhythm-configs">节律配置</RouterLink>
<RouterLink to="/image-card-decks">意象牌组</RouterLink>
<RouterLink to="/scale-definitions">量表元数据</RouterLink>
<RouterLink to="/funnel-definitions">漏斗定义</RouterLink>
<RouterLink to="/report-templates">报告模板</RouterLink>
<RouterLink to="/catalogs">目录仓</RouterLink>
<RouterLink to="/orders">订单</RouterLink> <RouterLink to="/orders">订单</RouterLink>
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink> <RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
<RouterLink to="/push">推送</RouterLink> <RouterLink to="/push">推送</RouterLink>
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink> <RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
<RouterLink to="/audit">审计</RouterLink> <RouterLink to="/audit">审计</RouterLink>
</nav> </nav>
<div class="foot"> <div class="foot">
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span> <span class="muted">{{ auth.username || '管理员' }}</span>
<span v-if="auth.role" class="role">{{ auth.role }} · {{ auth.permissions.length }} </span>
<button class="btn ghost" type="button" @click="onLogout">退出</button> <button class="btn ghost" type="button" @click="onLogout">退出</button>
</div> </div>
</aside> </aside>
<main class="main"> <main class="main">
<div class="main-body">
<RouterView /> <RouterView />
</div>
<IcpFooter />
</main> </main>
</div> </div>
</template> </template>
@@ -75,7 +94,14 @@ nav a.router-link-active {
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08); box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
} }
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; } .foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.main { padding: 1.5rem 1.75rem; } .role { font-size: 0.75rem; color: var(--muted); }
.main {
padding: 1.5rem 1.75rem;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-body { flex: 1; min-width: 0; }
@media (max-width: 800px) { @media (max-width: 800px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); } .nav { border-right: 0; border-bottom: 1px solid var(--line); }
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
type Prompt = Awaited<ReturnType<typeof adminApi.systemPrompts>>['items'][number]
type Source = Awaited<ReturnType<typeof adminApi.knowledgeSources>>['items'][number]
const loading = ref(false)
const error = ref('')
const items = ref<Prompt[]>([])
const selected = ref<Prompt | null>(null)
const detailErr = ref('')
const ksLoading = ref(false)
const ksError = ref('')
const sources = ref<Source[]>([])
const selectedSource = ref<Source | null>(null)
const ksDetailErr = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.systemPrompts()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function loadSources() {
ksLoading.value = true
ksError.value = ''
try {
const res = await adminApi.knowledgeSources()
sources.value = res.items || []
} catch (e) {
ksError.value = e instanceof Error ? e.message : '加载失败'
} finally {
ksLoading.value = false
}
}
async function openPrompt(id: string) {
detailErr.value = ''
try {
selected.value = await adminApi.systemPrompt(id)
} catch (e) {
detailErr.value = e instanceof Error ? e.message : '详情失败'
selected.value = null
}
}
async function openSource(id: string) {
ksDetailErr.value = ''
try {
selectedSource.value = await adminApi.knowledgeSource(id)
} catch (e) {
ksDetailErr.value = e instanceof Error ? e.message : '详情失败'
selectedSource.value = null
}
}
function fmtTime(iso?: string) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
onMounted(() => {
void load()
void loadSources()
})
</script>
<template>
<section>
<h1>AI 配置</h1>
<p class="muted">SystemPrompt / KnowledgeSource 只读目录 · 本切片不可编辑发布</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>系统提示词</h2>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>版本</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="p in items" :key="p.id">
<td><code>{{ p.code }}</code></td>
<td>{{ p.title }}</td>
<td>v{{ p.version }}</td>
<td>{{ p.active ? '启用' : '停用' }}{{ p.system ? ' · 系统' : '' }}</td>
<td><button class="btn" type="button" @click="openPrompt(p.id)">查看</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>正文</h2>
<p v-if="detailErr" class="err">{{ detailErr }}</p>
<template v-else-if="selected">
<p class="meta">{{ selected.title }} · 更新 {{ fmtTime(selected.updated_at) }}</p>
<pre>{{ selected.body }}</pre>
</template>
<p v-else class="muted">选择左侧提示词查看正文</p>
</div>
</div>
<p v-if="ksLoading" class="muted ks-gap">知识源加载中</p>
<p v-else-if="ksError" class="err ks-gap">{{ ksError }}</p>
<div v-else class="layout ks-gap">
<div class="card">
<h2>知识源</h2>
<p v-if="!sources.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>类型</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="s in sources" :key="s.id">
<td><code>{{ s.code }}</code></td>
<td>{{ s.title }}</td>
<td>{{ s.source_kind }}</td>
<td>{{ s.active ? '启用' : '停用' }}{{ s.system ? ' · 系统' : '' }}</td>
<td><button class="btn" type="button" @click="openSource(s.id)">查看</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>知识源详情</h2>
<p v-if="ksDetailErr" class="err">{{ ksDetailErr }}</p>
<template v-else-if="selectedSource">
<p class="meta">
{{ selectedSource.title }} · {{ selectedSource.source_kind }} ·
更新 {{ fmtTime(selectedSource.updated_at) }}
</p>
<p>{{ selectedSource.description || '无描述' }}</p>
</template>
<p v-else class="muted">选择左侧知识源查看详情</p>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1fr 1.1fr; gap: 1rem; margin-top: 1rem; }
.ks-gap { margin-top: 1.5rem; }
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
line-height: 1.45;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.75rem;
background: rgba(255, 253, 251, 0.8);
}
code { font-size: 0.8rem; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
type Detail = Awaited<ReturnType<typeof adminApi.askThread>> & {
messages?: Array<{ id: string; role: string; content: string; created_at: string }>
}
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const items = ref<Thread[]>([])
const selected = ref<Detail | null>(null)
const detailErr = ref('')
const detailLoading = ref(false)
const feedback = ref<Feedback[]>([])
const rating = ref(4)
const tag = ref('helpful')
const note = ref('')
const fbMsg = ref('')
const transcriptDenied = ref(false)
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
const canReadTranscript = () => auth.can('admin.ask.transcript.read')
async function load() {
loading.value = true
error.value = ''
try {
const [threads, fb] = await Promise.all([adminApi.askThreads(), adminApi.askFeedback()])
items.value = threads.items || []
feedback.value = fb.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function openThread(id: string) {
detailLoading.value = true
detailErr.value = ''
selected.value = null
fbMsg.value = ''
transcriptDenied.value = false
try {
const meta = await adminApi.askThread(id)
selected.value = { ...meta, messages: [] }
if (canReadTranscript()) {
const full = await adminApi.askThreadMessages(id)
selected.value = full
} else {
transcriptDenied.value = true
}
} catch (e) {
detailErr.value = e instanceof Error ? e.message : '详情失败'
} finally {
detailLoading.value = false
}
}
async function submitFeedback() {
if (!selected.value) return
fbMsg.value = ''
try {
await adminApi.createAskFeedback(selected.value.id, {
rating: rating.value,
tag: tag.value,
note: note.value || undefined,
})
fbMsg.value = '已提交质量反馈'
note.value = ''
const fb = await adminApi.askFeedback()
feedback.value = fb.items || []
} catch (e) {
fbMsg.value = e instanceof Error ? e.message : '提交失败'
}
}
function fmtTime(iso?: string) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
onMounted(load)
</script>
<template>
<section>
<h1>问答会话</h1>
<p class="muted">AskSessionView · QualityFeedback · 不可改写消息</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card list">
<p v-if="!items.length" class="muted">暂无会话</p>
<table v-else>
<thead>
<tr><th>更新</th><th>场景</th><th>消息</th><th>用户</th><th></th></tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id">
<td>{{ fmtTime(t.updated_at) }}</td>
<td>{{ t.scene || '—' }}</td>
<td>{{ t.message_count }}</td>
<td>
<RouterLink :to="`/users/${t.user_id}`">{{ t.user_id.slice(0, 8) }}</RouterLink>
</td>
<td><button class="btn" type="button" @click="openThread(t.id)">查看</button></td>
</tr>
</tbody>
</table>
<h2 class="sub">近期反馈</h2>
<p v-if="!feedback.length" class="muted">暂无</p>
<ul v-else class="fb-list">
<li v-for="f in feedback.slice(0, 8)" :key="f.id">
{{ f.source }} · {{ f.rating }} · {{ f.tag || '' }} · {{ fmtTime(f.created_at) }}
</li>
</ul>
</div>
<div class="card detail">
<h2>会话详情</h2>
<p v-if="detailLoading" class="muted">加载中</p>
<p v-else-if="detailErr" class="err">{{ detailErr }}</p>
<template v-else-if="selected">
<p class="meta">
{{ selected.scene || '—' }} · {{ selected.message_count }} ·
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
</p>
<p v-if="transcriptDenied" class="muted">无对话原文权限admin.ask.transcript.read</p>
<ul v-else class="msgs">
<li v-for="m in selected.messages || []" :key="m.id" :class="m.role">
<span class="role">{{ m.role }}</span>
<p>{{ m.content }}</p>
<time>{{ fmtTime(m.created_at) }}</time>
</li>
</ul>
<div v-if="canWriteFeedback()" class="fb-form">
<h3>质量反馈</h3>
<select v-model.number="rating">
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
<option :value="4">4</option>
<option :value="5">5</option>
</select>
<select v-model="tag">
<option value="helpful">helpful</option>
<option value="off_topic">off_topic</option>
<option value="unsafe">unsafe</option>
<option value="other">other</option>
</select>
<input v-model="note" type="text" placeholder="备注(可选)" />
<button class="btn" type="button" @click="submitFeedback">提交</button>
<span class="muted">{{ fbMsg }}</span>
</div>
</template>
<p v-else class="muted">选择左侧会话查看消息</p>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
h2.sub { margin-top: 1rem; font-size: 0.95rem; }
h3 { margin: 0.75rem 0 0.4rem; font-size: 0.95rem; }
.layout {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.list table { width: 100%; font-size: 0.88rem; }
.meta { color: var(--muted); font-size: 0.85rem; }
.msgs { list-style: none; margin: 0.75rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.65rem; }
.msgs li {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
}
.msgs .role { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
.msgs p { margin: 0.25rem 0; white-space: pre-wrap; word-break: break-word; }
.msgs time { font-size: 0.75rem; color: var(--muted); }
.msgs .assistant { background: rgba(255, 244, 242, 0.6); }
.fb-list { margin: 0; padding-left: 1.1rem; font-size: 0.85rem; color: var(--muted); }
.fb-form {
display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
margin-top: 0.85rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
}
.fb-form select, .fb-form input {
border: 1px solid var(--line); border-radius: 8px; padding: 0.4rem 0.55rem;
}
.fb-form input { min-width: 10rem; flex: 1; }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
type FeedSlot = Awaited<ReturnType<typeof adminApi.feedSlots>>['items'][number]
const loading = ref(false)
const error = ref('')
const items = ref<Banner[]>([])
const selected = ref<Banner | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
placement: 'home',
link_path: '',
sort_order: 0,
active: true,
})
const slotsLoading = ref(false)
const slotsError = ref('')
const slots = ref<FeedSlot[]>([])
const selectedSlot = ref<FeedSlot | null>(null)
const slotSaving = ref(false)
const slotFormErr = ref('')
const slotForm = reactive({
code: '',
title: '',
slot_key: 'home.main',
placement: 'home',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.banners()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function loadSlots() {
slotsLoading.value = true
slotsError.value = ''
try {
const res = await adminApi.feedSlots()
slots.value = res.items || []
} catch (e) {
slotsError.value = e instanceof Error ? e.message : '加载失败'
} finally {
slotsLoading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.placement = 'home'
form.link_path = ''
form.sort_order = 0
form.active = true
selected.value = null
formErr.value = ''
}
async function openBanner(id: string) {
formErr.value = ''
try {
const b = await adminApi.banner(id)
selected.value = b
form.code = b.code
form.title = b.title
form.placement = b.placement
form.link_path = b.link_path || ''
form.sort_order = b.sort_order
form.active = b.active
} catch {
selected.value = null
}
}
async function openSlot(id: string) {
slotFormErr.value = ''
try {
const s = await adminApi.feedSlot(id)
selectedSlot.value = s
slotForm.code = s.code
slotForm.title = s.title
slotForm.slot_key = s.slot_key
slotForm.placement = s.placement
slotForm.active = s.active
} catch {
selectedSlot.value = null
}
}
function resetSlotForm() {
slotForm.code = ''
slotForm.title = ''
slotForm.slot_key = 'home.main'
slotForm.placement = 'home'
slotForm.active = true
selectedSlot.value = null
slotFormErr.value = ''
}
function slotPayload() {
return {
code: slotForm.code.trim(),
title: slotForm.title.trim(),
slot_key: slotForm.slot_key.trim(),
placement: slotForm.placement,
active: slotForm.active,
}
}
async function createSlot() {
slotSaving.value = true
slotFormErr.value = ''
try {
const row = await adminApi.createFeedSlot(slotPayload())
await loadSlots()
await openSlot(row.id)
} catch (e) {
slotFormErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
slotSaving.value = false
}
}
async function saveSlot() {
if (!selectedSlot.value) return
slotSaving.value = true
slotFormErr.value = ''
try {
const row = await adminApi.updateFeedSlot(selectedSlot.value.id, slotPayload())
selectedSlot.value = row
await loadSlots()
} catch (e) {
slotFormErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
slotSaving.value = false
}
}
async function toggleSlotActive() {
if (!selectedSlot.value) return
slotForm.active = !slotForm.active
await saveSlot()
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
placement: form.placement,
link_path: form.link_path.trim() || null,
sort_order: Number(form.sort_order) || 0,
active: form.active,
}
}
async function createBanner() {
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createBanner(payload())
await load()
await openBanner(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveBanner() {
if (!selected.value) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateBanner(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value) return
form.active = !form.active
await saveBanner()
}
onMounted(() => {
void load()
void loadSlots()
})
</script>
<template>
<section>
<h1>运营位 CMS</h1>
<p class="muted">Banner / FeedSlot 可写 · 下架用停用 · UGC</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>横幅</h2>
<button class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>位置</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.placement }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openBanner(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑横幅' : '新建横幅' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system" /></label>
<label>标题 <input v-model="form.title" /></label>
<label>
位置
<select v-model="form.placement">
<option value="home">home</option>
<option value="explore">explore</option>
<option value="ask">ask</option>
</select>
</label>
<label>链接 <input v-model="form.link_path" placeholder="/membership" /></label>
<label>排序 <input v-model.number="form.sort_order" type="number" /></label>
<label class="check"><input v-model="form.active" type="checkbox" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createBanner">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveBanner">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
<p v-if="slotsLoading" class="muted gap">栏目位加载中</p>
<p v-else-if="slotsError" class="err gap">{{ slotsError }}</p>
<div v-else class="layout gap">
<div class="card">
<h2>栏目位 FeedSlot</h2>
<button class="btn" type="button" @click="resetSlotForm">新建</button>
<p v-if="!slots.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>slot_key</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="s in slots" :key="s.id">
<td><code>{{ s.code }}</code></td>
<td>{{ s.title }}</td>
<td><code>{{ s.slot_key }}</code></td>
<td>{{ s.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openSlot(s.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selectedSlot ? '编辑栏目位' : '新建栏目位' }}</h2>
<label>代码 <input v-model="slotForm.code" :disabled="!!selectedSlot?.system" /></label>
<label>标题 <input v-model="slotForm.title" /></label>
<label>slot_key <input v-model="slotForm.slot_key" /></label>
<label>
位置
<select v-model="slotForm.placement">
<option value="home">home</option>
<option value="explore">explore</option>
<option value="ask">ask</option>
</select>
</label>
<label class="check"><input v-model="slotForm.active" type="checkbox" /> 启用</label>
<p v-if="slotFormErr" class="err">{{ slotFormErr }}</p>
<div class="actions">
<button v-if="!selectedSlot" class="btn primary" type="button" :disabled="slotSaving" @click="createSlot">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="slotSaving" @click="saveSlot">保存</button>
<button class="btn" type="button" :disabled="slotSaving" @click="toggleSlotActive">
{{ slotForm.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
.gap { margin-top: 1.5rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input, .card select { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
type Row = { id: string; code?: string; slug?: string; title?: string; status?: string }
const catalogs = [
{ key: 'publications', label: '定时发布(目录)', load: () => adminApi.publications() },
{ key: 'knowledgeChunks', label: '知识块(目录)', load: () => adminApi.knowledgeChunks() },
{ key: 'tools', label: '工具定义(目录)', load: () => adminApi.tools() },
{ key: 'blockPolicies', label: '拦截策略(目录)', load: () => adminApi.blockPolicies() },
{ key: 'cases', label: '审核类型(目录)', load: () => adminApi.cases() },
{ key: 'events', label: '危机类型(目录)', load: () => adminApi.events() },
{ key: 'interventions', label: '干预类型(目录)', load: () => adminApi.interventions() },
{ key: 'handoffs', label: '转接类型(目录)', load: () => adminApi.handoffs() },
{ key: 'privacy', label: '隐私类型(目录)', load: () => adminApi.requests() },
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
{ key: 'templates', label: '报告模板', load: () => adminApi.reportTemplates() },
{ key: 'funnels', label: '漏斗定义', load: () => adminApi.funnelDefinitions() },
{ key: 'scales', label: '量表定义', load: () => adminApi.exploreScales() },
] as const
const active = ref(0)
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
async function load(idx = active.value) {
active.value = idx
loading.value = true
error.value = ''
try {
const res = (await catalogs[idx].load()) as { items?: Row[] }
items.value = (res.items || []) as Row[]
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
items.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
void load(0)
})
</script>
<template>
<section>
<h1>目录只读仓</h1>
<p class="muted">ECR-026040 运营只读目录聚合类型占位非用户个案 docs/WAVE0/CATALOG_VS_CASE.md</p>
<div class="tabs">
<button
v-for="(c, i) in catalogs"
:key="c.key"
class="btn"
:class="{ on: i === active }"
type="button"
@click="load(i)"
>
{{ c.label }}
</button>
</div>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="card">
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>标识</th><th>标题</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="it in items" :key="it.id">
<td><code>{{ it.code || it.slug || it.id.slice(0, 8) }}</code></td>
<td>{{ it.title || '—' }}</td>
<td>{{ it.status || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 1rem 0; }
.btn.on { background: #ffe4e0; color: var(--accent); font-weight: 700; }
code { font-size: 0.8rem; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
type Policy = Awaited<ReturnType<typeof adminApi.crisisPolicies>>['items'][number]
const loading = ref(false)
const error = ref('')
const items = ref<Policy[]>([])
const sample = ref('真的不想活了,怎么办')
const matches = ref<
Array<{ code: string; title: string; severity: string; action: string; helpline_text?: string }>
>([])
const evalMsg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.crisisPolicies()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function runEval() {
evalMsg.value = ''
try {
const res = await adminApi.evaluateCrisis(sample.value)
matches.value = res.matches || []
evalMsg.value = matches.value.length ? `命中 ${matches.value.length}` : '未命中'
} catch (e) {
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
matches.value = []
}
}
onMounted(load)
</script>
<template>
<section>
<h1>危机策略</h1>
<p class="muted">CrisisPolicy 只读 · 试匹配不写 CrisisEvent · 非医疗诊断</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>策略目录</h2>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>严重度</th><th>动作</th><th>模式</th></tr>
</thead>
<tbody>
<tr v-for="p in items" :key="p.id">
<td>{{ p.title }} <code>{{ p.code }}</code></td>
<td>{{ p.severity }}</td>
<td>{{ p.action }}</td>
<td>{{ p.pattern }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>试匹配</h2>
<textarea v-model="sample" rows="4" />
<div class="row">
<button class="btn" type="button" @click="runEval">试匹配</button>
<span class="muted">{{ evalMsg }}</span>
</div>
<ul v-if="matches.length" class="hits">
<li v-for="m in matches" :key="m.code">
<strong>{{ m.title }}</strong> · {{ m.severity }} · {{ m.action }}
<p v-if="m.helpline_text" class="help">{{ m.helpline_text }}</p>
</li>
</ul>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.7rem;
resize: vertical;
font: inherit;
}
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
.help { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.85rem; }
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.funnelDefinitions>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.growth.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.funnelDefinitions()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.funnelDefinition(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createFunnelDefinition(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateFunnelDefinition(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>漏斗定义</h1>
<p class="muted">FunnelDefinition 写面 · 下架仅 active=false · ECR-047</p>
<p v-if="!canWrite()" class="err">需要 admin.growth.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.imageCardDecks>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.imageCardDecks()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.imageCardDeck(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createImageCardDeck(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateImageCardDeck(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>意象牌组</h1>
<p class="muted">ImageCardDeck 写面 · 下架仅 active=false · ECR-045</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+15 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue' import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -46,11 +47,24 @@ async function onSubmit() {
{{ loading ? '登录中…' : '登录' }} {{ loading ? '登录中…' : '登录' }}
</button> </button>
</form> </form>
<IcpFooter />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; } .wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wrap :deep(.icp-footer) {
margin-top: auto;
width: 100%;
padding-bottom: 0.25rem;
}
.login { width: min(380px, 100%); text-align: center; } .login { width: min(380px, 100%); text-align: center; }
.login :deep(.field) { text-align: left; } .login :deep(.field) { text-align: left; }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; } h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Plan = {
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const items = ref<Plan[]>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.membershipPlans()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function save(p: Plan) {
msg.value = ''
try {
await adminApi.updateMembershipPlan(p.code, {
title: p.title,
duration_days: Number(p.duration_days),
amount_cents: Number(p.amount_cents),
active: p.active,
})
msg.value = `${p.code} 已保存`
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '保存失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>会员套餐</h1>
<p class="muted">配置成长会员时长与标价mock 履约读表</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-for="p in items" :key="p.code" class="card block">
<h2>{{ p.code }}</h2>
<div class="row">
<label>标题 <input v-model="p.title" /></label>
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
<label>标价 <input v-model.number="p.amount_cents" type="number" min="0" /></label>
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
<button
v-if="auth.can('admin.membership.plans.write')"
class="btn"
type="button"
@click="save(p)"
>
保存
</button>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; text-transform: uppercase; }
.block { margin-bottom: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
input { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const label = ref('batch')
const plan = ref('month')
const qty = ref(5)
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
const activeBatch = ref('')
async function loadBatches() {
loading.value = true
error.value = ''
try {
const res = await adminApi.redemptionBatches()
batches.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createBatch() {
msg.value = ''
try {
const res = await adminApi.createRedemptionBatch({
label: label.value,
plan_code: plan.value,
quantity: Number(qty.value),
})
msg.value = `已生成 ${res.codes.length} 个码`
activeBatch.value = res.batch.id
codes.value = res.codes
await loadBatches()
} catch (e) {
msg.value = e instanceof Error ? e.message : '生成失败'
}
}
async function openBatch(id: string) {
activeBatch.value = id
const res = await adminApi.redemptionCodes(id)
codes.value = res.items || []
}
async function disable(id: string) {
await adminApi.disableRedemptionCode(id)
if (activeBatch.value) await openBatch(activeBatch.value)
}
onMounted(loadBatches)
</script>
<template>
<section>
<h1>兑换码</h1>
<p class="muted">批量生成会员兑换码禁真支付</p>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
<h2>生成批次</h2>
<div class="row">
<label>标签 <input v-model="label" /></label>
<label>套餐
<select v-model="plan">
<option value="month">month</option>
<option value="quarter">quarter</option>
<option value="year">year</option>
</select>
</label>
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
<button class="btn" type="button" @click="createBatch">生成</button>
</div>
</div>
<div class="card block">
<h2>批次</h2>
<p v-if="loading" class="muted">加载中</p>
<ul v-else>
<li v-for="b in batches" :key="b.id">
<button class="link" type="button" @click="openBatch(b.id)">
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
</button>
</li>
</ul>
</div>
<div v-if="codes.length" class="card block">
<h2>码列表</h2>
<table>
<thead><tr><th></th><th>状态</th><th></th></tr></thead>
<tbody>
<tr v-for="c in codes" :key="c.id">
<td><code>{{ c.code }}</code></td>
<td>{{ c.status }}</td>
<td>
<button
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
class="btn ghost"
type="button"
@click="disable(c.id)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
input, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
ul { margin: 0; padding-left: 1rem; }
code { font-size: 0.85rem; }
</style>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.reportTemplates>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.growth.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
scene: 'portrait',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.reportTemplates()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.scene = 'portrait'
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.reportTemplate(id)
selected.value = row
form.code = row.code
form.title = row.title
form.scene = row.scene
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
scene: form.scene.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createReportTemplate(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateReportTemplate(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>报告模板</h1>
<p class="muted">ReportTemplate 写面 · 下架仅 active=false · ECR-048</p>
<p v-if="!canWrite()" class="err">需要 admin.growth.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>场景</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.scene }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label>场景 <input v-model="form.scene" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.rhythmConfigs>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.rhythmConfigs()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.rhythmConfig(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createRhythmConfig(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateRhythmConfig(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>节律配置</h1>
<p class="muted">RhythmConfig 写面 · 下架仅 active=false · ECR-044</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
type Rule = Awaited<ReturnType<typeof adminApi.filterRules>>['items'][number]
const loading = ref(false)
const error = ref('')
const items = ref<Rule[]>([])
const sample = ref('我不想活了,求帮助')
const matches = ref<Array<{ code: string; title: string; category: string; action: string }>>([])
const evalMsg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.filterRules()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function runEval() {
evalMsg.value = ''
try {
const res = await adminApi.evaluateContent(sample.value)
matches.value = res.matches || []
evalMsg.value = matches.value.length ? `命中 ${matches.value.length}` : '未命中'
} catch (e) {
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
matches.value = []
}
}
onMounted(load)
</script>
<template>
<section>
<h1>内容安全</h1>
<p class="muted">FilterRule 只读 · 试匹配不写审核工单</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>过滤规则</h2>
<p v-if="!items.length" class="muted">暂无规则</p>
<table v-else>
<thead>
<tr><th>代码</th><th>分类</th><th>动作</th><th>模式</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="r in items" :key="r.id">
<td>{{ r.title }} <code>{{ r.code }}</code></td>
<td>{{ r.category }}</td>
<td>{{ r.action }}</td>
<td>{{ r.pattern }}</td>
<td>{{ r.active ? '启用' : '停用' }}{{ r.system ? ' · 系统' : '' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>试匹配</h2>
<textarea v-model="sample" rows="4" />
<div class="row">
<button class="btn" type="button" @click="runEval">试匹配</button>
<span class="muted">{{ evalMsg }}</span>
</div>
<ul v-if="matches.length" class="hits">
<li v-for="m in matches" :key="m.code">
{{ m.title }} · {{ m.category }} · <strong>{{ m.action }}</strong>
</li>
</ul>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.7rem;
resize: vertical;
font: inherit;
}
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.exploreScales>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
slug: '',
title: '',
description: '',
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.exploreScales()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.slug = ''
form.title = ''
form.description = ''
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.exploreScale(id)
selected.value = row
form.slug = row.slug
form.title = row.title
form.description = row.description || ''
} catch {
selected.value = null
}
}
function payload() {
return {
slug: form.slug.trim(),
title: form.title.trim(),
description: form.description.trim(),
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createExploreScale(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateExploreScale(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>量表元数据</h1>
<p class="muted">
ScaleDefinition 写面 · slug/title/description · 创建为 draft · 上下架请用内容ECR-008· ECR-046
</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>slug</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.slug }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.status }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑元数据' : '新建(draft' }}</h2>
<label>slug <input v-model="form.slug" :disabled="!canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label>简介 <textarea v-model="form.description" rows="3" :disabled="!canWrite()" /></label>
<p v-if="selected" class="muted">当前 status={{ selected.status }}本页不可改</p>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<button v-else class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input, .card textarea { width: 100%; margin-top: 0.2rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.starConfigs>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.starConfigs()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.starConfig(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createStarConfig(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateStarConfig(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>星座配置</h1>
<p class="muted">StarConfig 写面 · 下架仅 active=false · ECR-043</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+196 -22
View File
@@ -1,25 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client' import { adminApi, type UserDetail } from '@/api/client'
import UserEntitlementPanel, { type Entitlement } from '@/components/UserEntitlementPanel.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
type Insight = Awaited<ReturnType<typeof adminApi.userInsight>>
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const detail = ref<UserDetail | null>(null) const detail = ref<UserDetail | null>(null)
const tab = ref<'base' | 'insight' | 'entitlement'>('base')
const insight = ref<Insight | null>(null)
const insightErr = ref('')
const insightLoading = ref(false)
const entitlement = ref<Entitlement | null>(null)
const entitlementErr = ref('')
const entitlementLoading = ref(false)
const plan = ref('month') const plan = ref('month')
const askDelta = ref(10) const askDelta = ref(10)
const grantMsg = ref('') const grantMsg = ref('')
const askMsg = ref('') const askMsg = ref('')
const banMsg = ref('') const nextStatus = ref('banned')
const statusReason = ref('')
const statusMsg = ref('')
const transitions = ref<
Array<{
from_status: string
to_status: string
reason: string
created_at: string
}>
>([])
const canWriteStatus = computed(() => auth.can('admin.users.status.write'))
const canGrantMembership = computed(() => auth.can('admin.users.membership.grant'))
const canGrantAskQuota = computed(() => auth.can('admin.users.ask_quota.grant'))
async function load() { async function load() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
detail.value = await adminApi.user(String(route.params.id)) const id = String(route.params.id)
detail.value = await adminApi.user(id)
try {
const tr = await adminApi.statusTransitions(id)
transitions.value = tr.items || []
} catch {
transitions.value = []
}
if (tab.value === 'insight') await loadInsight()
if (tab.value === 'entitlement') await loadEntitlement()
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
@@ -27,6 +60,37 @@ async function load() {
} }
} }
async function loadInsight() {
insightLoading.value = true
insightErr.value = ''
try {
insight.value = await adminApi.userInsight(String(route.params.id))
} catch (e) {
insightErr.value = e instanceof Error ? e.message : '洞察加载失败'
insight.value = null
} finally {
insightLoading.value = false
}
}
async function loadEntitlement() {
entitlementLoading.value = true
entitlementErr.value = ''
try {
entitlement.value = await adminApi.userEntitlements(String(route.params.id))
} catch (e) {
entitlementErr.value = e instanceof Error ? e.message : '权益加载失败'
entitlement.value = null
} finally {
entitlementLoading.value = false
}
}
watch(tab, (v) => {
if (v === 'insight' && !insight.value && !insightLoading.value) void loadInsight()
if (v === 'entitlement' && !entitlement.value && !entitlementLoading.value) void loadEntitlement()
})
async function grant() { async function grant() {
grantMsg.value = '' grantMsg.value = ''
try { try {
@@ -49,19 +113,15 @@ async function grantAsk() {
} }
} }
async function toggleBan() { async function changeStatus() {
banMsg.value = '' statusMsg.value = ''
try { try {
if (detail.value?.status === 'banned') { await adminApi.setUserStatus(String(route.params.id), nextStatus.value, statusReason.value)
await adminApi.unbanUser(String(route.params.id)) statusMsg.value = '状态已更新'
banMsg.value = '已解封' statusReason.value = ''
} else {
await adminApi.banUser(String(route.params.id))
banMsg.value = '已封禁'
}
await load() await load()
} catch (e) { } catch (e) {
banMsg.value = e instanceof Error ? e.message : '操作失败' statusMsg.value = e instanceof Error ? e.message : '状态更新失败'
} }
} }
@@ -93,6 +153,13 @@ onMounted(load)
<p v-if="loading" class="muted">加载中</p> <p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p> <p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="detail"> <template v-else-if="detail">
<div class="tabs">
<button type="button" :class="{ on: tab === 'base' }" @click="tab = 'base'">基础</button>
<button type="button" :class="{ on: tab === 'insight' }" @click="tab = 'insight'">洞察</button>
<button type="button" :class="{ on: tab === 'entitlement' }" @click="tab = 'entitlement'">权益</button>
</div>
<template v-if="tab === 'base'">
<div class="card block"> <div class="card block">
<h2>账号</h2> <h2>账号</h2>
<div class="kv"> <div class="kv">
@@ -102,11 +169,24 @@ onMounted(load)
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div> <div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div> <div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
</div> </div>
<div class="grant"> <div v-if="canWriteStatus" class="grant">
<button class="btn" type="button" @click="toggleBan"> <select v-model="nextStatus">
{{ detail.status === 'banned' ? '解封' : '封禁' }} <option value="active">active</option>
</button> <option value="disabled">disabled</option>
<span v-if="banMsg" class="muted">{{ banMsg }}</span> <option value="banned">banned</option>
<option value="suspended">suspended</option>
</select>
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
<button class="btn" type="button" @click="changeStatus">变更状态</button>
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
</div>
<div v-if="transitions.length" class="trans">
<h3>状态迁移</h3>
<ul>
<li v-for="(t, i) in transitions" :key="i">
{{ t.from_status }} {{ t.to_status }} · {{ t.reason || '' }} · {{ fmtTime(t.created_at) }}
</li>
</ul>
</div> </div>
</div> </div>
@@ -118,7 +198,7 @@ onMounted(load)
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} · 会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(detail.membership.expires_at) }} 到期 {{ fmtTime(detail.membership.expires_at) }}
</p> </p>
<div v-if="auth.isSuper" class="grant"> <div v-if="canGrantMembership" class="grant">
<select v-model="plan"> <select v-model="plan">
<option value="month">月卡</option> <option value="month">月卡</option>
<option value="quarter">季卡</option> <option value="quarter">季卡</option>
@@ -127,13 +207,13 @@ onMounted(load)
<button class="btn" type="button" @click="grant">授予 / 延长</button> <button class="btn" type="button" @click="grant">授予 / 延长</button>
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span> <span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
</div> </div>
<p v-else class="muted">仅超级管理员可授予会员</p> <p v-else class="muted">无会员授予权限</p>
</div> </div>
<div class="card block"> <div class="card block">
<h2>问答额度已购</h2> <h2>问答额度已购</h2>
<p>已购余量<strong>{{ detail.ask_paid_quota_left }}</strong> </p> <p>已购余量<strong>{{ detail.ask_paid_quota_left }}</strong> </p>
<div v-if="auth.isSuper" class="grant"> <div v-if="canGrantAskQuota" class="grant">
<select v-model.number="askDelta"> <select v-model.number="askDelta">
<option :value="10">+10</option> <option :value="10">+10</option>
<option :value="30">+30</option> <option :value="30">+30</option>
@@ -142,6 +222,7 @@ onMounted(load)
<button class="btn" type="button" @click="grantAsk">增加额度</button> <button class="btn" type="button" @click="grantAsk">增加额度</button>
<span v-if="askMsg" class="muted">{{ askMsg }}</span> <span v-if="askMsg" class="muted">{{ askMsg }}</span>
</div> </div>
<p v-else class="muted">无问答额度授予权限</p>
</div> </div>
<div class="card block"> <div class="card block">
@@ -195,6 +276,74 @@ onMounted(load)
</table> </table>
</div> </div>
</template> </template>
<template v-else-if="tab === 'insight'">
<p v-if="insightLoading" class="muted">加载洞察</p>
<p v-else-if="insightErr" class="err">{{ insightErr }}</p>
<template v-else-if="insight">
<div class="card block">
<h2>概览</h2>
<div class="kv">
<div><span>档案数</span><strong>{{ insight.profiles_count }}</strong></div>
<div><span>问答线程</span><strong>{{ insight.behavior.ask_thread_count }}</strong></div>
</div>
</div>
<div class="card block">
<h2>心理标签派生</h2>
<p v-if="!insight.tags?.length" class="muted">暂无无成长报告类型</p>
<ul v-else class="tags">
<li v-for="t in insight.tags" :key="t.code">{{ t.label }} <code>{{ t.code }}</code></li>
</ul>
</div>
<div class="card block">
<h2>报告类型</h2>
<p v-if="!insight.reports_by_type?.length" class="muted"></p>
<table v-else>
<thead><tr><th>类型</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="c in insight.reports_by_type" :key="c.type">
<td>{{ typeLabel[c.type] || c.type }}</td>
<td>{{ c.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card block">
<h2>近报告</h2>
<p v-if="!insight.recent_reports?.length" class="muted"></p>
<table v-else>
<thead><tr><th>类型</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="r in insight.recent_reports" :key="r.id">
<td>{{ typeLabel[r.type] || r.type }}</td>
<td>{{ fmtTime(r.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card block">
<h2>行为快照</h2>
<p v-if="!insight.behavior.events?.length" class="muted">暂无埋点仍为正常</p>
<table v-else>
<thead><tr><th>事件</th><th>页面</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="(e, i) in insight.behavior.events" :key="i">
<td>{{ e.name }}</td>
<td>{{ e.page_path || '—' }}</td>
<td>{{ fmtTime(e.received_at) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
<template v-else-if="tab === 'entitlement'">
<p v-if="entitlementLoading" class="muted">加载权益</p>
<p v-else-if="entitlementErr" class="err">{{ entitlementErr }}</p>
<UserEntitlementPanel v-else-if="entitlement" :data="entitlement" />
</template>
</template>
</section> </section>
</template> </template>
@@ -203,6 +352,17 @@ onMounted(load)
.crumb a { color: var(--accent); } .crumb a { color: var(--accent); }
h1 { margin: 0 0 1rem; font-size: 1.35rem; } h1 { margin: 0 0 1rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; } h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
h3 { margin: 0.75rem 0 0.35rem; font-size: 0.95rem; }
.tabs { display: flex; gap: 0.35rem; margin-bottom: 1rem; }
.tabs button {
border: 1px solid var(--line);
background: transparent;
border-radius: 8px;
padding: 0.4rem 0.85rem;
cursor: pointer;
color: var(--muted);
}
.tabs button.on { color: var(--text); border-color: var(--accent); }
.block { margin-bottom: 1rem; } .block { margin-bottom: 1rem; }
.kv { .kv {
display: grid; display: grid;
@@ -213,5 +373,19 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.kv .wide { grid-column: 1 / -1; } .kv .wide { grid-column: 1 / -1; }
.kv code { font-size: 0.82rem; word-break: break-all; } .kv code { font-size: 0.82rem; word-break: break-all; }
.grant { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.75rem; } .grant { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; } .grant select, .grant .reason {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.45rem 0.6rem;
}
.grant .reason { min-width: 12rem; }
.trans ul { margin: 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; }
.tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tags li {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.35rem 0.6rem;
font-size: 0.9rem;
}
.tags code { margin-left: 0.35rem; font-size: 0.75rem; color: var(--muted); }
</style> </style>

Some files were not shown because too many files have changed in this diff Show More