From 7ab9add5dd14a766193e1ab8a56b7623f1baae4e Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 02:26:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20ECR-007=20=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E5=88=86=E6=9E=90=E4=B8=8E=20ECR-008=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor --- .ai/domain/erd.md | 40 ++ .ai/product/feature-map.md | 6 +- .ai/product/feature-spec/README.md | 4 + .ai/product/feature-spec/account-auth.md | 135 +++++++ .ai/product/feature-spec/ask.md | 36 +- .ai/product/feature-spec/membership.md | 6 +- .ai/product/feature-spec/ops-admin.md | 12 +- .ai/product/feature-spec/ops-analytics.md | 156 ++++++++ .ai/product/feature-spec/ops-content.md | 92 +++++ .ai/product/feature-spec/profile-bootstrap.md | 129 +++++++ .ai/product/lexicon.md | 1 + .ai/product/page-tree.md | 1 + .gitignore | 3 + admin_server.py | 63 +++ apps/admin-h5/README.md | 45 +++ apps/admin-h5/e2e/admin-ops-smoke.spec.ts | 63 ++- apps/admin-h5/index.html | 2 +- apps/admin-h5/package.json | 2 +- apps/admin-h5/public/logo.png | Bin 0 -> 37786 bytes apps/admin-h5/src/api/client.ts | 150 ++++++-- apps/admin-h5/src/components/BrandLogo.vue | 55 +++ apps/admin-h5/src/layouts/AdminShell.vue | 39 +- apps/admin-h5/src/pages/AnalyticsPage.vue | 243 ++++++++++++ apps/admin-h5/src/pages/ContentPage.vue | 200 ++++++++++ apps/admin-h5/src/pages/DashboardPage.vue | 340 ++++++++++++++++ apps/admin-h5/src/pages/LoginPage.vue | 11 +- apps/admin-h5/src/pages/UserDetailPage.vue | 129 ++++++- apps/admin-h5/src/pages/UsersPage.vue | 94 ++++- apps/admin-h5/src/router/index.ts | 7 +- apps/admin-h5/src/styles.css | 72 +++- apps/admin-h5/vite.config.ts | 8 +- apps/api/internal/ask/reply.go | 5 +- apps/api/internal/ask/reply_test.go | 4 +- apps/api/internal/handler/admin.go | 146 ++++++- apps/api/internal/handler/admin_content.go | 96 +++++ apps/api/internal/handler/analytics.go | 57 +++ apps/api/internal/handler/ask.go | 70 +++- apps/api/internal/handler/auth.go | 105 +++++ apps/api/internal/handler/home.go | 34 ++ apps/api/internal/handler/report.go | 35 ++ apps/api/internal/httpserver/router.go | 47 ++- .../internal/integration/admin_ops_test.go | 2 +- .../integration/analytics_ops_test.go | 89 +++++ .../integration/explore_catalog_test.go | 4 +- .../internal/integration/ops_content_test.go | 127 ++++++ .../api/internal/integration/p1_flows_test.go | 86 ++++- .../api/internal/integration/p2_flows_test.go | 14 +- .../api/internal/integration/synastry_test.go | 7 +- apps/api/internal/llm/deepseek/client.go | 108 ++++++ apps/api/internal/llm/deepseek/client_test.go | 34 +- apps/api/internal/middleware/device.go | 55 +++ apps/api/internal/model/report.go | 17 +- apps/api/internal/repository/admin_repo.go | 246 +++++++++++- .../api/internal/repository/analytics_repo.go | 313 +++++++++++++++ apps/api/internal/repository/ask_repo.go | 59 +++ apps/api/internal/repository/auth_repo.go | 153 ++++++++ .../internal/repository/home_tools_repo.go | 113 ++++++ apps/api/internal/repository/report_repo.go | 134 ++++++- apps/api/internal/repository/scale_repo.go | 79 +++- apps/api/internal/service/admin/content.go | 61 +++ apps/api/internal/service/admin/service.go | 85 +++- .../api/internal/service/analytics/service.go | 273 +++++++++++++ .../service/analytics/service_test.go | 45 +++ apps/api/internal/service/ask/service.go | 363 ++++++++++++++++-- apps/api/internal/service/auth/service.go | 149 +++++++ .../api/internal/service/bootstrap/service.go | 131 +++++++ apps/api/internal/service/home/service.go | 133 +++++++ .../api/internal/service/home/service_test.go | 30 ++ .../internal/service/membership/service.go | 16 +- apps/api/internal/service/profile/service.go | 43 ++- apps/api/internal/service/relation/service.go | 3 +- apps/api/internal/service/report/service.go | 18 +- .../000011_account_bootstrap.down.sql | 10 + .../000011_account_bootstrap.up.sql | 29 ++ .../migrations/000012_ask_paid_quota.down.sql | 1 + .../migrations/000012_ask_paid_quota.up.sql | 3 + .../migrations/000013_analytics_ops.down.sql | 2 + .../migrations/000013_analytics_ops.up.sql | 29 ++ .../migrations/000014_ops_content.down.sql | 1 + apps/api/migrations/000014_ops_content.up.sql | 29 ++ apps/user-h5/src/components/ask/AskAiPane.vue | 26 +- .../src/components/ask/AskComposer.vue | 112 ++++-- apps/user-h5/src/components/ask/AskEmpty.vue | 68 +++- apps/user-h5/src/components/ask/AskGreet.vue | 79 +++- .../src/components/ask/AskMessageList.vue | 109 ++++-- .../src/components/ask/AskProfileBar.vue | 125 ++++-- .../src/components/ask/AskQuotaBuy.vue | 143 +++++++ apps/user-h5/src/components/ask/AskRails.vue | 25 +- .../src/components/home/HomeSelfCard.vue | 2 +- apps/user-h5/src/composables/useAskPage.ts | 81 +++- apps/user-h5/src/composables/useHomePage.ts | 75 +++- .../src/composables/useLifeRhythmPage.ts | 44 ++- .../src/composables/usePortraitPage.ts | 49 ++- .../user-h5/src/composables/useProfilePage.ts | 2 + .../src/composables/useStarProfilePage.ts | 44 ++- apps/user-h5/src/lib/analytics.spec.ts | 47 ++- apps/user-h5/src/lib/analytics.ts | 207 +++++++++- apps/user-h5/src/lib/authSession.ts | 47 +++ apps/user-h5/src/pages/AskPage.spec.ts | 44 ++- apps/user-h5/src/pages/AskPage.vue | 50 ++- apps/user-h5/src/pages/ExplorePage.vue | 2 + apps/user-h5/src/pages/LoginPage.vue | 294 ++++++++++++++ apps/user-h5/src/pages/MembershipPage.vue | 11 + apps/user-h5/src/pages/MinePage.vue | 38 +- apps/user-h5/src/router/index.ts | 1 + docs/BACKEND_DESIGN/ECR-007-ops-analytics.md | 137 +++++++ docs/BACKEND_DESIGN/ECR-008-ops-content.md | 128 ++++++ docs/CHANGELOG.md | 20 + docs/CODE_REVIEW/ECR-007.md | 27 ++ docs/CODE_REVIEW/ECR-008.md | 34 ++ docs/ECR/ECR-007-ops-analytics.md | 74 ++++ docs/ECR/ECR-008-ops-content.md | 67 ++++ .../ENGINEERING_SPEC/ECR-007-ops-analytics.md | 30 ++ docs/ENGINEERING_SPEC/ECR-008-ops-content.md | 28 ++ docs/HANDOFF/ECR-007-architect-to-engineer.md | 36 ++ docs/HANDOFF/ECR-007-engineer-to-reviewer.md | 18 + docs/HANDOFF/ECR-008-architect-to-engineer.md | 34 ++ docs/HANDOFF/ECR-008-engineer-to-reviewer.md | 17 + docs/PRODUCT_SPEC/ECR-007-ops-analytics.md | 32 ++ docs/PRODUCT_SPEC/ECR-008-ops-content.md | 23 ++ docs/STATE/ECR-007.md | 15 + docs/STATE/ECR-008.md | 14 + docs/TASKS/TASK-007-ECR007.yaml | 20 + docs/TASKS/TASK-008-ECR008.yaml | 20 + docs/TEST_REPORT/ECR-007.md | 36 ++ docs/TEST_REPORT/ECR-008.md | 28 ++ docs/TRACEABILITY.md | 2 + e2e-live/tests/fullstack-consistency.spec.ts | 2 +- packages/sdk/src/index.ts | 162 +++++++- packages/types/src/index.ts | 18 +- proto/openapi.yaml | 174 ++++++++- server.py | 9 + 132 files changed, 8276 insertions(+), 491 deletions(-) create mode 100644 .ai/product/feature-spec/account-auth.md create mode 100644 .ai/product/feature-spec/ops-analytics.md create mode 100644 .ai/product/feature-spec/ops-content.md create mode 100644 .ai/product/feature-spec/profile-bootstrap.md create mode 100644 admin_server.py create mode 100644 apps/admin-h5/public/logo.png create mode 100644 apps/admin-h5/src/components/BrandLogo.vue create mode 100644 apps/admin-h5/src/pages/AnalyticsPage.vue create mode 100644 apps/admin-h5/src/pages/ContentPage.vue create mode 100644 apps/admin-h5/src/pages/DashboardPage.vue create mode 100644 apps/api/internal/handler/admin_content.go create mode 100644 apps/api/internal/handler/analytics.go create mode 100644 apps/api/internal/handler/auth.go create mode 100644 apps/api/internal/handler/home.go create mode 100644 apps/api/internal/integration/analytics_ops_test.go create mode 100644 apps/api/internal/integration/ops_content_test.go create mode 100644 apps/api/internal/repository/analytics_repo.go create mode 100644 apps/api/internal/repository/auth_repo.go create mode 100644 apps/api/internal/repository/home_tools_repo.go create mode 100644 apps/api/internal/service/admin/content.go create mode 100644 apps/api/internal/service/analytics/service.go create mode 100644 apps/api/internal/service/analytics/service_test.go create mode 100644 apps/api/internal/service/auth/service.go create mode 100644 apps/api/internal/service/bootstrap/service.go create mode 100644 apps/api/internal/service/home/service.go create mode 100644 apps/api/internal/service/home/service_test.go create mode 100644 apps/api/migrations/000011_account_bootstrap.down.sql create mode 100644 apps/api/migrations/000011_account_bootstrap.up.sql create mode 100644 apps/api/migrations/000012_ask_paid_quota.down.sql create mode 100644 apps/api/migrations/000012_ask_paid_quota.up.sql create mode 100644 apps/api/migrations/000013_analytics_ops.down.sql create mode 100644 apps/api/migrations/000013_analytics_ops.up.sql create mode 100644 apps/api/migrations/000014_ops_content.down.sql create mode 100644 apps/api/migrations/000014_ops_content.up.sql create mode 100644 apps/user-h5/src/components/ask/AskQuotaBuy.vue create mode 100644 apps/user-h5/src/lib/authSession.ts create mode 100644 apps/user-h5/src/pages/LoginPage.vue create mode 100644 docs/BACKEND_DESIGN/ECR-007-ops-analytics.md create mode 100644 docs/BACKEND_DESIGN/ECR-008-ops-content.md create mode 100644 docs/CODE_REVIEW/ECR-007.md create mode 100644 docs/CODE_REVIEW/ECR-008.md create mode 100644 docs/ECR/ECR-007-ops-analytics.md create mode 100644 docs/ECR/ECR-008-ops-content.md create mode 100644 docs/ENGINEERING_SPEC/ECR-007-ops-analytics.md create mode 100644 docs/ENGINEERING_SPEC/ECR-008-ops-content.md create mode 100644 docs/HANDOFF/ECR-007-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-007-engineer-to-reviewer.md create mode 100644 docs/HANDOFF/ECR-008-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-008-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-007-ops-analytics.md create mode 100644 docs/PRODUCT_SPEC/ECR-008-ops-content.md create mode 100644 docs/STATE/ECR-007.md create mode 100644 docs/STATE/ECR-008.md create mode 100644 docs/TASKS/TASK-007-ECR007.yaml create mode 100644 docs/TASKS/TASK-008-ECR008.yaml create mode 100644 docs/TEST_REPORT/ECR-007.md create mode 100644 docs/TEST_REPORT/ECR-008.md 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 0000000000000000000000000000000000000000..7ec701b3ee3d50f5cf2cf67e8a1ef294c39e4567 GIT binary patch literal 37786 zcmV)bK&iipP)^f$>rCIg_y4^6oDvd~ zkx;4Xl+NNgd^M`RGktsQ_g(K=3^mkHLk%_5P{SpMD!%fMQ0`)!nplsb1R5884-Jh^ zvBrhpndx(@;hmx4;mwdi3o{qo_jccB@AA7)qo?me*6Cqp%cJl1b2ZdZ!=;7F4{v6f z+WWGG8mbM-8{W*EmPb`^`Ys%L{BOHocn19kyTKy|uk;!OyRS!6Hcd ziaCyu`>d|E(yub7yyn(0M~Icmb>EGFuFn=v9_Rp* zutO6P23!+hEJP+O0x*#04G4H*{L-Fw4Hp9(aRWmDp<)OH2nlfY#`h%yLJml}RbDEb zZW8+E0R`?z1YQm)#^C!QLY_bl>C(dWANmq&TmI!4yK|QT)RE_&hZSXEL|IteM--=E zr4K>c{fLYl>gPSok~NZ5Lk%+x6%K8I%&*1aJzqKg_|K4gZ6A`dfZW7sv^F(D_!hX) zfblqiKIUNgZ+#4%+wS}nvpT*v)8}5p`NMdpZ_s93W0HMAQl1+ zQ}81WU(}(LY)dw`cisu0My#f*zn`02|M*{heM4gK5a5o$Pg?MqK+;IJ6+H%FH{trt zAHQ3L1D{}Kt));y4Ko21^le8twh?KPMh?D&hHMC1`W0dYSo9k#LNfAU$yk4l z7Tj|2i$16wtLGSls*zNWGAhK1ORlR@`m4N5s$xF%8?s#4QVK>41VX@<3Ot#H1t85F zOd>=Ob0iR~yTI3E4Zjgez~KJiiPv!rAA!pEAW{kVjKj9FkOiin*2d;EAj8I)KHC~< zxE!HI4}Bd+UcBeTuYZQoSDrz0SimBaLzeOg5urKlK!9Und=yTi31(db21i{ubs5ZC zycqEPwwb;UHC)V!RVlYF812XWBtwdq;3yZVauWze#-X@`!((u26)_8v(IP#y9M_K3 zaxAy{=%{;rm5LQ3x~0EIWGb$ZD*~t1pOjDvP*UnzWwUZ&SrHIK`gOpRg;G!ef)Z3= za4SoOmQz=F;PU}yHv^!>VAp~9Y>{bYm8yuURZ=0ARK1EISOo4x2#W!j6GI3K7Nbg7g_+fG z@ez6*P)bB@9!BINO;4Gj;1*pZ%>djmFdF$lev(+2joFB}xC(KcRE3_FGS@FHgFlj0 zr6U`_(}!xJ-5RO|dYDU=THsIKfJ3{t4eogX!-rpkJ35F}4H*}xIf+Ds4cYqU{rxXIzOik|@v2 zrV*Y5d}<4t94vOh5l#1ftzjC#xdP`Lsv@4!t-uP2QdFPf)c%XXs1gN*IP{TnEZ9uI z9nXOarmIvzD3Qq~QE(%erhp{`;>jG0Os!gVv7u!Dort^~0>3R`THv7p#yEV}N7_jv z^jt723Cl15(*_i$x9Y5d^82=rJo4bzo9j~af}?gNP#DvdRB0wK5?N4%KCpC+T-7kU zp-MeFF?{+{7&x}6fA>>3@zSr6jq;dh0Yz^JX|E1K#&AhCp$tSyK$%pfQh=33FLN-j z^IF_+!>z!ot9K*Y`a`|3uxh%)D^dU@Mp+m_drT}$|B?KiB;%lxs1VNgfNism4uzK z>GzPc>o7P`1ZOswRB&vQrpqq%{jcF7K#S8kN6eTOuQPB6ex~UM$s~knASQApW5`&* z^CK8CLc+-+RhL12augztbQxQ!7%G;!!WH^1RAG2COV_-XE-KDc0n3#r1p&zn8X$q9 zmM%SeQxZA?n|;Je=0Nxr|(0{yodGlXBcJQLadZGv?Y4=iSq7I zN~E(kf}#)0urVG840#-0wgc^pRv}T>fPzzM)~99vDY^whkr=un}uJj#!n&@NTg{70=`h-^DqIvxyRI zX)J{F=rgHX^7_VT-jm9Wd{z=GhM+AP8$@a3B!t6Zn-aD+2}p&|O+$ncxGZ$QR$`{l zwT2p|pmL`^4PIJ?XmB0&y|Ve#bH9Q=F@WI{$B+yp7Bw~^VW;7`^F`QHy zCQo1}@1fUYa2Bn>)tf(orkij7eZcN#PEA)h8=xk-K7+x@^$$Jt&zqNRxaONn)~qNV z_}2fkb^LS}hHZtA8!c=CSh}zSwjE;4hc>p|`g=RRH}>-Woj>~f@9g-kk8fM%bS}LQ zw$t+#Vnt7K?uq}dOZ2qHa>IfWJ_;dF;tnR@VATgcg4VS+11$^gLpps3eD+-Q>NzJG=YwpTd));86)uNia^! zdIXaxc!eBI9L`RCPc=gmC3>#7PgO;#HcCcSE2>_VCqAiQRp!T3!D6$nPtOweaD&6p zkTDU!L&^d&y3)o_p!#U+0$~YYLCZ{^YYjCJRH^??#N(^++Kcz}KKU>PkM4z|e6Y~R z(yW6Fw-6P^kt-CyO~A@H2uvH&v2lE&fI<|Yb=8gdt&Q7&bszpB%dGs1nZ8doya5&W z--(g__5XbT5AMA2gCG9h!i^vMV-+v zSNOzdmh5a?x9*#33&ov}{o>~ve&>Jv-|j`Bu=;IVfv)d?2@@)27@5e!Hc7cG;AC20 z*DXTphd#Os4U6t&=~~%xEuW{wfVFOHF_vo`8~k->P&qbfXoSD9c{s#DtUgnGGUvoW&8V7hWhn1!1jp$vEv zKxrH?4-tkEu~&p43>b8+VV<5UMc}v3^r>F%P$d%bMp-CjyyGCmg>Inqw9J$POy%Dw z8xM=}kv4iX9V3WkNBSHLyPsbB%(ygF9+e;53Rzr(`z$dTR1qKFpeBQE8^ zq=}@RKpYiN2u5K$3h8tfp%fS|2=wG^v|hIfYd8Kj>?^PTCK3(LFmY+MgUY1|+$f8f zCWMutH_D72TRPJ7`aVGG!THRtMRXo<61F`6X7|q;Em@pjv**zt-??Jp!k&ejKK3d7 zHJ0p(LJ6rNps@wWwmgF6%l7b&m7QzWE!t7~>u=$}v(Mw|ZA-A^_RW8B_~d{8df)Cn zpO}B$`upDYK`nZ&u2@N$t0xa5MlA;ppBYFrw*gJ<53pp7RLvFqCQ_w;CUx%fttR@1 zn-?wr$KvQd?S(}mcfrZQ-G`}s6efp&*zKIw6Te(Ri;d!bnL1I^PomriEE6iJ->v1A z?5rHv6-t;fSwl!zsU(>6VR&Q`!C=vibAZJ6wlzG{r+PVp?B@BJ@q=Fkt zIO#Ovm;VR&>h*uY5-YwqEssH=XD4!_x8vZicXmJbD|8>+g*05WIudY;`a0K?O(8dy zLs$qQMT|^c8n&IlL@9z7Ct$WM#%&+{B(QMh02Z$L40A5snyDw-`cB<_^u=Fq?)r!S z7s@Mug)TS^q|sAA8!*NYR=g-L?D?zs99FcQZwD#Mk|G+CGUz=Gz;PV3wzk4vxQafV z@hcQ*ff1it`^q!F#P5IM@4f^42mN&>+8;ddfBst}NwnJ1zF)_MHLm)c8aw$%keS;D zhDO@1TR)F=fS@O1sgGhU__|sH6vs2xP;Qg2;k*vCZW>-s#ejCQ@77za20Cn z3)%+}t)h>?sRKAmwU3O&DbqkEVIe1|L`7|6Ltm_;D25BFSow5SuCq zo_=KI=;8e^{XC3-tXW*`J*W`r$C%dmkbDb?7hS;frs)*va^K~A9MeP?hGl|9L@o^B zN{KL?M%aEORPTXru?1KEA!ZT+n0V%~Z8xo1354g$4@M_SuoD3lQHxNOjh;+&<9K&l zx~UnXML)ODjj~8&_8ZxjM;hu|?(E(D$|v3`UJrYA9{$0%ASLN$VtC5LXc1W4lmI+| z`75si3GOc8-L7GlqOom0MifmHGQ`Y6#1y3TfzXAT90N*))hZa;!mOG&^WuUuR*Lre zP(+~g!c-YNptXt7MXZuLG#fWT${Z^N0VWDXxONDOEEj};Ff84v&;&73;8BfOT}se< z6=ALorLpyez9SfV?FB4vP9q_Ux(Y265i5GVl}A&x)e5($R((M>#Z#bvPLM{k5|=d< zokd8bGZ>%9BZ`Vh)Mep`7^9Oz7(SFoE|5@O&(4cQTGZI_KMh`Z>}!L2ok3#c1uX}B)nV+W`?X3 z(wHbom>sQX>R16pP7kYJa33xuob(~&3af|5#~&N* zrtCtVKaI-s_fC+M(BUNX$3?GvDY6pDGv9ml#TReEOoIbaXhNFxVD;y6n>uz9QU6Ko z`qKZo+GF*r{qEPWXi*1JO)a?oW4{-hi*NYk*+1Xd*ofG5Th1X?D(KmXkwcq}(g++8 zLxvF~&w?4`UsOUvb30%rPF||}Tf;1eFmlOM20XN3nl?Nc!>Q~(6GX&M9*4tbXQWCs zgM_;(cVH{EWh9SubA}QTlw@EeztR`a@&#j7M#Ly{Tv-W3%z&W+kY}FabZ-=5(pQlu zR}jtGPUf5jO$E2KqNSx3`CJd`6eW02B~(D3WyL}|a+*k`>Y~<_PyPCN9?Qx)s!~;= zvMTNDsUI-tx)PT3>p7E6L7E1G=R`}`ivG-RAItpqG5{nl?O4^l{M`1mJ*98fxB;8agH)^Gkj;Og~% zcJ|Mzk-qIuKlD#ohN$7(ij`CbhWoP8E5Ic8?0~^gAq>mFWFf$UHOr~botyr9T0>Pp z7`giS3*i?|!!|fV&nufeO1fMsoa%ZVD({3iFD~fT9UffN9QeEwfWTkaio$Ngwog9pphm#Wg}X8 zrUZOgDyNSZ0%~8AjG+9C)4xk?#i{qI@p)>(608C4O#}b8HfRY!XbJ1d~ym2Ej ziQIW>$fB{0*z?5BL;v@`gBm0zKFo}uoUn7-U`gP?WX=C;!wpK8JhSp}x?`Q|>J66^( z#sPR7($)nAiK>JFDHv>eTrc>2Mw4w+XXNiltX$Ve5Qmyr83u=pB*MVc8}U@e#`5LM zG2;lMY+X|$22OQDb*>$N!8h0YPJ$923U@RpmIT0 zDgv(%D#>W)y&GSH@)QdeFjl5GnRgbF@)z=VT>h#=|{NL_gyaQ$xs>b-SP zRAFcfqS4#2@6kU!@zjqnc6<-AGKW-wa8Vq~8&>Xv;I z(+nktwg7Gpoy#9t`@uW5y!g-vxH1sH0VC5=T@^MFRfv_8)ZV>NW>qPRS0*^$4w;q= zJkQhDCd;z)k#0^y16mec1-5YMf58lcr91Cchko>}rylYIv)vuJe8HNs2c6{=E5%|bnS!g{ICmmbDvH6(vL~18(!A;W^w_E4 zyg~X|75CZrz{Wpby85ckOM~Jn?O%0f?WGFQl!35jLvNS~pD&asA+AhXkr^yS#E@yR z{x~Kd4qNL9y*L$wte+XFF4Jx}1iNlGYa{K_U;XAkF?sa!2*);J?>}rhNyeUC`;j(c zG}s2LqK7(HAw`5v#bw=>X(1FDj7I`}E~gk1SdIzDAd$9@IQHPvA{x8!ln+t|Z@ahIQ4bL*8^Il977@zluCReGp z2l;h(9NMt*`dbhLEoJ{G@~|>;5OzO=H4H(I|NBYDpy!7O$Gf~wqbMsquSLJdEvs1e zLjzD>zYBHEk22?t>d=g$qw~IGQ+9{k^Vp~PwYPMtU?_*M)R|6XAgm-HD_TAw?bWy6 zjG=vxV`TR)(6(~r2GCSsx_;O5|KkULcHK4C96=_t|J(;;c;L?Qo|9-%F{t86WR!Be zJo21rUIa9@_tbO`)d3|3w$YLCwue+SxRn_fKM+}qT?c+O!0MaZ{P6(@M}Wnq_Ol{K z;&ciaJo-AS9zlMs${oFd^o1-@p72rmlYd0E0kqqh>C>Z&)|)AmCRVydF$+QYvre*{ zZAdzun6zO`YeKkP9_RKnv6>rGfhru>#?0y&_&LkKQdd}Lp{4~JRRrZ~r}SI!>SOl| zJohv7AAAuR5nxHe)I(TtULj){u!Ub1%qI<)MkD%57W#*MsD_0|uUd>XAN?H~QAIFt z8Yf?W6(_q6!g4({SSh5kX-t+Tkc17xHX%I^UeSfaIGPO(6&BH&6zF~aQS5v62{?;>GmDZJ@!wywV`1% zmd(Ep%o;Ap3?kpb!uBysVbb@E9D+nQq@b(tWeDz{nfjtJTZD~JVS{$rb%#^5 zqg44r8&MpKmD`tOiD_{Bt!sWXHNSIbuJ?#;=R~1G%E`bh=Ja@PZ`W&3rI&xmlGlF% zRS0$Lx#w~0)eZ1VpfOps=O^C<86_Tk77#YV69+oYC0GCXY^hjPM@5JViXImHmJ=73dJae0_srAv?6`QYOLIF z2dwqCei8+DG3!|NRaNZUzVh}vm#sX0@JspE_MrRFE9gFP6zz3wh<&o7C{eQwyfg_R z6dcDw1dh?%2wEEEqbb#dF*k=JKe`{sAN}dNCCgTQKYZvL-#0$E`5Q>p@1nJAx~k$V z(f;6c{JV1sEO9b><+_Jj5AWV~>a`=N$CYdAuDa>Zp8e*3{6<{guKo^SO0eb9@5O_feC=yvNB7-z^r63Lh{s2eP%)GU5%3rhd2Sd2M$BN-@P&5er7%PZ z-zYo(T9K_?`N{i4i(xOPBPjX7teCZhTJu0U02+j*AE2onSDFynCNwTy4b^{mEo)!( zxr_PyksGZ&Zvi+>=!7o(P$HYPQ7VLBQ3>uqFW{G|wjKD)L;qug8aakAN8`&hznszn zP&+K5ATqe-U;T$!WrA7_;&qm3d9?ID{tuL!FQl<5yFk;C$T_{T<2ZUF=A;_@pS-b+ zi1KUU=hk6xxUJAL3U72Ar%oQm{MD;5@1_qu4xlQDRW(3)1KTih>Tdb?KWy&Z`!u}X zV-WrXu53=Am>-8<3Siqd1T)}yLHWv=X+dvjpu!>0W)-ggFQI<+H)9t;^+6YTzMTY!Chq^kUu*27p&Bg~V8lmL~lt2+CuDUT0{QjCm|-s zVY3jN1>ihJ90m{;y;!-vak9qOVZV}oPmh}GOtdn~c*7j!GC3;Pkg^_1(I_iTe9-89 zWvYe)0T-BvO~|@d*rtWdHS2^b3~s%sv*37`T^%lbnZea&$(C6%Cls zQ%=N$3mTy=M@VRbCG-_xR>H7sFp4UXVcD!OjNLiqw*#29;F08hX2E7ULOyl3&6>!G@S^;OJY{0)f2NoHNzT*1p3EWPS; z%0JL>+rR$3+h2XLFZJEO{IBb7S=s!j%h#`lDjv8Op)$bJhp3i;*Pi|QU>w{u zkcC3mv(I4JRUfK8+Xd;jCu0{fDj*Z*V8s$4OjtA`9!`Qq4z)Yai9o2~y@4B6Sz|R- zUDF?wh3k6rMD5lpuv<`Z&ra-ru4CWHLuizF7%~r$8%0`0NO`Wlo|Q-heeBR(Sa!pa znLgjS0F~?d4B&Pm9&3yr{n7T}!v`>U>L{3(N18>jr3VKR$#e=O&qol1@M!wY&Oq9A z2vZi6;h>mo!EL|$?}66E-^0AN2U+s`);DI8(0^zPmR$KK+4hC2v)6xc8@i8ol#aZH zfy0L|(RUh6DF=v4K$Hjb^9X|iY!Sg0K%EoA;p50ABwU%t|FJg z%={%#eJ_0qiN>c`!?LeV-y>SX8>rufrp*3pZ@uc|6|1gU+xP0Pb3gsw_p*gx;_mq? zuRy`5f4t}L(avQk;M!}i#YcYkx1Ys=<=1^~%#|ktJl3Dac}o zASOp=3ppjQ=*o3Krr{Y(2TUWEPuR#O3~-jvqpf~G)hG#J8i=Vn5K|r%2XOPJ?^5RL(#EIXJ$vt{7O{=HZ>)Vou~L;DaZgW1(0pp3GX^pTJ*l5r6h zi|O=XQG5(d2gR=Td8^_4Au1ZHGP$Y{tcqI%X{;2r0>k(3sy*$Xc4b@BfIBhRHqQ=V zprCzvY;MBxTo{r=x*X$sUPQyP>#L%*Ia@*F>|kC8{E-{*%C1evp8f?ed;+R8juZrv z$)sK%ynGS4pn$+-uuTK?nKWW6h2hY`kW9mAUyW;S`xwLxw|#=8->2@)3mYNKF;;ig z9|NEp)cCQ_cJv;=HaR?cM!gK*{CA7g~ zE{qUm+t6~wbs%%B>A5E?@?#)~%K1gq*^nxN)C;o-$sFC@&_1!`ouVsN)byebh=h`c z&aApDp{R-{5mU9Q5{wF2)>i45&6t54Wl2YSR(9&e2Tyc2U4JX~{qiShNIJ-SV_-=O zu^%BB#HbG<6c6lOotoddI4)`9lq!J|0@O+f_06Oz3fNh>W@R#4!v(;hDQ|jDmp4l1 z=)-uqH3+X7w0Y(@<%_j1T(r~g*^i_}LF6GNu0Sadwj^Uo8v{oUqd}e<@LI!rgYt(r zYwempatjXb*?RoRUtpx`b)=aGTNRLX%0+m$Fs^-?vgrg&kwOrK2qeR)2@geKp=0e$ z_`n^X0It~Z`)8X#z8|pETPOQigI+7YtYpv5fBAu%SL5(2pLyl6hj8-nUev{DG$xZs zko$PCfQD=%V&6yPxq6LDgNg+KV$VYyOn{3NPCR=I#TR~#hDFQoT6XP?;8$N0Q-uw- zImVrGfUyj^{*+tGX515ME`rsyKd9WEWUAr4|BeB7@`l&2Mj~5 zLg2Kb{R1~+?aiBkW!HQe$;PKx^4zw~ML%b150WlJVnCK6TdesvOvlB!qqk4K`1sD_ zuRMjm(}&;$0^ua!NDkXf0hWMI-dI5i5=97M!SzN_SKo|g$HMr)e(ZYSWjH@;5?8Li zc6;L5>!HF!J~LPUNsUxZACy116;ZweLdOEUCm8;#FRnSV_eI>+OhVWQo{&Jq|i3I{_Ar}fV4T1;SO4|d|b}}N_wRXcs9RKmF z$RrHpqY@Hk3TeYY(Jf*4wF78+@>kmdR1dLY+^6h1s)$lslim-3ejOURRUI+=j83SA z^F*Q?X){%=q9d<3$4^-=!1vow1z>jPy{iAId*a03uMERcSUiNUuVdvZoXHtD$s3Lx zn^hB}vlXh)y904yHDqxm4m@+`>(4!f(}!L`Jy+;V3oy5csNf@&OdtwekP^Z)IqYm5 zCi6w~nFF?z#5H+xMVt=~r2@CU(ErAnA(oPPZVJU!z7&eVR2d`u1=79mCCyqPZ@KFxQPH zQ-B*8Z4Rn@7cCu2k-hp_Fk|}MQOSAr*fT%x$gJ_dkjxFhaR*_bh{$FL8KtIBu!Rl8 z2`HUYMS}GZF&Vqj9SBVbd6`xYxP+n`qg|R#ilbpk4TG}0m;u|&Ksi}B9ZQF&GeDdC zJ=E#Pzmj??-4S{tI)y$8By46NjwI&SIWRmIsxYwaed`)76*%`}mULhug0uJpxFw!C_LJCX0dGkoe7TaWF23dNpdXh|BlJ_$sA391xOy8@92 zke?VpOH#n~0~CV{+(Zl9maC9nd<|CIyt!oF@UavyeKmm#L1oJC4Ea&LuYNx`_|lGJ zdtbt6Ul&AS7ukC!N4zZh-lxkpJZ|yz+}5u50YL z;(qnuU*3$gF-VFME`ucesS(Jt8x9MYfpsMnxrp|-=AZ>NT zVmO(7l~Lj8Lu1P$>CTm(KK<$`)HT;3D7hH)0yqtASa$t7VAIA!;P1VhI`b$S&r(f_ zEG=SM??IknK+L$lQPpMUP{sN#)9&|$wJb%$tc-$q=<(EKR&Nk2nxqO!s2a%nU8&A% zk?VOKkvFbi7s9Z#H4IH!C*mSbcI|}Cd#8D z6~WIBLdsA}RA89!!Vm@*aGF}-Ngu9dz_pu@=v<4{8@7U9_n}W#OXY?wxc1*Fcldj& zZ``yFgQxD!?|-TDe=ipptmC$;=!1y|{);;K*gvD5 z;z+V-bcX>}eQf6uSegBo;zU^XjVE3CLt8W{p|*foJ+8m!1C<}z!ZLH`zNu(n8%n(` zE!*zG(bIiM6i2ij#6Vp$uKC!{jR*7URDo%+w*u7tr7U7@f8P>qQ64&U-{1 ze)Yc7zyA9jrpzOgu;9A^4CTO(F&d&?M23aJ(Y>D~=jNF{-`N7?owx@hU7z({eYxe} z-+qCzMUj<7q*2oL1h6>_E700Tn51naI-#IK17jkKzT`Z#uDuag-|zuo*_B^}ncc&j z>LL&fU_=ENVF9KLkrW}g$&qIvd=+VP zXv#B8$vo8PDDt~r$MDNZlzv?InUx>-Fw9#%(yhE>t0A&`=IWX{n{cLzQg{2Ee|Syv zyl)+O>>->Q=)>xdeiBH`dzkS{ACk!Ix&Oq^B~nFT24xa1sn*7rqVKg?O%Tpl)1&S)t_nloPoC#DXsZw6wc6bxRky*|Q z)Vpw90R&>*3RK|IrHsa^2*92KTXpTf-Sa2wcd(cyaw8br1Q!-!sxB}WhC6~nPuFS{ z^xw&>%Qt33jSg;QO|1{BV)xztBY*$({)5kB@Z?b>Wq=0mLwE%kB1S`93ZtW=U_$6& zrjj&Khy(&|BhfeytaS-)`>lWLvzA5QM)SOfD+HzL!J1cnPL+nA>Rh#EOXsGKZZ7P9 zarKeiPomU+0!_Asgv}6nIh2YM$TT$Q33?R;uy_oc$9gh62qqA_zu7(=k z8?5y92OH8 z9z2SalSN}n!0&q%`@j1NA}jOx!N2i&JK>xmQzjQ+Qk997 zp2XmYi+QRRR+Xt{`?A$Ie(VJ#i5$ie6erI;1z|dfCBww&6F^DZE?idES2fYQ1Ma|` zc>YJ*jz0Bc_!?A^7KZc)dK-)Fzam|PB z09IZ3Rc2hFgU?kkxD|60sxZ8nC0p)Qd5Z2)2G)MyaQoHQx3^D>Y%cD7xuNgCtLQ&` z0QHs)gRBarhak#Bg&u4}Az?BsXlusg*d+X+E+m{ZR@fQ%{wRvip1`5!9)aC)>Dlyv0|yY$NFoSIj|M$&vK}(p@y?jCRR=&r9acg zz=EnfN>2l7`MLrMg#zkOT}Z6F8t$V>ts7Hh{Al`!g+gQ{VUY9o#1JredWVXJ?qf!E zv^d`n)X1T)!ynm9awV3pEL8^XqT$eY@R1ry4>1inKz0YD(KyThU}iyrX^yRa4!we7ukL|2+>a(Bg$7$ex;YdkhY^|q9NR!PLGfoE;OCGG^_)FIGmXJs7q9mo zhV|r++uE0}`E>T$n_#b4^OTx6W<%Hm7t2WXa>HfnQ*iO2;^ECuN@&?j*-28PTJ$0B z7b+auf;h@DXZ{0fv~N3XBgfL0$9=EiJi(MfQ?r&s8Y@vY2jy?H0;zo#GX=Lu#HxBK zRusyM!tq^3wrzvTckB6^Kxfq1piyCN2xMaq;jx1-S6n;$<9gl)RIdAOgku}yT|e61 z{nFzY@7jxIhhc473Z-HJ-b4Y0X(H!I*p34$or32p3>B%Rm4MZ{5YED-xa||$OK52Q zAu=tGFrzk2$*gl91LYnY&aPbBaoufOZ=vtTo9tHwunHSL^x{e+%Qe5=t%!A z_f_%OIz({_pggIibFRFq7n9=$@5TNn+Q`^W9ewOCapI*Gq>|%m@8d;w&8CHK|9ium z5k^^-zI;Wzmo2mv3HOCf2CG!DB9{e93y_H82;49=+45ZXEIX4b09OV%duQ60Z3xGE z;BXT$g-cl}lzi4jU;*=!27=-Q z#&Qu-wuL&=L>MVddJ1Nu9+SdCk1Qc>ZN<_XK7fTAHUf)QejAb9&r&s^^*ezj%29L5 z?Z1<6S|OHh{8&2%`gZg_@yqo`cRhne^=*hJhhPSiu(%7~^ihP1*ot9M?otR?jLfzv zqRWII=TICzfW40$hx>Q}^H*GbPuq&C?onM&9YOno@4_(0!Rh@gN=(#5qB^aNF?Vi^4-^;*_gQE*16Rp^G9!g{wLpS^ZUEtK%wNS7K7Z%1GfS73mOKV z|M~rGH~;oq+}4>rHzs&$xUi{T6%1@+M8wI)q5_>7AeDYEQ68uagLD1gW;vKyj<-q9 zTZFFlAXW_7R2GI12wV?Q7(&D@{hMMd+rqrE(YH7MN9Q{ zeWtW;*H?~w^Z!Pn_W;ab2sTO(aR@GmAraA`|0zv$$iR(s6N=K--oao~7prK#dg^bS z<-$A#Y7e|4C#Z4`(7@!S_5b%9p$5YYA z+JPMs6XRpmRk0$(My~zVk95EM$lrH13kG3Y@H`KunbtNPD2{_G0Zfi;MwLT({dXeD zuR;Iu&y;@h*Y=5hFCZA`LPD01AXCf8M=G5Kx08q?z>7FkBoN0G!(}2u8adX4g}2;@ z)|+nvR;;`iR-&7xi)Z@2RWr_BUzFF|v*`BQ?M1`AJNsUKZsV!lFMtXnb=oe*hwBZ& zF$_2khvX50$U_ttDJ%_XW`TzjNDBwCpF@66A6|a>8D!=y*u3((n>J(J4dg=>!$@}3 zT7~CHtW@drT{wF1TZ2FPJA2~D0mKuhkd8)?usx)m7%K3}K0oA2EhQxFHe*={RaN>z z`A(p!j#)VcUa3Hx?=hx|6)sK1>doYeDs}Zv7GW?31U?KI!sMI+ED-rI3L%4&Xn?6= z#7e-3eE~Inqt zY#C9cDJ;&lOE$eV4boZl_asq;P;T!Ic$4eFrH!Dk9l!k1ww}Ga(AV9Cq=?aMQ1G-1 zhXeJE4Hz040)zu*B@yx@O3Fa&I7m0lL!xC7mVA6iH|pE(M>>57+#0?d1pSv1%sJDp z`;PKX{?YvPn>OHd_l~}oUPkwx7towe!1o3bOy(e1fTRPYDB3QLAj3Qa6JW{(7Xi}A z6v7xNjh({r#|BY)_6f*kE5F`x&Gk^jFFuM?{Vp(j04^nhqz<#pdW{{5&-QuII& zd+Sk;V86}Rr-GMR^v1_;hj*=bm4D*hzgzkJH)on@NChhj!9x!s_Fyx>q3?x}jykis zjZ$PnG7}ziNG(VXi0{la-v`uq?~eUH{QjS2-4XEOI4qOFQw)>BLCLO1c>I+O#=M0O zOih|xG%#~4VwBe?U`Po|g^<#RP)tV_muWA>>(q!<4R3&%Esy4&{XRu6X$oa(6V|g) zF<&iKVav=(9Zt1%Y;pT0U=XVcLnKlRk?TPiF$@-A_(T_!-?yFFGatP|gD3CBja!vY zeH!uD4dA(T>er8~f96O3fL7_jmLXcXfFTSB3MDrj1YQAseSK)iW)Lx|+%ve`K#=Z0 z{qpOu=;n`%W5t@gYIZD_4$N8nO#t6i?!bfdS6|ycf6JB~Jx~1P7CC$xY_JEI9EKU^ zVaFZ}#Sv5Y$pGBYMHmHORtQ6+U~mH}C?JuWKyY{rM-Dv;3S6<~ z5sF$<(TT+fjT}o{ev^goNUT&av;|$yedV@Sh70cz`Sr6 zYN8+Mgauau1v`s^(+uN-x8KUrEx&)!|1LLjF(WHP16&&V`Jmg>a~g35n~7pgQ&q#8 zk;-Jx(ML`x1+>OWe=dRiSyOIa28)gwp`mPavl_ z#ykm`Zp4DMA3)1(9|sn%{e70c{1)5U0+u*KX7AOJLdEf0aA@D1gRj1b@xuo&KHi56 z45aKdOd;Wj00xWo1}LV*O#+r>BW*dD%=aOiOpy;J#`Zpi6R$pv)cnpZ%dfk6OZuuC zfR3g2D!wEXSh}_BJu=5j0 zL@tuF)>{mbh~O#@rMQT^pU1qmdX!29c==Hzlt9`}fDIhS>4#4t_rrh2(ls~U-E!qR zVC@ahszTRdi1Z-zg z&J((7!#F$B0;G?LFuk21{cJ>(ZA4&i#9Ew9#(q;PPglC$1;0_?tBKWnty-P?$6{_E zvJwb_0J-8A?9fM(X(5xg!Qv35AVO#e#Eg7-c=^vvm)=-Nn}vBmPC)phaEm3dL=pk7 zgNFV?z_I$^B2G_rObFk3W1%`_j zbCYPlEwNICSHFMizkdyBcMK^=gt0-poEVM~BSmGs zdS{#__%8k*j=6Y(fA8#TbIQ^(6npOSp8m<|V=q3AV6Yoa4oACS2y-Pw zQ3Ttx0kaNnsf47PN2b0G93>2DD^B^J$Oz;u-KH{hdAsB4OM#6F6Mw7@m z3T|*3C!ao!eUJY$8Wt@5%=I^a5Lo}=-D<3-$QtKf>dV_#ta78b_a1l!%aSI9ACMLs z5p5$a3%;^Zhz!&v>yT(&jO#!4?}6sU52LB+@vDJW)0~4Wxufq>yfIMele4zTJSu8$W`~tsm>g{44J{ zOUF|~6@xjI?AQ3jJ$2C?>+t%^I|p8U9z#bDL5>Z>_hXnLX|Om<%R~?GfUA6DDEkTydM7#D%p$Da@-LCT@q-VcuKj*yT|T0;cY+H0epUDJ`ag&d zzPQQs#`G?=?9;_zO28qD+lZ*W(=#n6;07(WWuWMm^kkluZ9=}tVKaq{V*xxl3_H8$ zqW@iHjw={?5u`G3$dZsu=n9rCeV8(Q`(HC^gzBOYE8TvjYG|lQb$Rb%0E(t{4H}!B zy~KoB!}TrgPy-Y2y%KmBp)s35etZ<^i~~!?2qy-hN(0+ivb<0$Z)i((#6&edm0hs% z&S2yeLKGn*1>f`FBCj~95^?? zlL^FzgJKxL4}D#+cg;2&HJ4(^*3DRX<1No(-u(N(6DP?4cBb!74Hp+oXe|~EJKW#4 z`nR)fxyc(ydw1}m? z@DM)tZS=hGgAHR#ui2oE|MY8^zxq!qy05uItW-3(6;W(+HwJzOYkT9F$nB*L*GLaJG1v*^6(1Hghsb5eBn{=j`(d-5nvEXcP?Xz8i| z*A;~T;MHY#SVIld2FCqFDgzG;hSn}tjD@*2TF}T(lKc8uRX6^AvOO;?TIRK3qQVNN z26^aa7zSkEVPb3?YP@GBYnWM$iJBbPhGQ@2ft!(`KA7X9s55|6l0h12gh-;q>QJy6 zVKgp4{mRv7U3XKA&J~|$>2g+G4VNES-Bg=!XqVl2b^EHDH*ZDv;cWwl_hPi`08Wqe z!YoaqE|orC>d#A7p)Z`Vnr1z9#`L# zQw=pt6EgJ;C@2o2GDSQ!_%bz3oSPV@>Tl|7V^LHI>e|~8#**Isu!&4Xk@kCHoLaOD zUa1J=bL#-itaw+DFq46N;yotNwx|=1@-Ut+VInXwDbi5$mZ9_7+puWE?GLfVwN6D1 zZ-9w2Rjlu*sBe4Q>T7OoJ9+4jN`1$1@R?sBER4Y@P9SM>q@4^Bd-k{Vkl80wGqkH z1tLhAl%q*UQrHu*;=%0Jywp&`B?b3s*Ls>Nx}@Xb%4M_=i>-01K}0sP5Ko+eATKCFgcy>H%&K8_%lhr=y| zA_d;ufh(`yg!)^5=V3N~{oD$Wui>JD8D}O6|5(LGzHq}Wn{GhYfi0(Zzks2`2QV}| zh@|w9GE=ZEj#Mgzz6lRGi=ln#O2DhKY|wYW+vpWobjJ38c2;Bx>XDVbrK4Fg^-Wr^ zJQF87`@!O~4M)pP1EhVS#>SLE=-hbW)KJ6w1&N&1)Ay1JP-g}*GSVfDm7p2Z2uw+E z(k2!rHlP}juHR*+(i>%d1d=gL9^;Ur>SNd@U@C^faNq3Elw=y7y=L>rcO0@U44v+R zl}Tg8sR zxol$#RH+AkD3LVEms?F96;-lh>dOxW|^8W zy@m@1wjz6f)ibLk9QwW_8i!a=2QyHue}vmbN!#Q`ZJ-? zb4k2j(f{ZPqq2}xScY~M=Z1-V-iP8u7;5C;ZNZd~+OU*q%K-%wu_}Kowc!>`Y}K@B zHPmnhb_xMAp%hI9QbxI|XspW4>j^S|D@|q*&V2mKd4q{kCfk5KGZ98Hq%hCP9OYyu zk)Ob9K+PhdhUtXubiDcX^=B#=*f#I>J2vWQC$nxhqPY`&cJ53znR9AhrSwgy7*g8= zl&e>XR1%>MRjv`Mw*^-Q#Z}Pwc*r>1i0lu@B-DZ=NjrH9$yyt=nm`p4(i+l0$5fgC zZ8YfpKrYl%-byode&z71$_W8&ai^$Y9R5C@&b`qR)m|SA0_XJe2XQ#^d?m!hm#eLgVJae!6 z8mb@6YI_J){Vrsh9%spAUuD_36U_aliWL#2u%R4kO#a#Bl6BcVTRSmSY?x}q>TMC^ z*FaG#RUslTA;Z>Lc`-~#khDshH`XdvRfMTXrYXYm=Il%T^~#21rlDI*XMdp@h?SW* zDUx+yK|iP%es=yRX_2@Jpu8MemsFvET@=ZkTv29bTyAf^5!*$n1PUy2DYU*3%p_ll zs&bc~>2s)|`oZSt>*%d6f>eUnaU_)cR*ognB2slQks(wTZbwaelvoTeqoM79CUv=PKH;+U?X<*KKSe3CJwUj%~EvdX@-h8k*^ z19)2(W2#tjy3t@@WvZ=Asw(8_0w>$lSl@PS@)Wv@wdtR24eN^!tI{ftin35%2f~pg zx=-9wI>$f$SF9?P zG|YN1YdDdquMk41D}>sfMBfK8+7036sC!9dSTJ6!71B-^Cwoy?Z5NzBR~8`GAy>$@dzfBz+)HX zM7`<+X{o3uDl8Mjy{F;#9)>yIkEFv9N>gh_IIO9n|N9J!u3;uX&!O=q=WN0hu_9g5 zY`_%ZJLmkWfmkUP*_kW?iat^bQpOObL5vkf#ay?Pr!HCb8S9eQpX&N4xM3kC3p>Uk zxKYurN-!D{iac^bU5)lCB#t%IP{VmttTd^j3jvX-a@C5Hn3P(Ant7ccu~Oc^HjJFU z>+mzbXiK=mXc8ezF9#NRu&69nrHGh;&~^}$GrjL4=}({uA?lb3mn%&AKA7$3pVjeg zHB=?BI+I^_R;z6`wF0ZVV#WPrwhmrJtRB&@z|^NH)c-A&>^2OmQ|D>Y zv?EOn$K}eLFz7lP0lFerrT4Ff8fy3r#EQKG$57!9;41R)ZJft(B>rdk}DsY)n+a4WNG zzA)9Mu{xVr@k;#4Y$8@~7S(H10kL9<`41Ex{crbAR3%s?@igT!JYh zP>%#F34^$lHoi0vQO*QA&paRLw;}f19pxiKZ9snlmX}AyNg@gY#N_a~#c#T9BLRi|FTp`l0+n5YW$VZPV zhV-VQsLg7G;q>@s0w!a6sD(CI6c0wjrgUvE5kdw<2x;UlS5l>d8MbYh1^W+X&U-|K zLt8H8@$A2^{K|81wp)w!?%rnf??6ghYp(SLl8(w?V(!=`TqxdBDq_j`51jnFFWet< zVp9=JDqF{N*|y+w8zsX=Mx3XS7mcGc7ENhX%6>rQQFh5qD81`1Ps&u&kuHoBvPz=# zVi+P>=O8~0)TKy&Rn}e!$zVqu0j(Axxra{ELdT69O&=zG8!5yHnU6r2@K_yOku-%n55UFgu8q+V_3nwVz+wBW?6)p`z!3o`Z@pc)51Kp_fIu7qmH-T)hcndk|Lt$F_QT&`sns)@fjN_S+a4T6vXnJJQn#`y0XN!@Dc}ZJ z;yhh?nKcY<4VRR;EEfydq7JZQDh8CzGnJSHH0k`0R2TALA#wy_!Qt^3P8N^`5JW(+ z+(hKJanJw)`6*R~ z4=T^!RMkouiLMzv$1~!+N(n^!r4jT6u{x%0O>5Y)Ghne-_ORk~&0!ER0}m8LC=r)D zR3(Hx5IY%dxXejUMZ)#U!bKJ~h6;cvpdT!uN}&yMC;N7UFFm&7#7j?N`1oruLKkV< zfiKAt)u_aL5p?JmOuFCndZmSX1~KKCHdCO%rkR57q{QU{LChm4UhZ<};Z@Q+N&{S) zRD{y!Nsm<|B19t8KTp!2I@5;ldML;ch(fejCMK*#w1$aK&UCo=Oybgro4DN%B)fd4 zWy6?MFf0j+r@*{C5=uZ&U0jRhQB}u?8<mLqhWCyzY@|ckF!v2{xaP;SP;dcZnzO#u zHtS*91Bpxq@#H9^Znah>=!hHA_hpP?VRl5noHrDU9LG6Rsn=hsRPM_I1`59KBax}j zO0t@K?Q3}A{y*LS%wx#+oj{!`p+m%4LZV6~NM<480wJgE-qZz@mT98Sw1k(#wr2u5 z(RFG+GDFbUk(q=d!bF-s8mK4}E1j)E#^5x$s*_e|eI|L3Ld+uShajd+0zHpj1e=~x zlT5HNFmuNuLzi zMVlPOVjd$W_99ozL(=`SIfP@ukCY}>q**JMQm5-|;$+zlDcU}%z#Bm)tO zlT~F*{stHg2Bs0ytA2aXvgw`h_SK9vL%o<{VQB}24vCa)ht&!$&ptBk2POs!&dUU0pRDYINk1+Ew^ zltZ=!U7ZEsRAGkQYy;zdx}gPQCrv~$0FNt+@06xZg(yypK;`BEoHqWdtSC5} zSZNPSntrCoioQm6OO?s?iWW<^Q5_or6ugKAOnewzAma$sCmmd_q>6A!eGXXt>=)8r z2$b8i6UoM>>XiSL(F4B*D^8S4|Fj#>FGAWDMbmce)Ds;O$9>W1e(=`*!LPp2UEV-koo46OpbQnG$1eZIQ>kYXVrrlIj6A|fnh;KrOJX!wgfQ@ z62p>7=;SaUy$)0zSe#5{kk?5TwDW(X9XMrWLlvtT7aN_=wXQI-Y2>9Rwj6)qaa_@w zg*z~T($FYiDAXq`v^KSr7i10;N^W_nt}NQ5S7Zpvmm|%cX#o#sWUiZTA?b@|uwqrN z;>`@EwCkE6>9wgMUe_9RGP2d)L6EYnf#!PJoU~P>-=)=su7NbL%Sf{h6@@Sj(h^b> z)%h6*%r#V}fz&JmOLTmXj%s|*x>9UzuX8fGWJ)_!8J05_-&mk8e#MH}^Bz${Fa2@A zO?cda%OV5<@JKWY8v$7Y=;@p)5i`#cgh44=_Hd)vI;vPPeIYEj0nM`T)T*xV+}Q|b zw>_kO`kxV*4tQwj5Gbza8_O4a8GCSZBb6wuDud#_<0Q*ON)TZzFYR=sN(rZGw(nPu zL1^rC;Ls~OPVRmZ#%M46VIK{M(VEOake)OO5xOD5I6|Z(oJ3Mbqv>4Q+4o}M0qfBiug{|<*71Nk;HL&-pa(v)X$#}p(1_#^CAzX zX~VEA#D;=JRjxQdKvUDj-W@Ep;67Z2s1d7+2{XGV2>5oQ@iNl;0Wi=)LAx{(08XQp zjNAPskwU>p!C(xYu;J?qcp4trEpU4PtVROP2QmuJig7A0ih62BS0WiCwE|J4ZS2{G zR2@Py0f(DQJqIUsU7QEea-P}3-fN>^~kYm9U)RJ93%svuU%9o!0k zaM@_rK@_@Q!;&ONom+$*hlm0O3MhkVgQpvGMyeada1FaWnXHLSd6Pod#hFtUo!biY zjq-8kK%(#&`pZ-Cb=qrD&;3;hmL7Mm*n^j=tke!9QbpOFru1Q|5C-WXqfm#5N76^& z`EZp&U^);~!n%w%oi$>0G2v3nIRg|2V1vr1Wrt-7WY)sKX*39pN)bfEe2h;{g3|4h zO(_fyk3p&gGObH7k&g~FnB~e^4et$u{k@u8nT9~Y^N}GPP%f|2$+@sm*Kk=6zFb^f zdG)TL@4Fj1xX{zIGK^su0#XKW5*!W>fZ^V)s5U4TiF6jiwBY7^C?}!6EVL?L!88Tj zQlYHZob8~g;?aRSPwaaUDd{53CF-+2hI;ZyHrbFy8be+PpL9E^2DGiX8VlFo45aFT zOnnb5(j$tZ)`RLn9wUd4`%u%YFC#+15i ztOMy(njEYymH?JP?9@Rlz2d=X`{Q#IDhzcSgH>W}bQ_12b!%j5E9}emx^eMgCc2Gm z{RR}>^81CATY}|UVFV#vV5ARK2fcv5osOm=2-g3Rzf~0wD;Xy-*q`O02Zsf);t*~j zk8}pGvS|!XN=&dMT9;pgB{zHs;-(LM8=exdvwL8tj?~PF=LXbRclSJ2f7ih9$a;U^ zc{o!wgNju{O!FqQ#CUb4P$O0s6-rPH2Mc0NtcbXi&Y*k|Eo%&>GfcMr#*ba6CcE#} zEfbq2E@`)659qjzWG!akd_kgOqax^}t{>^FG}Gb&k>W_wio$BGz>5Om^rV{Gq1+)Y ztrEO!TuA?;YOjb~jGsP0)UZmL56L*=JLm)@f$(tgiE3HPL@31UavA zpC#KKQj^1*r}wYUO%(GvH0aT#@~FHH&t)XG6?&2=TURr(zi8mra9!I1c&A>~ri2vE zLs}&A5TlBYi2$Pmr%;v9>g~#oo<2Tn3{6+EDKjjtf{GPm(coY|1hp_pn-s<1b_#`9 zV6ea-TNh!``a6ItZu}FLS@MmUKHVCo5w;95HmG0V$SsBiH>9{2hL4<|A^JMpVRhZb+r=w`9KB3n-PW$PyIiiLnC>-(3ml0 z@=-znX$d&Z#W196#OmUK8M(&!3vgNjai#T&Sn8M!njo`83^zB5sst07kZY%P%}>e! z?qx3}GD9U|RULX1a*3zuM$(9RV8|Is6r3!^RR#@vrm!WvT)3TI%Zh357}KwC#maxmB7lb@Wl zZNn@_-zbVsD<99?>5r=Z*AoimDHl@o}UwGkvOa2b}w?Y2NS@0Wkz=k{E=b$w0DS2oZ%oFgmso zRfn=s(;MWf4E?&&s$bo1u8Qt~ZQBTCtO=FPfoxbrgFlH>X$AKzYI2$BZXCV}2 z1{w}xWr2~eRU{$vBh^T(d}<+|t*p|qAQjzJ8C9_1ilS`7kPu-BQZWdFg61VsoFPG> z?}1B7=DBr!tKqFM%Q0R`hWc7K^~x@5t181<)v-^QHoWEDD}P{H*Zucub-z?p>YH-S zjiGn|#Y3oIXbS}^pKleQ>+y&Ga$%Y+vw{NfGQwsiS=Bk<+=6 zNg@4EeD3#o2t5A)kQ>3+=^l)9y^co5qDe6XMjFCoV8Nsw8&|aJ4oRpw$67~H2`%h) z>fp&D0y&9PosH3<35a>~Dh|tGBdQHYUfHq8D8VdAM3OSyDD6Fh6&Dc-3lm~K(0R?D z&Di+`VS|dRGrasAk)HRkDcP2^kdQL3iIt}^2xA+zG?7p?2K$aJL$x6@?_uBfx7QmC zfn+ea0mZyB2T=wDZNRD_0Yn(NWH^C9YD)qxE48bamfqJxoNI+7C)=c2cM8i8{s(e4CubI+)=W*eIk_Me6+=wi(v6*=D1<1c_9 zf}0G@SvaqW3wSY6=l;h$(CDOjm<0c606e zR>Rvu$9hfW;8uM8NHeIznN{UAefi>CMl+R6S-EQK{^>cbzbVW6^CMQu>%SADCszBT zgD~>_NT?!`%14q6<^oqw%*+@np{VZ!JqdOe&e2IQ3C}CQvP~on3zp50wk^82=rsEv zlGD2GnUB67ps`ZYJxr&>b|HPrACZ zDY5gkrN3^<0D^kXp-(>1h!N7XxE>Ry+$^bomx0-H$NE6|y*n^`WU~>LU_v5e8So;7 z*bgDN4Qf?Yqo;`~4sAL7^*;hlcI-rU8}q0Y^h~^Gu)S+ymo&df6(RemMaV z=V6k6mGX729OD8^x+snG0IIZ16$iGlRNF%{eabb|P(uyxv0_DdYXnQ#DOjDCd$=l6 zd$DAb7RQKV2r`vp5oF{epjHc?-6g}RiWQyb*&IO!@+t+}HW9}mneRYFVOgJ!5X=-> zOQE%CG)bm^YmN!}RM3Khdpdbx90`O-a#E84Q4m8IFG0XSUAu%M-Frq6N?g2h;{9B!fsc3~7*1(?i(rG?)iOc+r4J|H)^4cV5T&GgyU zP(uwhydBP6>EBU1MTDx{M=xt;-n-l%MA0RVm4MmuW~82G$?j zvkSa94wFXV0}g|cEkRVC8WN^%gH|1KqnjeXs1x5c$Z|out_wtDp8*REEv++smU9mj z2;1EGV3cjY(<`1tmKVWE7j4Aw;}{l~u!B6%bG(IJcSo&SRYMImR7u6E0(piAyv^j% z`R3hma}3-b2{U$>D^04H)Gb0Pjjw;LV8v4bO)=Oe`hq50+R(j z6hcV`u9OglL{bLuPSzg!8fvIv_TpVDPc+3shF@jhmG|>!%yC%RJ!Z?oZ7J4@1YD3V zN2mzl`}>FAC#Fc_JCmy^@$%WzffTtBMG;sW!7UU4zl10*fH4<>bXU|0G!n4v6hgm< zxDrhHJ|D!33gr!Lp;jJCy!YuUmG8bAMwCTqWKI9xU0ZfP@*w8BlW0sDfXATX5T;0y zb{|ntCRnka_^cjcrAh6a%0bzWV$4M?#zk>MaT5v89*I>HWl3nRsPe*vP7V&D2*Nbs zM;@5X-uSum0847Y_M^({JB-+Ghw|H?$lJ&qgHgYWC8|?|s){{3ab(YT2cG#Qj=l10 zv=|QjqKk}e!<7aM9kxobN+e##>_U)R2LvrD|N3gc$Wd}V<+Xu{qW)ZyA!(BXIar+1 z&sk~U1{~V6>B!!fFxY(p&Cvu}lLB6T7%7T=;T(C_2NLx#j0{Slf?=nDl3zW7BEKnKnSQLH_z%xn{oX?S*~Pj#+h(W6Zm4|!oh);1CT0ya)KJ6hBUY9;DF@rC7D zF=ze*XB&|+yE+Ng6j(}Ed%mY$f993Ojz#N^KK}@E`4OF)l*ra&WMl*x5?)nOzh{fw zNR6HRquyWs@Jq%-A1vMKqSmH?n6yN6q=5n*Q$Q8V2$5Gn?B);_$Kj0+!Oe{zEhD5& z3F+lvnH+{P5XKQgF0=_^Ky5%PjYO9Ca}}8ehNWeNksjT$mSI^>M^Bq!7*N85&n#r; zEzq7zGkvOa6=t?+k8jp(>Ca6fAsXS!2ti<=;45TJ3yh~xBULrjP(zhythi4j&yge# zCcqCROcEfIza2NgO$(+?Q67oz_xp#dom8C-cEzpNso?|P%JiJPdt~?|3Sl0tRvMh; z%C7a5cIvDFm-g{jpNG-=I_gCTqmpS@uB=hp@{My%*%kt~02!8Gs1R8ep-DwBxq_iM zCcGR}Dg!@`5RgH=orWuI6at30Ap_Rj_UKduLRG-atsCn-4x3uKVWiu>oHLk)flvte zQeZ{rikWo?nd5M>^*v5YCvv#~Fv~{C58xXHTr)&6k%CM#A?iQ1!)RZ0-%Out4K>tI z!@Co!SlT2vqJ@>(e+Ek+3<5+kMN5S6`~Y4M0AckxX-_*+jZZDQcKzL@le-c2=1?e< zkV>Q=qS8f5SGj1N1p2YROk#|>qY-4F`5KR_GOt1B`NM=F8-R)VqVnyT5bUg6)UqZf6g6-vD+Yb36 zKng*`OD{4M#yMkWJey&eW3F35OVUAD3czV{kn91%E-S3s)_y{C@r!0ga>;RvD4Ua3My zS}Vgc(KxRi5Z3TapXZzhRjX>Kp@tgf3b9IeEPt3?d)wCL1W;Ej!L+%)&WVU2)43c- zr4L>DQ=Q#l#=J*V@z7?~vUJ0VV0Ew_wTjA3*xU zAA1PAeivl2>YN;N13hSsJw%=do=QTIc1$SUDiu}&Mq?Xb%!b!L5f3t*rN_S9o#&FG zlUT*vSL%#T6%1`*R`pfa=F)IW4K>U?h*EhAKASWyvz%CU-109ze)i|e9oWWM*f^ic zF2Wom5^>4Su$tf}yB7b8KYq#^A6iH08@4Izm~wn9t7N3>4x_34Da-=I1+!=6zx&*- zk)9(P>N6(d$cOTMn300b1Z>G*kSTpEA*kiYBqCBE&<+C=3liM zcwTC-#>L+RK(|>9qj|}<0eo8rWOJ_rmMESH;11~)Wg*OQU`iw`ZJi@Y(+qs-v8p1@c{mGN0;d_7B0yXaoVm8Id2F*h!AL{9RHCOCC2(pN)+Xp`sG)|7 z9ET=-Qn^$%bv3*h7rj9mPWEs1hE8m!$cc;nZ`Uw8p}fJZsB)-6&(87V`(RpvYE}qo zIFgZWi8N#}O3J|eWvjswv%vAYaxv3I!wR{Y3PqUGJ&=ojIjX2O!CONOHC#;SO`To7 z_SA4mDpqD)yRL$$xM!ydyFPhpEeb^_{|a zZ#Uq(kfkDI;OpnXGEF#+17X2ZH^-~WHT?piyuR)D_4hmL;t4Q8^N|$eq#!tljB+p%SwvPH z(7Ec+Y$8@?O3q|lN?D$|4On*2pORfMr#5I(Lk%@tY)HG-2AoD>rG}4u0m!dKZfv9f zs|Op7?ca^TlSje4JknetWf@?BhbRmYNAyf^L^P^z+EA8(@gsXskP1B2xNgA}>(;45 zKl>UMU-gG9*?I5#J+~U(9cpxN>m&d1f8XB1JtSM2(71RpT34+AU%m`zTkt)a09A3< zIxxE*um>2Qqa;iE?PGg?jV4xv3~j{10u`$irZ9$Y@ix+L@erBN($GMH~BP#oBcgAX2o z_v7z=cEjd>0d@M>KchAwKx6%LK+iK<5W9;*qli*80)z~85NINAlF3m-ujVni=QZ^I z?8!}?*W9?N<(5sr^3{Jr6Crc1y>#;E-B}TWdquF=LM$rb(s2O8OklX^W6h1XfjP59 z2grSd(q@JY-;Llfz@ZATl;BLEFgc23-F%=lxe_XDoawWzp@tgf5EM|{ckmw1^H4X_ z=Thy&N=3ap5anCY({<13hwew;-rZPSpF|W+(hL=;)e(g*WJD9*F-*&V?HHg3pK%4# zAVLKQ2E-~ZM}^23B97s3(zDRt$isCfz$8b;$$~pE4*&cIu=eg+zwEZ(S&wUO`JBoR z2$q?HRg;;3in5IP5UKaQfK(!jRyUwj9CejIf&Lc+07j46#MAUQbYJHg1dg0(N{#;k!T!m*lNQ` zLCB!|WvO7B9N7d_v0`9i2=I$5XZmbwsNuap6^1uMx}D%Lm5lAO-cuCI)2-YuS6`#7 z-q@?KLI08vrP_eT1wzqdN{{c9-yQkm(POMA6gbu!xT4z$+aA#RTN*wKi>q+#g`LB% zK8fOqy)ez%Lw~`&eEAUz834MFxgh7hoY(5~KO4=p)|WiSXLSgHiW^ANi};wXrZs9*l{m#+TA|Fj!GjaXd_#DL=kXow|jQ|O5}6~$0d2~k*rj17drFzOR^u(^-I ziCs7_at!nKt^9n)jURw29NNIrtN#Sm3@+`_-cvA%MsbFOX~GRcO|0lr6EFwa`743; zrC-Htf#5!@#L0jRLOE5|n0CnzeIFJRNbm%T#W74AJBa#gHe^-Mx1CwlPl9Whdr+nR zJCW1R@wRhM~yG7zoLCPDeP^aA6%i zT7%0mW(-j|GnP@0sXS#|-_(j?p@7iy;RwKy0hC(+3yZi~NLaC~#oAm5u~L5Db_{j@ z!O0*0VAHAHPomW*Ar(%dlpDjcMJ;eABFLB_QVL!aqYz0%6k5v?h^&;(l%q*2dj9Ch z=t-k#B`I|rhm&yNmx>7UlQ3c*X%k3V7EF4s=!wnPI62abI=cyO_kKL{?XRsL|IuHm z`r9`D8JuJnt@w=3vDVb3f+32YG*O5RxB*oU$#p!^k3Cgx(xyPJ6&}g#NI}jO}}EP+0rIEPYn0%INfy=^8<-VU%?SB42rQy*oaLFgIejay^i|W>wt6@t9+O1MZ1*ojb@; zJHFRrOx+_!QXN{JV{PBUX}4V&fT8b|PV_>C5iG@!bW9}Z`=N`JP)H_I$QMEctR~|; zFJh&FuFqib@ZAT#``4WlNA_ZA#sDi7AiX>ij*0%!0fYf%GG`!C^@x;-u>dG>2Rz#h zv#td#oy#GTStRQl;G{Feu2A$`l=1~c#XN?)j=*#afI9){=TM4r2%|i>Ux12SD0c)c zjZK)00+`V#Y&XWs5B)XT_dWf!)!YAVj77`7q@vU9P%J{oVwS8e9`6};`cST}eWM*Z zNFag}c~E`{u~$G;Dni5sq#P4baSTcbG}<;&X#@Fq7{yZ)7#!$A{lw_b6&ttiRK?d< zgC)D*ai29-56LQ=I`L7us3aSjfr);oID*BP4qh%rKx8*Uth^SpPPEU>F)N)xFb>^T zH%t@Uv~<WmQHscy8j zW+3Y8Ffus|X&Fejw_&^#Vl-DkWM@&|aV1t^kV!?RRXP`Um)S#}nf;= zAQ;mN7tsK5hFNLn>Duu;jzpYDhGQ^l{4qWF!YUqkk}-Oubyg}91|rW#p;Ux}7l0)4-}A@ zzXZ!}xE`(7eE?Xo?t2jFLoBiEH)>MOn()(ACRIFr*RsK`&&Kvu_ccrC?cIS)$t~ z>2p=;+m7+me-Iqqz3IgMXVHD)5Zc>1Ffniv-q;B0%n*&K3{1m98}G;|udm3Q>d^EQ#)r|AN}@Qb(3om~z~a}9d>UqR>%>j_34M}UmMBLij}fd?fF_npGAS6;&Mx&=D`)ZQC4%sMD{VB6?p z|AZ(%0@ELd&GRs&kGcdy7)>H#4gwZH(QuXkPz8EWPGtG+am3iuzqZ>ImEm^Uh(Zh2K%$P`_yH zShsx1$}e@iz6VEMd;;AQeMrU(*=!?>$b~FUB5eQ$GY|)ZIP{DAF?#sbTdw}CPu!v= zPlMIf$kl~_B{dm(K!ttVSFOEi$EsZ4)|Y9IR5<45r*L= z)%(J)*n*pGpXqbI?7+C6H!`qTh(MXJIEQa2T_Ny!0+VKT8d6)Q2R^s8riN^FI8}G4 zmZV#C93c%Ru?x8NE5BXv5O%_WY1Jc)0IyI2i%FPnXd!i2siIWsRP-mcR!CBdkjYu3 zT&kvHIagHI0m=KW(q7fEShTh(fygBNG}rA{lQVs7VS$enY2fP8m<%ZZUcjUcT`a*U zN=u1KhUw*tw-77k9sBI5zx!6l&@)frnsftPIf99>fQFO}7Fe*&dK7%1-?DJ!Z{3L{ zTkiTpW?wv)^omCk-M*~co}E{;uKw=c$A64q_yi`$Pov(DXwBF_m`7B|p~*gs6icCW zd>BXmVE~J7?fF|Zdc1)()uM4O6wE9WEc#f5-FM^k>tE}7<`>D6FZ~)5xjrmPSxChS z?)VVuEFfV?xQfFG3poAEk0A@=Tblm$|4L%-Sx>B3{Z)Sq;Ez?IZ##rC5=z=Dq_j~i zt_+KTVJ^LvzCSzBxZu0#PwZHk3ks`G?RyC=^t^ZzDCS3zwHVUeMbhnoEluQJ{t>z+ zyEn;Qf1}tn8}8LrIG_8pnVIRkG1n1{jjawlufgG-V_=~V3lj5MS}{>{k>hnR+m;{- zyewurn6wK<8aXS4=4b*Y3N%%Vm@~L0;F=r>QL#aIk5KOEyD)Kjr?3o&hIX9n%_D2h zL$a+IgMJA{W#W%2C`1DE1fY6@g^Hm*B(;Il%7FJGKPtd#8889e#e zm;A0HSimKW@eyR26j(Zq@u57jnR)0N^}$+~;G_S`{{l2Fe)eLLsyBlrI=-hyx>Gl- zSbfWLfBB^yj-dYgF#0FDQEw?UH6`^JXqv|;2_IwQT{!T>KcHct`)g|7qo2pBwVz`4 z?5~>4W-zn!UKI`WFZ;xT&5IUv-hKSJUt+T7C}b3)A=L=xPr_ybDNdKZB7`5If8X;+ zH7&YJjlDk18n5_sQ~_AJI$D=!gDQfm^!kSRcWhfdSoCpt{|i{%+>FMQgNdGAIE50j zS<+A#z=Vg@r&%^QIQE5EATzU{SSgXx8}Y{fS}@QF{sWYJ=XI0?U$ zg(-l73+XgJN_L;C}nb+;Y5xpm21k(-B^5ELM;r_ajSQd2)nD(i{>Tg!xqWKEshj7yfP z?@0PIQBD`t!+ANbQhd;H+v{MSwSQ#qtl^APwG7|$`-PAf`5 zNCSZoq6jhh8b)8~L!x*p+t&NRAFAAuFG8k{u=?7x-~}Tf-=z9o=7x{>t2!3l)AhrD zK=0uLFp|`#w;+@cPkAW09@5Er_@x4lJn=JJX{J7--0p6csO3IR8|?aBShn`dS8et` zOP)Tj9oU5h2GG$yAG|n@B6(XmCR$0G7&1%@_F(Ag$B=*R5YRCHGs{2v$#K>F(x;G2 zcEL%U)E-${y{fOS5lnMj8wHY?pa%C@qMRA2+~Lg-(xytrjNN~IUX^oxbGin79>+R{ zN%@0YDO{X#6P4G0C!#pX(wAm>mDvmKQ{K=RSd@hf8_Ad#Do#?1kaK&WDF&ETUVvsT zsUpZIYsV4nSiz3T!-y(L3>Fu#BMzGuXOcdh2P!$1Q+7GOfA;s3KeXjsqQ-r)8o3yE zr3Nab3~iaRDpJvjdo;O{^nMaWZMsUPprPLmhxP#c<6SfryZ}Oi9%lVx`7T--WQ$*8RwjI=tT3&|((gP4;14#z8UX z!(=8xmO_}BhpT_*UjaAX@kNwa%u<)=foq!gF!FsnuDa{r_w4_xuXp%^-7uAnu$Tw9 z0naT#SrXaCB8u4CW;b^bMWB(O>@1 zVrE^nD62+jOngd3qL?HtKf>p~2XFT;v_2$!;Rh(a^oX85i(Y%|i^i3AEWRY?S&4yC zg`q8ogBDiT@jXo(=yU_W9o2w1P8v$WkPS*DL4LzNKxW`i&3>uQ1wsZ0nMb{q9!f zzP=7Bb%Z5nm#$I8t_m7Q=@> zaU4f}`H#55$$SeyjaW_d>qkv?WpDYtKiM((^doByKJ_y=r4rigdL)zwTgEV%LL&0D zv$bJZm}e^td)IINmzEGLY&IlmNJRae^q`7D;`R4vfl8#%HsW$J}hMz~{-Jk(mC3l7OzWdQB~z0RqXO2_ zDnW^i9xQJVabwCULN_6*QnIb?-o3Fj`|QX&M)s(}8$G}+&M0+pnt3UcUn%=T?As5SxmG`QkG>OO_ zz4!~?ZhQI%U;9`bj3SgS;BlBrAY1N00ah>*Q@eJ;>^Sc> zRT1PTg?5!L1G+Ku@H^K-%K^RDE3g9U>O4d)kjb9L77r)E!LBhiH-6<47O zVJZt23*ju_+8YWzGj0SmZo%VOR8T;u$imY^jXT=YFyJNx&E=#lTGaUPCL~FK<(&_9 zwk_++P7R?D*vQg5F*X3xhHcm|A`=LOizSMnt=rwtKASVLSsX2gXlp6J4`XnvfSJTZ z!k6Ka>u&;_u3w)C3cJ}gY*PpK-Tm%w+`jAYKm5$aMksX*i4ju}twa>gKw1ebCxb+{ zDkAWzi(JM)#S5`#+d<^_K7GlWcU^Ufa)%Gh8fB)O0<3Ax9cre($2l~-d207xppps1 zNmv)0FrXu!#|wimB3Zrzz3ciu4InM7-b7gY>|nV&(uP%U`;xPAb&nIc7xs)FzRG|3 zMZDbqJPr(z>_0*=TY&I|c2idL&!V3r6&AghjQWmMEY-j40-Y$V>JH7cYk>uWcF;&V zK7W^L{(7ijq@6fA)nM2KM8oZsgvx3MF#?1vK+T9yV+BNHxu9%7nv+C9nJhNJ7?CkTuL{jk!Zu;i04sHJ>M*gt} zT{g$^W)og@Qb(~kb~8e{0A%tIk${XMWR*lWX9z}~$KKx#V&*T8ZymVrM_bRmXk7_s zpM56`;nwAaS{>mrQU9Py`4425Z*#7@scU+)w7xR36ONrlNQQsHLgH7^-IB-T*dY41 z-G9|WFLkNFnog)-RXn^2t6Fztd-{0a`!;s;h5l+xOa5YG78ZtjzS+NN|IMPqWyzw2!g{5o@=^ajLJ0k+S*^jDaI@Gb zMCEEn)Y4h19NZ}cXqAa4`Y zqA+c>z+?_N;X*}i@Vs6KdlbBB4|85$wUT&;HyOE=XG{xCjrDKo|IN=gkL-RFt1>aN zGbME8Oq9pQk#Q_oc?XJHs8Pn9AHaxdX3Nmdvf-@#isAx_R8Xd=!zpDwJ?VJ+gJwDC zjM`jur>w8zbbg9!ra@sYG6;eYLU2ThL}FT~@f>EXCKS(oJJxNw^^?qQj8-KTxszMJ zw`F+e!)Or(cw9q<8CqQybAgiGj_`sHQDW{K_QvX--9R-QR?|~6?A&FW+w_%C1pEc=3izSO-Z6~Y;R5ZSxwf%QL3xaWL z3TF^(`Pv)RvAwdmYW1gIdh~bb5g8mFIf%15yAgO(D7$4y!NE-jM%DojISduUbJ|pccn4EmFIjXgSo2tGbMvWaccc%SulrQh=~66JDjNRgBbb2}28I zWWe)XSjIy@dc9~6$i_mw_|TS-{V$<5brc0^wV6oog^i51Y?!O4 z=J|5axt5(9T9#hVuR8fi0igoq;KF19ncM0eGoOWs;s{X9$nhAuP1s2Ey#2-%>pr+; z;E@NBD*!T@&_^KLjSh2G{Y+Jk zxbL{}%bP3DJ$lvtryfDgABRXPuw)Ds#)yIll6hc^cB;rD%Y-HB^-CNii2MLvRD)6s zxm@n}Ym)+sAV=pqlnx#rFS1THUp%207zFipBE>by7_~$qObi5J2;ZZ9)y7gS>0PVA z=dM>fpI9Hn!>|y+l##9{v)V<@$Ke-tpm^mq)PGM4tCW`v}`hg9lC;z;0aL1p(CWi5rj6yLNLIzU^!WoE62Gq^-d><;7FcJ=%3p8g7u!M

M(ES$FLaXsm3tl2}svQJamw(7j9nYwWg$`q@<+$KUtK8)x2;| z;$Bidc3yVv2E4NG7I)VZIP{O5NK}O8Vmqp}NGGT>?HbM@z!1s=ZWtp8By8c#7DBXR zrDMEj#es=7brzgP+^LO7cGV|$j12<;Y=j&zxq%=Kv@cA|n-TE>eAocDj-ml6Ui$vM zyPkNkugU5_wK4|F%0RI^%924kS&WU1E%XvoQc_Ylr^5xKBA{Urs;RZ2f^L7QRiQ4=E{XT=kKODQQSDJdz9&XShB zD|c)Ic-R5u_vlS28FWF(oR$zX>C}@(wWoqTmz4!5ftaMmWMgFndbm81Iqm(!7zL8m zW4*~YhaOSX3Qm4k!quKYbDlc-rRe9C(9&fw>tz+noc4dPTX&#k*>;w1TwfS9zVB{0 z!bRX-2u^<=@ = { 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 @@