Compare commits
103
Commits
89756f65b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7155b8b53a | ||
|
|
db4d118f9a | ||
|
|
fc53be22f8 | ||
|
|
e7290ed726 | ||
|
|
db3669c595 | ||
|
|
580ba85b61 | ||
|
|
57687de360 | ||
|
|
f4bd57ce1b | ||
|
|
9d4fb52d29 | ||
|
|
96292d0e95 | ||
|
|
635a83fe11 | ||
|
|
ebc2d3ad80 | ||
|
|
8d856fb56c | ||
|
|
d33a25a663 | ||
|
|
c693ac4bbc | ||
|
|
e0f605558b | ||
|
|
aa197b719b | ||
|
|
580f5ea3f0 | ||
|
|
69cb99600f | ||
|
|
1db5fcc018 | ||
|
|
258deb0184 | ||
|
|
b3fe4363fd | ||
|
|
fa8b0ba963 | ||
|
|
0158036341 | ||
|
|
dc93999f9e | ||
|
|
87b463b2bb | ||
|
|
fcc7667ba3 | ||
|
|
19091bff12 | ||
|
|
77907f79ee | ||
|
|
2b295472a4 | ||
|
|
f492088237 | ||
|
|
cd3ac2c9ab | ||
|
|
ec20e9744e | ||
|
|
aff6621ff8 | ||
|
|
56f661748a | ||
|
|
655141980a | ||
|
|
9707b72808 | ||
|
|
ad45d17dff | ||
|
|
d81e8364cc | ||
|
|
b7b0b18802 | ||
|
|
0d50c0ee73 | ||
|
|
62cd8c45dd | ||
|
|
4889ff5916 | ||
|
|
1026cd0596 | ||
|
|
e9c11cdf1d | ||
|
|
432c090046 | ||
|
|
e955f1e6a2 | ||
|
|
63742c1d66 | ||
|
|
5de9a7bbe5 | ||
|
|
b34b4f281f | ||
|
|
5c462ecb2a | ||
|
|
0fa6da2446 | ||
|
|
bd9e13497f | ||
|
|
57acfa6105 | ||
|
|
ebc93b6c7e | ||
|
|
09b9c4daab | ||
|
|
45e98f6872 | ||
|
|
aad6cdf7ea | ||
|
|
487bee78d1 | ||
|
|
01063208a1 | ||
|
|
447fb6da16 | ||
|
|
58eef7c02b | ||
|
|
24a115297b | ||
|
|
0d4bc5054e | ||
|
|
b904e6b04f | ||
|
|
271e312669 | ||
|
|
5846d39d97 | ||
|
|
e6740a0326 | ||
|
|
00d9ed5610 | ||
|
|
1f572c4151 | ||
|
|
c1a8a58488 | ||
|
|
78857d5510 | ||
|
|
b91806ba80 | ||
|
|
8c50b3d925 | ||
|
|
e32466357d | ||
|
|
f51b2524c5 | ||
|
|
32ac559385 | ||
|
|
ac1aec857d | ||
|
|
bf1ae8bc53 | ||
|
|
dd4d644eaa | ||
|
|
de224184a6 | ||
|
|
4b5e5bc63d | ||
|
|
140b08ca01 | ||
|
|
22f9e358ae | ||
|
|
93227d3316 | ||
|
|
e270a38393 | ||
|
|
cae3380cbf | ||
|
|
de025d72bc | ||
|
|
c81f57a7d1 | ||
|
|
37b91e51b8 | ||
|
|
00a798bd85 | ||
|
|
1c157a0e46 | ||
|
|
61ae3b0451 | ||
|
|
1afda1d389 | ||
|
|
1eeb0b00e7 | ||
|
|
0e26aabef8 | ||
|
|
882c01d81a | ||
|
|
e25cd94b0c | ||
|
|
f75b42397f | ||
|
|
b5a05941d9 | ||
|
|
85ed0901bb | ||
|
|
27f27a1cb2 | ||
|
|
d33c8fdfe9 |
@@ -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 单次使用,且拖住前端统一接入)。
|
||||
@@ -3,5 +3,10 @@
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [../domain.md](../domain.md) | Tech ↔ 用户名 |
|
||||
| [domain-map.md](domain-map.md) | Bounded contexts(冻结) |
|
||||
| [domain-map.md](domain-map.md) | Bounded contexts(P1 冻结 + Ops Wave 0 Draft) |
|
||||
| [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/`。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -23,6 +23,26 @@
|
||||
|
||||
---
|
||||
|
||||
## Ops Platform Bounded Contexts(Wave 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-013B(Identity_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
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Entity Catalog(Wave 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** 读模型(Membership∪DeepAccess) |
|
||||
| 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 改动。
|
||||
@@ -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, 社区广场]
|
||||
@@ -227,16 +227,18 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
|
||||
## 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 B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Implemented** · Spec `ops-analytics.md`) |
|
||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Implemented** · Spec `ops-content.md`) |
|
||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007** · Spec `ops-analytics.md`) |
|
||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008** · Spec `ops-content.md`) |
|
||||
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.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。
|
||||
|
||||
@@ -258,11 +260,11 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
| `/scales/:slug` | 探索测试作答 | |
|
||||
| `/reports` | 成长报告列表 | |
|
||||
| `/reports/:id` | 成长报告详情 | |
|
||||
| `/star` | 星座 | P2 |
|
||||
| `/synastry` | 合盘 | P2 |
|
||||
| `/synastry/invite/:token` | 合盘邀请 | P2 |
|
||||
| `/rhythm` | 身心节律 | P2 |
|
||||
| `/cards` | 意象卡片 | P2 |
|
||||
| `/star` | 星座 | P2 Complete |
|
||||
| `/synastry` | 合盘 | P2 Complete |
|
||||
| `/synastry/invite/:token` | 合盘邀请 | P2 Complete |
|
||||
| `/rhythm` | 身心节律 | P2 Complete |
|
||||
| `/cards` | 意象卡片 | P2 Complete |
|
||||
|
||||
兼容重定向:`/decode` → `/portrait`(仅技术兼容,UI 不出现 decode 文案)。
|
||||
|
||||
|
||||
@@ -21,11 +21,46 @@
|
||||
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | 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-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Implemented |
|
||||
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 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 |
|
||||
| [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 |
|
||||
| [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` → 填满 → 在本表登记 → 再编码。
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| Name | 账号登录(手机号+密码) |
|
||||
| Name | 账号登录(小程序微信;H5 过渡账密) |
|
||||
| Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 |
|
||||
| Business Goal | 数据归属清晰;为会员与深度版付费打底 |
|
||||
| Business Goal | 数据归属清晰;咨询与魔方同一人;为会员与深度版付费打底 |
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) |
|
||||
| 小程序微信登录 / 登出 / me | 短信 OTP |
|
||||
| 设备身份绑定到已注册账号 | 游客可看完整报告 |
|
||||
| Bearer Session | 运营 Admin 登录(独立) |
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
```text
|
||||
进入结果页或建档
|
||||
↓
|
||||
未登录? → /login 注册或登录
|
||||
未登录? → 小程序 login-pop(微信)或 H5 /login(过渡账密)
|
||||
↓
|
||||
Device 绑定到账号 User
|
||||
↓
|
||||
@@ -58,7 +58,8 @@ Device 绑定到账号 User
|
||||
|
||||
| 路由 | 页面 |
|
||||
|---|---|
|
||||
| `/login` | LoginPage(注册/登录切换) |
|
||||
| `/login` | H5 LoginPage(过渡账密;小程序已删除此页) |
|
||||
| 小程序 `login-pop` | 微信手机号授权(唯一 C 端登录) |
|
||||
| `/mine` | 展示账号手机号尾号 + 退出 |
|
||||
|
||||
---
|
||||
@@ -77,8 +78,8 @@ Device 绑定到账号 User
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| R1 | **临时开放:** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB |
|
||||
| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 |
|
||||
| R1 | **小程序:** 微信 `code` + 手机号授权登录;按 `wx_openid` 命中,否则按手机号合并历史账密账号;未命中则建号 |
|
||||
| R2 | **H5 过渡:** OpenLogin(任意非空手机号+密码)仍可用,避免 H5 断服;小程序不再提供账密页。正式关掉账密须另改本 Spec |
|
||||
| R3 | 登录成功后:签发 session;device_identities.user_id 改绑到账号 |
|
||||
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
|
||||
| R5 | 登出作废 session;设备可再登录其他账号 |
|
||||
@@ -91,7 +92,7 @@ Device 绑定到账号 User
|
||||
|
||||
## 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)`
|
||||
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
|
||||
|
||||
@@ -101,8 +102,9 @@ Device 绑定到账号 User
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | 注册(可带 nickname) |
|
||||
| POST | `/api/v1/auth/login` | 登录 |
|
||||
| POST | `/api/v1/auth/wechat` | 小程序微信登录 `{ code, encryptedData, iv }` |
|
||||
| POST | `/api/v1/auth/register` | H5 过渡注册(可带 nickname) |
|
||||
| POST | `/api/v1/auth/login` | H5 过渡登录 |
|
||||
| POST | `/api/v1/auth/logout` | 登出 |
|
||||
| GET | `/api/v1/auth/me` | 当前账号(含 avatar_url) |
|
||||
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
|
||||
@@ -158,4 +160,5 @@ Device 绑定到账号 User
|
||||
## 13. AI 开发前检查
|
||||
|
||||
- [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: 账户生命周期 / UserStatus(Ops · 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.status(013A 外) |
|
||||
| 权限码 `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 | 200;GET 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 **< 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 | YES(transitions 表;permission 种子;必要时 CHECK/注释枚举) |
|
||||
| Packages | service account/lifecycle · DeviceAuth/session 拒绝 · admin handler · admin-h5 用户详情最小 CTA |
|
||||
| Depends | ECR-013A Closed(permission middleware) |
|
||||
@@ -208,7 +208,19 @@ Phase A 可先 `console`/本地;不挡验收。
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| **A(本 ECR)** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 |
|
||||
| **B(ECR-007)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` |
|
||||
| **C(ECR-008)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md`(Approved) |
|
||||
| **B(ECR-007 Closed)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` |
|
||||
| **C(ECR-008 Closed)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md` |
|
||||
| **E(ECR-013A Closed)** | Admin RBAC — Spec `ops-rbac.md` · Parent WAVE0-FROZEN |
|
||||
| **F(ECR-013B Closed)** | AccountLifecycle — Spec `ops-account-lifecycle.md` |
|
||||
| **G(ECR-014 Closed)** | MembershipPlan — Spec `ops-membership-plan.md` |
|
||||
| **H(ECR-015 Closed)** | RedemptionCode — Spec `ops-redemption-code.md` |
|
||||
| **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` |
|
||||
| **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` |
|
||||
| **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` |
|
||||
| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
|
||||
| **M(ECR-020 Closed)** | QualityFeedback — Spec `ops-quality-feedback.md` |
|
||||
| **N(ECR-021 Closed)** | AICoreConfig SystemPrompt — Spec `ops-ai-core-config.md` |
|
||||
| **O(ECR-022 Closed)** | CrisisCare CrisisPolicy — Spec `ops-crisis-care.md` |
|
||||
| 后置 | Knowledge·Tools 写面 / CrisisEvent / 真支付(Loop 续跑) |
|
||||
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
|
||||
| E | RBAC · 封禁 · 推送占位(另开 ECR) |
|
||||
| 后置 | 封禁加深(Account_Risk)· 推送占位 |
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · SystemPrompt(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-021.yaml`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature Spec: 运营行为分析(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)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: AskOperations(Ops · 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 < 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 Closed(Banner 只读)
|
||||
> 关联:[ops-banner.md](ops-banner.md)(只读基线)· [ops-content.md](ops-content.md) · [home.md](home.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
让运营可 **创建 / 更新 / 上下架** Banner;C 端首页对 `placement=home` 的 active Banner **生效**;写操作鉴权 + 审计;失败可回退静态。
|
||||
|
||||
验证 Write-Wave 首刀闭环,**不**做 FeedSlot、真支付、UGC。
|
||||
|
||||
## 2. In / Out
|
||||
|
||||
| In | Out |
|
||||
|----|-----|
|
||||
| Admin:Banner 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)。
|
||||
- **本切片**:用 **Banner(placement=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-goals(STOP 对齐)
|
||||
|
||||
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`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: OpsCMS · Banner(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-024.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ContentSafety · BlockPolicy(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-029.yaml`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: ContentSafety · FilterRule(Ops · 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 < 500ms |
|
||||
| AC-O-01 | evaluate 不写 Audit(只读试匹配) |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature Spec: 运营内容配置(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)
|
||||
|
||||
---
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds 信息流 CMS(本 ECR 后置,仍用静态 `homeFeeds`) |
|
||||
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds/推荐区运营:ECR-008 后置;**Banner 写面见 ECR-041**;完整 FeedSlot 写另 ECR |
|
||||
| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 |
|
||||
| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS |
|
||||
| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E) |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: CrisisCare · CrisisPolicy(Ops · 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 < 500ms |
|
||||
| AC-O-01 | evaluate 不写 Audit/Event |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-022.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: CrisisCare · CrisisEvent(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-031.yaml`
|
||||
@@ -0,0 +1,36 @@
|
||||
# Feature Spec: Entitlement(Ops · 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 < 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`)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: OpsCMS · FeedSlot(Ops · 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 < 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 · FunnelDefinition(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-039.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AskOperations · HandoffCase(Ops · 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 < 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 Closed(Rhythm 写)
|
||||
> 关联:[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 · ImageCardDeck(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-037.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: CrisisCare · InterventionOutcome(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-032.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · KnowledgeChunk(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-027.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · KnowledgeSource(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-023.yaml`
|
||||
@@ -0,0 +1,77 @@
|
||||
# Feature Spec: 会员套餐 MembershipPlan(Ops · 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 < 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 · ModerationCase(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-030.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AdminGovernance · PrivacyRequest(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-034.yaml`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: QualityFeedback(Ops · 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 1–5;tag ∈ {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 < 500ms |
|
||||
| AC-O-01 | admin 提交写 AuditLog `ask.feedback.create` |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-020.yaml`
|
||||
@@ -0,0 +1,151 @@
|
||||
# Feature Spec: 运营后台 RBAC(Ops · 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);兑换码;CMS;Ask 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 publish(Ops-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 **< 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 | YES(roles / permissions 关联;accounts.role_id;种子 super_admin) |
|
||||
| Packages | `service/admin` · repo · middleware permission check · admin-h5 只读展示权限(最小) |
|
||||
| Parent | WAVE0-FROZEN |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Feature Spec: 兑换码 RedemptionCode(Ops · 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\|disabled;redeemed 不可再兑 |
|
||||
|
||||
## 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 < 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 Closed(Funnel 写)
|
||||
> 关联:[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 · ReportTemplate(Ops · 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 < 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 Closed(StarConfig 写)
|
||||
> 关联:[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 · RhythmConfig(Ops · 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 < 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 list;PATCH 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 · ScaleDefinition(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-040.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: OpsCMS · ScheduledPublication(Ops · 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 < 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 041–042 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 · StarConfig(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-035.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · ToolDefinition(Ops · 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 < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-028.yaml`
|
||||
@@ -0,0 +1,39 @@
|
||||
# Feature Spec: 用户洞察 UserIntelligence(Ops · 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 < 500ms 本机 |
|
||||
| AC-O-01 | N/A 只读(无写审计) |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-016.yaml`
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
**Given** Self 有生日
|
||||
**When** 生成星座报告
|
||||
**Then** 展示轮盘、太阳/月亮/上升、相位速览、fortune 日运与行运;无「占卜」「算命」恐吓
|
||||
**Then** 展示轮盘、太阳/月亮/上升、相位速览、outlook 日/周/月/年/一生与行运;无「占卜」「算命」恐吓
|
||||
|
||||
**Given** 两份档案
|
||||
**When** POST synastry
|
||||
@@ -214,3 +214,5 @@
|
||||
| Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` |
|
||||
| 星历 | Swiss Ephemeris(CGO,默认 Moshier);热带 + Whole Sign |
|
||||
| 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite |
|
||||
| Gaps | ECR-012 收口:报告页复用 StarFortunePanel;分享 type=star;fortuneBundle 仅读 outlook 且剥离 lucky;e2e mock 含 outlook |
|
||||
| ESS | `docs/ECR/ECR-012-star-alignment.md`(**Approved** · L2)· BD-2026-012 Approved |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ __pycache__/
|
||||
.venv/
|
||||
*.log
|
||||
.env
|
||||
deploy.env
|
||||
.DS_Store
|
||||
|
||||
# ESS — machine-local root pointer(仓库内用 scripts 包装器)
|
||||
|
||||
@@ -21,7 +21,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
await ok({
|
||||
token: 'adm_e2e_token',
|
||||
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
|
||||
}
|
||||
@@ -55,7 +70,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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 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 page.locator('input[type="password"]').fill('change-me')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export default defineConfig({
|
||||
@@ -10,7 +11,8 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
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',
|
||||
},
|
||||
projects: [
|
||||
@@ -24,7 +26,7 @@ export default defineConfig({
|
||||
],
|
||||
webServer: {
|
||||
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,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
@@ -31,7 +31,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
headers,
|
||||
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})`
|
||||
: `响应不是 JSON(HTTP ${res.status})`,
|
||||
)
|
||||
}
|
||||
if (!res.ok || env.code !== 0) {
|
||||
throw new Error(env.message || `HTTP ${res.status}`)
|
||||
}
|
||||
@@ -96,7 +106,12 @@ export type UserDetail = {
|
||||
|
||||
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 = {
|
||||
id: string
|
||||
@@ -125,12 +140,61 @@ export const adminApi = {
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||
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'),
|
||||
users: (q = '') =>
|
||||
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
||||
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
||||
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
|
||||
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
|
||||
setUserStatus: (id: string, status: string, reason: string) =>
|
||||
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) =>
|
||||
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
|
||||
grantAskQuota: (id: string, delta: number) =>
|
||||
@@ -145,6 +209,642 @@ export const adminApi = {
|
||||
request<PushJob>('POST', '/push-jobs', body),
|
||||
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
|
||||
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?: {
|
||||
status?: 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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import IcpFooter from '@/components/IcpFooter.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -28,19 +29,37 @@ async function onLogout() {
|
||||
<RouterLink to="/analytics">数据</RouterLink>
|
||||
<RouterLink to="/content">内容</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 v-if="auth.isSuper" to="/pricing">定价</RouterLink>
|
||||
<RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
|
||||
<RouterLink to="/push">推送</RouterLink>
|
||||
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
<div class="main-body">
|
||||
<RouterView />
|
||||
</div>
|
||||
<IcpFooter />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,7 +94,14 @@ nav a.router-link-active {
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.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) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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-026…040 运营只读目录聚合(类型占位,非用户个案;见 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>
|
||||
@@ -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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import IcpFooter from '@/components/IcpFooter.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -46,11 +47,24 @@ async function onSubmit() {
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
<IcpFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 :deep(.field) { text-align: left; }
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,25 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
import UserEntitlementPanel, { type Entitlement } from '@/components/UserEntitlementPanel.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Insight = Awaited<ReturnType<typeof adminApi.userInsight>>
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
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 askDelta = ref(10)
|
||||
const grantMsg = 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() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
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) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} 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() {
|
||||
grantMsg.value = ''
|
||||
try {
|
||||
@@ -49,19 +113,15 @@ async function grantAsk() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan() {
|
||||
banMsg.value = ''
|
||||
async function changeStatus() {
|
||||
statusMsg.value = ''
|
||||
try {
|
||||
if (detail.value?.status === 'banned') {
|
||||
await adminApi.unbanUser(String(route.params.id))
|
||||
banMsg.value = '已解封'
|
||||
} else {
|
||||
await adminApi.banUser(String(route.params.id))
|
||||
banMsg.value = '已封禁'
|
||||
}
|
||||
await adminApi.setUserStatus(String(route.params.id), nextStatus.value, statusReason.value)
|
||||
statusMsg.value = '状态已更新'
|
||||
statusReason.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
banMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
statusMsg.value = e instanceof Error ? e.message : '状态更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,107 +153,196 @@ onMounted(load)
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="detail">
|
||||
<div class="card block">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</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="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">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
</div>
|
||||
<div v-if="canWriteStatus" class="grant">
|
||||
<select v-model="nextStatus">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<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 class="grant">
|
||||
<button class="btn" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '解封' : '封禁' }}
|
||||
</button>
|
||||
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="canGrantMembership" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无会员授予权限</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="canGrantAskQuota" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无问答额度授予权限</p>
|
||||
</div>
|
||||
<p v-else class="muted">仅超级管理员可授予会员</p>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
@@ -203,6 +352,17 @@ onMounted(load)
|
||||
.crumb a { color: var(--accent); }
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
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; }
|
||||
.kv {
|
||||
display: grid;
|
||||
@@ -213,5 +373,19 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.kv .wide { grid-column: 1 / -1; }
|
||||
.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 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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user