diff --git a/.ai/domain/erd.md b/.ai/domain/erd.md index 6333ef8..e2da222 100644 --- a/.ai/domain/erd.md +++ b/.ai/domain/erd.md @@ -202,6 +202,43 @@ | meta | jsonb | | | created_at | timestamptz | 只追加 | +### analytics_sessions(Ops-B · ECR-007) +| column | type | notes | +|---|---|---| +| session_id | text PK | 客户端 session_id | +| device_key | text | | +| user_id | uuid FK null | | +| started_at | timestamptz | | +| ended_at | timestamptz null | | +| exit_page | text null | | +| duration_ms | int null | | + +### analytics_events +| column | type | notes | +|---|---|---| +| id | uuid PK | | +| session_id | text FK→analytics_sessions | | +| user_id | uuid FK null | | +| name | text | page_view / page_leave / … | +| page_path | text null | | +| props | jsonb | 白名单键;无 PII | +| client_ts | timestamptz | | +| received_at | timestamptz | 只追加 | + +### home_tools(Ops-C · ECR-008) +| column | type | notes | +|---|---|---| +| id | uuid PK | | +| row_index | smallint | 1 \| 2 | +| sort_order | int | | +| path | text | 站内相对路径 | +| icon | text | HomeToolIcon 白名单 | +| label | text | ≤16 字 | +| badge | text null | | +| badge_tone | text null | hot \| new | +| enabled | bool | | +| updated_at | timestamptz | | + --- ## Relationship sketch @@ -217,4 +254,7 @@ profiles ── relation_insights ── profiles users 1──* ask_threads 1──* ask_messages admin_accounts 1──* admin_sessions admin_accounts 1──* admin_audit_logs +users 1──* analytics_sessions 1──* analytics_events +home_tools(运营配置宫格) +scales.status published|draft ``` diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 95665a1..1d0027c 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -231,8 +231,10 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。 | 分期 | 内容 | |---|---| -| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5` | -| Phase B+ | 内容配置 · 测评上下架 · 封禁 · RBAC · 推送(后置) | +| 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 D+ | 商业加深 · RBAC · 封禁 · 推送(各开独立 ECR) | 不计入 P1 Complete;不进入五 Tab。 diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 81825ed..4d1fa52 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -6,6 +6,8 @@ |---|---|---|---|---| | [home.md](home.md) | 首页 / 发现入口 | §1 首页 | `/` | P1 | | [profile.md](profile.md) | 个人档案 | §1.1 · §5.1 | `/profile` | P1 | +| [account-auth.md](account-auth.md) | 账号登录 | §5.x | `/login` | P1 | +| [profile-bootstrap.md](profile-bootstrap.md) | 生日包生成 | §1.1 | profiles→reports | P1 | | [portrait.md](portrait.md) | 个人画像 | §2.1 · §2.3 | `/portrait` | P1 | | [relation.md](relation.md) | 关系理解 | §2.4 | `/relation` | P1 | | [explore-test.md](explore-test.md) | 探索测试 | §2.2 | `/explore` · `/scales/:slug` | P1 | @@ -19,6 +21,8 @@ | [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 | | [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 | | [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 Approved | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/account-auth.md b/.ai/product/feature-spec/account-auth.md new file mode 100644 index 0000000..2367c89 --- /dev/null +++ b/.ai/product/feature-spec/account-auth.md @@ -0,0 +1,135 @@ +# Feature Spec: 账号登录 + +> Status: `Active` · Map: `5.x 账号 [P1+]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) +> 关联:[profile-bootstrap.md](profile-bootstrap.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 账号登录(手机号+密码) | +| Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 | +| Business Goal | 数据归属清晰;为会员与深度版付费打底 | + +| In | Out | +|---|---| +| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) | +| 设备身份绑定到已注册账号 | 游客可看完整报告 | +| Bearer Session | 运营 Admin 登录(独立) | + +--- + +## 2. 用户价值 + +1. 换手机后仍能看到自己的档案与报告。 +2. 登录后输入生日一次生成、各页直读。 +3. N/A(登录本身免费)。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 仅设备身份;可打开登录页;**不可**建档出结果 | +| User(已注册) | CRUD 本人档案与 TA;读已生成报告 | +| VIP / DeepAccess | 同前,权益不变 | + +--- + +## 4. 用户流程 + +```text +进入结果页或建档 + ↓ +未登录? → /login 注册或登录 + ↓ +Device 绑定到账号 User + ↓ +进入档案 / 结果页(读缓存报告) +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/login` | LoginPage(注册/登录切换) | +| `/mine` | 展示账号手机号尾号 + 退出 | + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 登录中 | +| Error | 密码错误 / 手机号已注册 | +| Normal | 跳转 redirect 或 /mine | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | **临时开放:** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB | +| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 | +| R3 | 登录成功后:签发 session;device_identities.user_id 改绑到账号 | +| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 | +| R5 | 登出作废 session;设备可再登录其他账号 | +| R6 | 正式环境恢复校验前须改回本 Spec | + +--- + +## 8. 数据模型影响 + +- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` +- `user_sessions(token, user_id, expires_at)` + +--- + +## 9. API 需求 + +| Method | Path | 说明 | +|---|---|---| +| POST | `/api/v1/auth/register` | 注册 | +| POST | `/api/v1/auth/login` | 登录 | +| POST | `/api/v1/auth/logout` | 登出 | +| GET | `/api/v1/auth/me` | 当前账号 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor | User | +|---|---|---| +| register/login | ✓ | ✓ | +| profiles / reports 写读 | ✗ | ✓ | + +--- + +## 11. 埋点 + +`auth_register` · `auth_login` · `auth_logout` + +--- + +## 12. 测试验收标准 + +| Given | When | Then | +|---|---|---| +| 新设备 | 未登录 POST portrait | 401 | +| 注册成功 | 建档 | 200 且生成报告包 | +| 登录另一设备 | list profiles | 见同一账号数据 | + +--- + +## 13. AI 开发前检查 + +- [x] lexicon:登录/注册/账号 +- [x] 不碰 Admin Auth diff --git a/.ai/product/feature-spec/ask.md b/.ai/product/feature-spec/ask.md index 5e92b69..c244f98 100644 --- a/.ai/product/feature-spec/ask.md +++ b/.ai/product/feature-spec/ask.md @@ -17,7 +17,7 @@ |---|---| | 挂 profile 的多轮对话;场景入口 | 占卜/运势/预测未来 | | DeepSeek(可配)+ 规则引擎降级 | 医疗诊断 | -| 免费次数 + 会员配额 | 无档案空聊(禁止) | +| 免费次数 + 额度包(ask_pack)+ 会员配额 | 无档案空聊(禁止) | --- @@ -25,7 +25,7 @@ 1. **为何需要:** 看完画像仍有具体情境问题。 2. **完成后获得:** 结合档案的结构化建议与可执行小步骤。 -3. **为何付费:** 免费次数用尽后会员提供更多回复。 +3. **为何付费:** 免费次数用尽后可购买问答额度包,或开通成长会员获得更多回复。 --- @@ -49,10 +49,10 @@ 选择档案(我 / TA)+ 可选场景 ↓ 输入问题 → 检查配额 - ├─ 耗尽 → 引导会员 - └─ 有余 → 创建/续 thread → assistant 回复 + ├─ 耗尽 → 引导购买额度包 / 成长会员(mock 支付后立刻加次) + └─ 有余 → 创建/续 thread → assistant **SSE 流式**回复(delta → done) ↓ - DeepSeek 失败/无 key → 规则引擎降级 + DeepSeek 失败/无 key → 规则引擎降级(仍按字流式输出) ``` --- @@ -69,7 +69,7 @@ ├── Loading(发送中) ├── Normal(历史 + 输入) ├── Error(发送失败可重试) -└── Quota Exhausted(引导会员) +└── Quota Exhausted(额度包 + 会员) ``` --- @@ -82,7 +82,7 @@ | Loading | 发送中禁用重复点 | | Error | 失败 + 重试 | | Normal | 消息列表 | -| Quota | 明确耗尽文案 + `/membership` | +| Quota | 明确耗尽文案 + 额度包购买 + `/membership` | --- @@ -91,11 +91,12 @@ | ID | Rule | |---|---| | R1 | 每条用户消息必须绑定 `profile_id`(self 或 other) | -| R2 | 免费回复次数有上限(实现:如 3);会员另计配额 | -| R3 | 配额耗尽返回明确业务错误,不生成付费假回复 | +| R2 | 免费回复次数有上限(实现:如 3);可购 `ask_pack` 加次;会员另计配额 | +| R3 | 配额耗尽返回明确业务错误,不生成付费假回复;引导购买额度或会员 | +| R7 | 扣次顺序:会员配额 → 已购额度包 → 免费额度(非会员);购买额度不解锁报告深度版 | | R4 | LLM 可选;失败降级规则引擎,仍须 lexicon 安全 | -| R5 | 禁止运势/吉凶/算命/合盘话术;结尾免责声明 | -| R6 | 回复宜有结构:回应用户 → 结合档案 → 可执行建议(详见 system prompt) | +| R5 | 禁止占卜/算命/吉凶恐吓/医疗诊断;可用愈心解码·星座·人格匹配等探索向用语 | +| R6 | 回复宜短(约 80–160 字):回应当下 → 1 条档案洞察 → 1–2 条可执行建议;system prompt 须对齐 lexicon 与产品定位 | --- @@ -106,6 +107,7 @@ | `ask_threads` | user_id, profile_id | | `ask_messages` | role, content | | `ask_quotas` / 等价 | 余量 | +| `users.ask_paid_quota_left` | 已购额度包余量 | --- @@ -115,7 +117,11 @@ |---|---|---| | GET | `/api/v1/ask/quota` | 余量 | | POST | `/api/v1/ask/threads` | 创建线程(profile_id, scene?) | -| POST | `/api/v1/ask/threads/{id}/messages` | 发消息拿回复 | +| POST | `/api/v1/ask/threads/{id}/messages` | 发消息拿回复(默认 JSON) | +| POST | `/api/v1/ask/threads/{id}/messages?stream=1` | SSE:`meta` / `delta` / `done` / `error` | +| DELETE | `/api/v1/ask/threads/{id}` | 清空该会话(软删除) | +| POST | `/api/v1/orders` kind=`ask_pack` | 购买额度包(plan: pack10/pack30/pack100) | +| POST | `/api/v1/orders/{id}/pay-mock` | 支付后增加 `ask_paid_quota_left` | --- @@ -147,7 +153,11 @@ **Given** 配额为 0 **When** 再发送 -**Then** 业务错误引导会员,无助手胡编 +**Then** 业务错误引导购买额度/会员,无助手胡编 + +**Given** 免费额度耗尽 +**When** pay-mock `ask_pack` pack10 +**Then** GET `/ask/quota` remaining ≥ 10,可继续提问 **Given** 无 DeepSeek key **When** 提问 diff --git a/.ai/product/feature-spec/membership.md b/.ai/product/feature-spec/membership.md index 5f36124..8c0ff38 100644 --- a/.ai/product/feature-spec/membership.md +++ b/.ai/product/feature-spec/membership.md @@ -17,6 +17,7 @@ |---|---| | 单报告 DeepAccess | 广告干扰主路径 | | 订阅 Membership(月/季/年 mock) | 塔罗商城 | +| 问答额度包 ask_pack(mock) | 与会员深度权益混绑(额度包不加深度版) | | Server 侧权益生效 | 纯前端「解锁」 | --- @@ -103,7 +104,8 @@ pay-mock → memberships 行 active + expires_at | R2 | Membership active:`expires_at > now` | | R3 | 报告 detail 可见 ⇔ Membership active **OR** DeepAccess 存在 | | R4 | 支付 P1 仅 mock;成功必须服务端落库后再改可见性 | -| R5 | Ask 配额:免费次数耗尽后依赖会员配额(见 ask Spec) | +| R5 | Ask 配额:免费次数耗尽后可购 ask_pack 或依赖会员配额(见 ask Spec) | +| R8 | `ask_pack`:plan=`pack10|pack30|pack100` → 增加 `users.ask_paid_quota_left`;不改变 membership / deep_access | | R6 | 订单归属必须校验 report 属于当前用户 | | R7 | 文案:成长会员 / 深度版;禁用恐吓与疗效承诺 | @@ -125,7 +127,7 @@ pay-mock → memberships 行 active + expires_at | Method | Path | 意图 | |---|---|---| | GET | `/api/v1/membership/me` | 当前会员状态 | -| POST | `/api/v1/orders` | deep_access \| membership | +| POST | `/api/v1/orders` | deep_access \| membership \| ask_pack | | POST | `/api/v1/orders/{id}/pay-mock` | 生效权益 | --- diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index 15da445..9e80f88 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -128,9 +128,11 @@ AdminShell | POST | `/auth/login` | 登录,返回 token + account | | POST | `/auth/logout` | 注销当前会话 | | GET | `/me` | 当前管理员 | -| GET | `/users` | 列表 `?q=&limit=&offset=` | -| GET | `/users/:id` | 详情(含 membership · profiles 摘要 · 近订单) | +| GET | `/stats` | 仪表盘概览计数 | +| GET | `/users` | 列表 `?q=` 支持 UUID / 手机 / 昵称 | +| GET | `/users/:id` | 详情(档案 · 报告 · 会员 · 问答额度 · 近订单) | | POST | `/users/:id/membership/grant` | 授予/延长会员 | +| POST | `/users/:id/ask-quota/grant` | 增加已购问答额度 `{delta}` | | GET | `/orders` | 订单列表 | | GET | `/audit-logs` | 审计列表 | @@ -204,5 +206,7 @@ Phase A 可先 `console`/本地;不挡验收。 | Phase | 内容 | |---|---| | **A(本 ECR)** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 | -| B | 内容配置 / 测评上下架 / 封禁注销 | -| C | RBAC · 推送 · 优惠券 · 退款工单 | +| **B(ECR-007)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` | +| **C(ECR-008)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md`(Approved) | +| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | +| E | RBAC · 封禁 · 推送占位(另开 ECR) | diff --git a/.ai/product/feature-spec/ops-analytics.md b/.ai/product/feature-spec/ops-analytics.md new file mode 100644 index 0000000..226d5eb --- /dev/null +++ b/.ai/product/feature-spec/ops-analytics.md @@ -0,0 +1,156 @@ +# 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 +> 关联:[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) + +--- + +## ESS 门禁 + +按本仓 `docs/PROJECT_RULES.md` + ESS `CHANGE_MANAGEMENT.md`: + +1. Change Level = **L2** → ECR Approved + Feature Spec Active + BACKEND_DESIGN Approved — **已满足(2026-08-07)** +2. 实现顺序以 `docs/ENGINEERING_SPEC/ECR-007-ops-analytics.md` 为准 +3. 完成后:TEST_REPORT · CODE_REVIEW · `ess-validate --phase review` · DoD Review Report + +(与 ECR/BACKEND_DESIGN 冲突时以已 Approved 的 ESS 工件为准。) + +--- + +## 1. 目标 + +按**传统 App 运营**模式: + +1. **采集**:页浏览、点击、停留时长、会话退出页等 +2. **落库**:自有管道(可与 GA4 双写) +3. **展示**:管理端「数据」看板(DAU、页面、退出页、漏斗) +4. **分期**:先 Ops-B 分析,再内容 / 商业 / 权限全量模块 + +--- + +## 2. 分期总览 + +| 分期 | 主题 | 交付 | +|---|---|---| +| Ops-A | 已完成 | 登录 · 用户 · 订单 · 会员授予 · 审计 · 概览图 | +| **Ops-B** | **行为分析** | 自有埋点 + 管理端「数据」 | +| Ops-C | 内容运营 | 首页宫格/工具配置 · 测评上下架 | +| Ops-D | 商业加深 | 订单筛选 · 套餐价配置 · 退款只读 | +| Ops-E | 系统 | RBAC · 封禁 · 推送/站内信占位 | + +UGC / 达人 / 真支付网关:**更远期**,不在本 Spec 承诺范围。 + +--- + +## 3. Ops-B 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | App 行为分析(采集 + 运营看板) | +| Purpose | 用自有数据回答:谁来了、看了哪、停多久、从哪走、点了什么、漏斗掉哪 | +| Business Goal | 替代「只能看 GA / 改库」;支撑日常运营决策 | + +| In | Out | +|---|---| +| session / page_view / page_leave(dwell) / ui_click / session_end(exit_page) | 热力图坐标级精确点击 | +| 漏斗事件写入同一管道 | 实时毫秒大屏 | +| Admin「数据」页聚合 | 用户轨迹录像 | + +--- + +## 4. 采集字典(客户端) + +扩展 `apps/user-h5/src/lib/analytics.ts`:通道 A = GA4(可选);通道 B = 自有 `POST /api/v1/analytics/events`。 + +| Event | 触发 | 关键 props | +|---|---|---| +| `session_start` | 冷启动或可见性恢复(节流) | `session_id`, `cold`, `app_ver` | +| `page_view` | `router.afterEach` | `page_path`, `page_title`, `referrer_path` | +| `page_leave` | 路由离开 / `visibilitychange=hidden` / `pagehide` | `page_path`, **`dwell_ms`** | +| `ui_click` | `data-track` 或白名单委托 | `page_path`, `element_id` | +| `session_end` | 超时 / 关页(`sendBeacon`) | `session_id`, `duration_ms`, **`exit_page`** | +| 既有漏斗 | 保持 | `home_cta_portrait` 等 | + +规则: + +- 批量上报(5–10s)+ 退出冲刷 +- `session_id`:`sessionStorage`;30min 无互动新开 +- **禁止**生日、问答正文、手机号、完整档案 ID 明文 + +--- + +## 5. 数据模型 + +```text +analytics_sessions + id, device_key, user_id?, started_at, ended_at, exit_page, duration_ms + +analytics_events + id, session_id, user_id?, name, page_path, props jsonb, client_ts, received_at + +analytics_daily_page(可选汇总) + day, page_path, pv, uv, dwell_sum_ms, exit_count +``` + +--- + +## 6. API + +| Method | Path | 意图 | +|---|---|---| +| POST | `/api/v1/analytics/events` | 批量 ingest(DeviceAuth,限流) | +| GET | `/api/v1/admin/analytics/overview` | DAU / 会话 / 均时长 | +| GET | `/api/v1/admin/analytics/pages` | 页 PV/UV/avg_dwell/exit | +| GET | `/api/v1/admin/analytics/exits` | 退出页 TOP | +| GET | `/api/v1/admin/analytics/clicks` | 点击 TOP | +| GET | `/api/v1/admin/analytics/funnel` | 漏斗计数 | + +查询均带 `from` / `to`(日)。 + +--- + +## 7. 管理端「数据」页 + +侧栏:概览 · **数据** · 用户 · 订单 · 审计 + +- 时间筛选:今日 / 7 日 / 30 日 +- 卡片:DAU、新增、会话数、人均会话时长 +- 图:日活趋势;页面均停留 TOP +- 表:页面明细、退出页 TOP、点击 TOP +- 漏斗:画像完成 → 深度 CTA → 支付成功 + +视觉:愈心谷色系;品牌仅 logo,无「运营后台」字样。 + +--- + +## 8. Ops-C~E 边界(后置) + +| 分期 | In | +|---|---| +| Ops-C | 首页宫格/工具 CRUD;scales 上下架 | +| Ops-D | 订单多维筛选;membership 展示价;退款状态只读 | +| Ops-E | 用户封禁;admin 角色;推送任务占位 | + +--- + +## 9. Ops-B 验收 + +**Given** H5 打开并切换两页后离开 +**When** ingest 成功 +**Then** 存在 `page_view`×2、`page_leave`(含 dwell_ms)、`session_end.exit_page` + +**Given** Admin 登录「数据」近 7 日 +**When** GET overview/pages/exits +**Then** 返回非空聚合且与事件量级一致 + +--- + +## 10. 实施顺序 + +1. 本 Spec 置 Active + 改 feature-map §7 / ops-admin 分期 +2. Migration + ingest API +3. Admin 聚合 API +4. H5 SDK(会话/停留/点击/beacon) +5. admin-h5「数据」页 +6. 测试与部署 diff --git a/.ai/product/feature-spec/ops-content.md b/.ai/product/feature-spec/ops-content.md new file mode 100644 index 0000000..3e4e4da --- /dev/null +++ b/.ai/product/feature-spec/ops-content.md @@ -0,0 +1,92 @@ +# 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 +> 关联:[ops-admin.md](ops-admin.md) · [ops-analytics.md](ops-analytics.md) · [home.md](home.md) · [explore-test.md](explore-test.md) + +--- + +## ESS 门禁 + +1. Change Level = **L2** → ECR Approved + Feature Spec Active + BACKEND_DESIGN Approved — **已满足(2026-08-07)** +2. 实现顺序以 `docs/ENGINEERING_SPEC/ECR-008-ops-content.md` 为准 +3. 完成后:TEST_REPORT · CODE_REVIEW · `ess-validate --phase review` + +--- + +## 1. 目标 + +让运营在后台配置: + +1. **首页宫格工具**:增删改排序、显隐、角标(替代写死 `homeCatalog.ts`) +2. **测评上下架**:`scales.status` = `published` / `draft`,C 端列表只见已发布 + +--- + +## 2. In / Out + +| In | Out | +|---|---| +| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds 信息流 CMS(本 ECR 后置,仍用静态 `homeFeeds`) | +| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 | +| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS | +| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E) | + +--- + +## 3. 数据模型 + +```text +home_tools + id, row_index (1|2), sort_order, path, icon, label, + badge?, badge_tone? (hot|new|null), enabled, updated_at + +scales(已有) + status: published | draft +``` + +种子:与当前 `homeGridRow1/2` 一致。 + +--- + +## 4. API + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/home/tools` | C 端宫格(仅 enabled;DeviceAuth) | +| GET | `/api/v1/admin/home/tools` | 管理端全量 | +| PUT | `/api/v1/admin/home/tools` | 整表替换保存(排序+字段)+ 审计 | +| GET | `/api/v1/admin/scales` | 全部量表(含 draft) | +| PATCH | `/api/v1/admin/scales/:id` | `{ status }` + 审计 | + +C 端既有 `GET /scales` 继续只返回 published。 + +--- + +## 5. 管理端「内容」页 + +侧栏:概览 · 数据 · **内容** · 用户 · 订单 · 审计 + +- Tab「首页宫格」:两行列表、拖拽/上下移、编辑 label/path/icon/badge、启用开关、保存 +- Tab「测评」:列表 · 上架/下架 + +--- + +## 6. C 端行为 + +- `useHomePage` 优先拉 `GET /home/tools`;失败回退静态 `homeCatalog` +- 下架 scale:列表不可见;直链 slug 返回 404/不可用(与现 Get 行为对齐) + +--- + +## 7. 验收 + +**Given** Admin 将某宫格 disabled 并保存 +**When** H5 刷新首页 +**Then** 该项不出现 + +**Given** Admin 将 `mbti-lite` 置 draft +**When** GET `/scales` +**Then** 列表不含该 slug + +--- diff --git a/.ai/product/feature-spec/profile-bootstrap.md b/.ai/product/feature-spec/profile-bootstrap.md new file mode 100644 index 0000000..2d4cd38 --- /dev/null +++ b/.ai/product/feature-spec/profile-bootstrap.md @@ -0,0 +1,129 @@ +# Feature Spec: 生日包生成(档案引导) + +> Status: `Active` · Map: `1.1 个人档案` · Phase: `P1` +> 关联:[account-auth.md](account-auth.md) · [profile.md](profile.md) · [portrait.md](portrait.md) · [star-profile.md](star-profile.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 生日衍生数据一次生成与覆盖 | +| Purpose | 输入/修改生日后服务端一次性生成并落库;结果页优先读缓存 | +| Business Goal | 降低重复输入;保证本人与 TA 数据归属同一账号 | + +| In | Out | +|---|---| +| Self / Other 档案创建与生日变更触发 | 无档案凭空出合盘 | +| 单人:portrait · star · rhythm | 意象卡片抽牌(配额玩法,不预生成) | +| 有 Self+Other:relation + synastry 成对覆盖 | 附近的人合盘(进页再算) | + +--- + +## 2. 用户价值 + +1. 填一次生日,解码/星座/节律可直接看。 +2. 添加的 TA 挂在账号下;与本人关系/合盘一并备好。 +3. 改生日自动覆盖旧结果。 + +--- + +## 3. 用户角色 + +仅 **已登录 User**。Visitor 被鉴权拦截。 + +--- + +## 4. 用户流程 + +```text +登录 → 创建/编辑档案(含生日) + ↓ +Server GenerateBundle(profile) + ├─ 单人报告:portrait / star / rhythm(同 type 软删旧 → 新建) + └─ 若存在配对:self×each other → relation + synastry 覆盖 + ↓ +结果页:GET latest by profile+type(无则引导档案,不强制再填生日) +``` + +--- + +## 5. 页面设计 + +| 路由 | 行为变更 | +|---|---| +| `/portrait` `/star` `/rhythm` | 有 Self → 读 latest;无 → 引导 `/profile` 或登录 | +| `/relation` `/synastry` | 选已有 Self+TA → 读 latest pair;缺则引导加档案 | +| `/profile` | 创建/改生日后展示「已生成」提示 | + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 生成中/拉取中 | +| Empty | 无档案 → 去建档;未登录 → 去登录 | +| Normal | 展示缓存报告 | +| Error | 重试 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 仅登录用户可写档案 | +| R2 | Create/Update 若 birth_date(或 time/place)相对旧值变化 → GenerateBundle | +| R3 | 覆盖=同 user + profile + type(+ peer)软删旧报告再插入 | +| R4 | Other 档案 `user_id` 必须=当前账号;删除 Other 时软删其单人报告及含其 peer 的配对报告 | +| R5 | 结果页不得再要求重复输入已知生日,除非用户主动改档案 | + +--- + +## 8. 数据模型影响 + +- `growth_reports.peer_profile_id` nullable(配对报告) +- 索引便于 latest 查询 + +--- + +## 9. API 需求 + +| Method | Path | 说明 | +|---|---|---| +| POST/PATCH | `/profiles` | 成功后服务端同步 GenerateBundle | +| GET | `/reports/latest` | `profile_id` · `type` · 可选 `peer_profile_id` | + +现有 POST `/reports/*` 保留(幂等覆盖),供重算;主路径以 latest 为准。 + +--- + +## 10. 权限设计 + +同 account-auth:必须 User。 + +--- + +## 11. 埋点 + +`profile_bootstrap_done` · `report_cache_hit` + +--- + +## 12. 测试验收标准 + +| Given | When | Then | +|---|---|---| +| 登录用户建 Self | POST profile | DB 有 portrait/star/rhythm | +| 再加 Other | POST other | 有 relation+synastry 相对 Self | +| 改 Self 生日 | PATCH | 旧报告 soft-deleted,新报告可读 | +| 登录用户开 /portrait | GET latest | 无需再填生日 | + +--- + +## 13. AI 开发前检查 + +- [x] 合盘不造假 TA +- [x] lexicon:愈心解码 / 星座 / 合盘 / 个人档案 diff --git a/.ai/product/lexicon.md b/.ai/product/lexicon.md index 19f6c38..72d0eac 100644 --- a/.ai/product/lexicon.md +++ b/.ai/product/lexicon.md @@ -64,6 +64,7 @@ | Synastry | 合盘 | 五主盘+推运;与人格匹配分流 | | LifeRhythm | 身心节律 | | | ImageCard | 意象卡片 | 可并列塔罗别名 | +| Account / Auth | 账号 / 登录 / 注册 | 手机号+密码;未登录不可出结果 | **Code aliases(可用):** Decode≈Portrait · Match≈RelationInsight · Zodiac≈StarProfile diff --git a/.ai/product/page-tree.md b/.ai/product/page-tree.md index dbc40e0..25844de 100644 --- a/.ai/product/page-tree.md +++ b/.ai/product/page-tree.md @@ -16,6 +16,7 @@ IA Tab 冻结:首页 · 探索 · 问答 · 陪伴 · 我的 /ask AskPage AI成长助手 /companion CompanionPage 节气生活 · 心情记录 /mine MinePage 资产总览 → profile/reports/membership +/login LoginPage 注册/登录 /profile ProfilePage 个人档案管理 /portrait PortraitPage 个人画像(基础/深度版) /relation RelationPage 关系理解 diff --git a/.gitignore b/.gitignore index 7e41e54..c47ec46 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ tools/android-adb-docs # OS Thumbs.db .gstack/ + +# Local secrets (never commit) +apps/api/config.local.yaml diff --git a/admin_server.py b/admin_server.py new file mode 100644 index 0000000..4e64187 --- /dev/null +++ b/admin_server.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Serve admin-h5 dist for /psy/admin (nginx strips prefix → this listens on :8003).""" +from __future__ import annotations + +import mimetypes +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +DIST = ROOT / "apps" / "admin-h5" / "dist" +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8003 + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args) -> None: + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def do_GET(self) -> None: # noqa: N802 + path = self.path.split("?", 1)[0] + if path in ("", "/"): + path = "/index.html" + # security: no path escape + rel = path.lstrip("/") + candidate = (DIST / rel).resolve() + if not str(candidate).startswith(str(DIST.resolve())): + self.send_error(400) + return + if candidate.is_file(): + self._file(candidate) + return + # hashed asset miss → 404 (not SPA), so MIME errors are obvious + if "/assets/" in path or path.endswith((".js", ".css", ".map", ".png", ".svg", ".ico", ".woff2")): + self.send_error(404) + return + index = DIST / "index.html" + if index.is_file(): + self._file(index) + return + self.send_error(404) + + def _file(self, file_path: Path) -> None: + data = file_path.read_bytes() + ctype, _ = mimetypes.guess_type(str(file_path)) + self.send_response(200) + self.send_header("Content-Type", ctype or "application/octet-stream") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache" if file_path.name == "index.html" else "public, max-age=86400") + self.end_headers() + self.wfile.write(data) + + +def main() -> None: + if not DIST.is_dir(): + raise SystemExit(f"admin dist missing: {DIST} (run npm run build:admin)") + httpd = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) + print(f"admin-h5 on http://127.0.0.1:{PORT}/ (nginx → /psy/admin/)", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/apps/admin-h5/README.md b/apps/admin-h5/README.md index e5d1d79..6bcb012 100644 --- a/apps/admin-h5/README.md +++ b/apps/admin-h5/README.md @@ -9,4 +9,49 @@ npm run dev:admin # http://localhost:5174/ ``` +生产路径:`https://jackyu66.com/psy/admin/`(静态 `:8003` + 共用 `/psy/api/` → Go `:8080`)。 + +## Nginx(需 root 编辑并 reload) + +在 `jackyu66.com` 的 HTTPS server 里,**放在** `location /psy/` **之前**增加: + +```nginx +# 运营后台静态(user systemd: yuxingu-admin → 127.0.0.1:8003) +location /psy/admin/ { + proxy_pass http://127.0.0.1:8003/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +# 问答 SSE:关闭缓冲(建议一并改现有 /psy/api/) +location /psy/api/ { + proxy_pass http://127.0.0.1:8080/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 120s; +} +``` + +然后: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +本机已可用(无需 sudo): + +```bash +npm run build:admin +systemctl --user enable --now yuxingu-admin.service +# 校验:curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/ +``` + 默认管理员:见 `apps/api/config.example.yaml` → `admin.bootstrap_*`(仅空库种子)。 diff --git a/apps/admin-h5/e2e/admin-ops-smoke.spec.ts b/apps/admin-h5/e2e/admin-ops-smoke.spec.ts index 4bed755..cd86159 100644 --- a/apps/admin-h5/e2e/admin-ops-smoke.spec.ts +++ b/apps/admin-h5/e2e/admin-ops-smoke.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test' -/** Admin Phase A: login → users → detail grant → audit (API mocked). */ +/** Admin ops console: login → dashboard → users → detail grant → audit (API mocked). */ test('admin login users grant and audit with mocked API', async ({ page }) => { const userId = '11111111-1111-1111-1111-111111111111' let granted = false @@ -29,10 +29,39 @@ test('admin login users grant and audit with mocked API', async ({ page }) => { await ok({ ok: true }) return } + if (url.includes('/stats')) { + await ok({ + users_total: 1, + membership_active: granted ? 1 : 0, + orders_today: 0, + paid_cents_today: 0, + ask_replies_today: 0, + profiles_total: 1, + reports_total: 2, + series: [ + { day: '2026-08-01', new_users: 0, orders: 1, paid_cents: 990, ask_replies: 2 }, + { day: '2026-08-02', new_users: 1, orders: 0, paid_cents: 0, ask_replies: 1 }, + { day: '2026-08-03', new_users: 0, orders: 2, paid_cents: 1980, ask_replies: 3 }, + { day: '2026-08-04', new_users: 0, orders: 0, paid_cents: 0, ask_replies: 0 }, + { day: '2026-08-05', new_users: 0, orders: 1, paid_cents: 2500, ask_replies: 4 }, + { day: '2026-08-06', new_users: 0, orders: 0, paid_cents: 0, ask_replies: 2 }, + { day: '2026-08-07', new_users: 0, orders: 1, paid_cents: 990, ask_replies: 1 }, + ], + reports_by_type: [ + { type: 'portrait', count: 1 }, + { type: 'star', count: 1 }, + ], + }) + return + } if (url.endsWith('/me') || url.includes('/me?')) { await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' }) return } + if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') { + await ok({ ok: true, ask_paid_quota_left: 40 }) + return + } if (url.includes(`/users/${userId}/membership/grant`) && method === 'POST') { granted = true await ok({ ok: true }) @@ -42,13 +71,18 @@ test('admin login users grant and audit with mocked API', async ({ page }) => { await ok({ id: userId, status: 'active', + phone: '13800000000', + nickname: '测试用户', + ask_paid_quota_left: 0, created_at: '2026-08-01T00:00:00Z', - profiles: [{ id: 'p1', relation: 'self', display_name: '我' }], + profiles: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }], + reports: [], membership: { active: granted, plan: granted ? 'month' : undefined, status: granted ? 'active' : 'none', expires_at: granted ? '2026-09-01T00:00:00Z' : undefined, + ask_quota_left: granted ? 100 : 0, }, recent_orders: [], }) @@ -56,7 +90,19 @@ test('admin login users grant and audit with mocked API', async ({ page }) => { } if (url.includes('/users') && method === 'GET') { await ok({ - items: [{ id: userId, status: 'active', created_at: '2026-08-01T00:00:00Z' }], + items: [ + { + id: userId, + status: 'active', + phone: '13800000000', + nickname: '测试用户', + ask_paid_quota_left: 0, + profile_count: 1, + membership_active: granted, + membership_plan: granted ? 'month' : null, + created_at: '2026-08-01T00:00:00Z', + }, + ], }) return } @@ -86,17 +132,20 @@ test('admin login users grant and audit with mocked API', async ({ page }) => { }) await page.goto('/login') - await expect(page.getByRole('heading', { name: '运营后台' })).toBeVisible() + await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible() await page.locator('input[type="password"]').fill('change-me') await page.getByRole('button', { name: '登录' }).click() + await expect(page.getByRole('heading', { name: '概览' })).toBeVisible() + await page.getByRole('link', { name: '用户', exact: true }).click() + await expect(page.getByRole('heading', { name: '用户' })).toBeVisible() - await expect(page.getByText(userId)).toBeVisible() - await page.getByRole('link', { name: userId }).click() + await expect(page.getByText('测试用户')).toBeVisible() + await page.getByRole('link', { name: '测试用户' }).click() await expect(page.getByRole('heading', { name: '用户详情' })).toBeVisible() await page.getByRole('button', { name: '授予 / 延长' }).click() - await expect(page.getByText('已授予')).toBeVisible() + await expect(page.getByText('会员已授予')).toBeVisible() await page.getByRole('link', { name: '审计' }).click() await expect(page.getByRole('heading', { name: '审计' })).toBeVisible() diff --git a/apps/admin-h5/index.html b/apps/admin-h5/index.html index 4db323f..b02a58c 100644 --- a/apps/admin-h5/index.html +++ b/apps/admin-h5/index.html @@ -3,7 +3,7 @@ - 愈心谷 · 运营后台 + 愈心谷
diff --git a/apps/admin-h5/package.json b/apps/admin-h5/package.json index d43fb66..88304b1 100644 --- a/apps/admin-h5/package.json +++ b/apps/admin-h5/package.json @@ -6,7 +6,7 @@ "description": "运营后台 Phase A(ECR-006)", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "build": "vue-tsc --noEmit && vite build --base=/psy/admin/", "typecheck": "vue-tsc --noEmit", "preview": "vite preview", "test:e2e": "playwright test" diff --git a/apps/admin-h5/public/logo.png b/apps/admin-h5/public/logo.png new file mode 100644 index 0000000..7ec701b Binary files /dev/null and b/apps/admin-h5/public/logo.png differ diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index fd0e7fc..d8237c2 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -4,6 +4,14 @@ export type ApiEnvelope = { code: number; message: string; data?: T } const TOKEN_KEY = 'yuxingu_admin_token' +/** Under /psy/admin use /psy/api; local vite uses /api. */ +function adminAPIBase(): string { + if (typeof location !== 'undefined' && location.pathname.startsWith('/psy/admin')) { + return '/psy/api/v1/admin' + } + return '/api/v1/admin' +} + export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } @@ -18,7 +26,7 @@ async function request(method: string, path: string, body?: unknown): Promise if (body !== undefined) headers['Content-Type'] = 'application/json' const token = getToken() if (token) headers.Authorization = `Bearer ${token}` - const res = await fetch(`/api/v1/admin${path}`, { + const res = await fetch(`${adminAPIBase()}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), @@ -30,6 +38,62 @@ async function request(method: string, path: string, body?: unknown): Promise return env.data as T } +export type DashboardStats = { + users_total: number + membership_active: number + orders_today: number + paid_cents_today: number + ask_replies_today: number + profiles_total: number + reports_total: number + series?: Array<{ + day: string + new_users: number + orders: number + paid_cents: number + ask_replies: number + }> + reports_by_type?: Array<{ type: string; count: number }> +} + +export type UserListItem = { + id: string + status: string + phone?: string | null + nickname?: string | null + ask_paid_quota_left: number + profile_count: number + membership_active: boolean + membership_plan?: string | null + created_at: string +} + +export type UserDetail = { + id: string + status: string + phone?: string | null + nickname?: string | null + ask_paid_quota_left: number + created_at: string + profiles: Array<{ id: string; relation: string; display_name: string; birth_date?: string }> + reports: Array<{ id: string; type: string; created_at: string }> + membership: { + active: boolean + plan?: string + status: string + expires_at?: string | null + ask_quota_left?: number + } + recent_orders: Array<{ + id: string + kind: string + plan?: string + status: string + amount_cents: number + created_at: string + }> +} + export const adminApi = { login: (username: string, password: string) => request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', { @@ -38,14 +102,16 @@ export const adminApi = { }), logout: () => request<{ ok: boolean }>('POST', '/auth/logout'), me: () => request<{ id: string; username: string }>('GET', '/me'), + stats: () => request('GET', '/stats'), users: (q = '') => - request<{ items: Array<{ id: string; status: string; created_at: string }> }>( - 'GET', - `/users?q=${encodeURIComponent(q)}`, - ), + request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`), user: (id: string) => request('GET', `/users/${id}`), grant: (id: string, plan: string) => request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }), + grantAskQuota: (id: string, delta: number) => + request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, { + delta, + }), orders: () => request<{ items: Array<{ @@ -70,26 +136,60 @@ export const adminApi = { created_at: string }> }>('GET', '/audit-logs'), + analyticsOverview: (from: string, to: string) => + request('GET', `/analytics/overview?from=${from}&to=${to}`), + analyticsPages: (from: string, to: string) => + request<{ items: AnalyticsPageRow[] }>('GET', `/analytics/pages?from=${from}&to=${to}`), + analyticsExits: (from: string, to: string) => + request<{ items: AnalyticsExitRow[] }>('GET', `/analytics/exits?from=${from}&to=${to}`), + analyticsClicks: (from: string, to: string) => + request<{ items: AnalyticsClickRow[] }>('GET', `/analytics/clicks?from=${from}&to=${to}`), + analyticsFunnel: (from: string, to: string) => + request<{ steps: AnalyticsFunnelStep[] }>('GET', `/analytics/funnel?from=${from}&to=${to}`), + homeTools: () => request<{ items: HomeToolAdmin[] }>('GET', '/home/tools'), + saveHomeTools: (items: HomeToolAdmin[]) => + request<{ ok: boolean }>('PUT', '/home/tools', { items }), + scales: () => request<{ items: ScaleAdmin[] }>('GET', '/scales'), + patchScale: (id: string, status: 'published' | 'draft') => + request<{ ok: boolean }>('PATCH', `/scales/${id}`, { status }), } -export type UserDetail = { - id: string - status: string - created_at: string - profiles: Array<{ id: string; relation: string; display_name: string }> - membership: { - active: boolean - plan?: string - status: string - expires_at?: string - ask_quota_left?: number - } - recent_orders: Array<{ - id: string - kind: string - plan?: string - status: string - amount_cents: number - created_at: string - }> +export type HomeToolAdmin = { + id?: string + row_index: number + sort_order: number + path: string + icon: string + label: string + badge?: string | null + badge_tone?: string | null + enabled: boolean } + +export type ScaleAdmin = { + id: string + slug: string + title: string + description: string + status: string +} + +export type AnalyticsOverview = { + dau: number + new_users: number + sessions: number + avg_session_ms: number + series?: Array<{ day: string; dau: number; sessions: number }> +} + +export type AnalyticsPageRow = { + page_path: string + pv: number + uv: number + avg_dwell_ms: number + exit_count: number +} + +export type AnalyticsExitRow = { exit_page: string; count: number } +export type AnalyticsClickRow = { element_id: string; count: number } +export type AnalyticsFunnelStep = { name: string; count: number } diff --git a/apps/admin-h5/src/components/BrandLogo.vue b/apps/admin-h5/src/components/BrandLogo.vue new file mode 100644 index 0000000..3430687 --- /dev/null +++ b/apps/admin-h5/src/components/BrandLogo.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 9316ee3..e945bf6 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -1,6 +1,7 @@ + + + + diff --git a/apps/admin-h5/src/pages/ContentPage.vue b/apps/admin-h5/src/pages/ContentPage.vue new file mode 100644 index 0000000..2f124fa --- /dev/null +++ b/apps/admin-h5/src/pages/ContentPage.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/apps/admin-h5/src/pages/DashboardPage.vue b/apps/admin-h5/src/pages/DashboardPage.vue new file mode 100644 index 0000000..9fded53 --- /dev/null +++ b/apps/admin-h5/src/pages/DashboardPage.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/apps/admin-h5/src/pages/LoginPage.vue b/apps/admin-h5/src/pages/LoginPage.vue index a75c299..d40bb50 100644 --- a/apps/admin-h5/src/pages/LoginPage.vue +++ b/apps/admin-h5/src/pages/LoginPage.vue @@ -1,6 +1,7 @@