fix dockerfile unhealth problem

This commit is contained in:
王性驊 2026-06-24 14:04:54 +08:00
parent f2736ced32
commit 7e58bdba45
21 changed files with 869 additions and 200 deletions

View File

@ -1 +1 @@
34256 19005

Binary file not shown.

View File

@ -3,7 +3,22 @@
> vite > vite
VITE v6.4.3 ready in 146 ms VITE v6.4.3 ready in 134 ms
➜ Local: http://localhost:5173/ ➜ Local: http://localhost:5173/
➜ Network: use --host to expose ➜ Network: use --host to expose
9:30:54 AM [vite] (client) hmr update /src/components/OnboardingRouteGuard.tsx, /src/index.css, /src/components/MobileBottomNav.tsx, /src/components/AppSidebar.tsx, /src/onboarding/OnboardingContext.tsx
9:30:54 AM [vite] (client) hmr update /src/index.css
9:30:55 AM [vite] (client) hmr invalidate /src/onboarding/OnboardingContext.tsx Could not Fast Refresh ("useOnboarding" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
9:30:55 AM [vite] (client) hmr update /src/components/Layout.tsx, /src/index.css, /src/pages/PersonasPage.tsx, /src/components/OnboardingRouteGuard.tsx, /src/components/MobileBottomNav.tsx, /src/components/AccountSwitcher.tsx, /src/components/OnboardingBanner.tsx, /src/components/AppSidebar.tsx, /src/components/AccountConnectionMode.tsx, /src/components/islander/IslanderCompanion.tsx, /src/components/DevToolsPanel.tsx
9:31:02 AM [vite] (client) hmr update /src/components/AccountSwitcher.tsx, /src/index.css
9:31:02 AM [vite] (client) hmr update /src/components/MobileBottomNav.tsx, /src/index.css
9:31:02 AM [vite] (client) hmr update /src/components/AppSidebar.tsx, /src/index.css
9:31:12 AM [vite] (client) hmr update /src/components/OnboardingBanner.tsx, /src/index.css
9:31:12 AM [vite] (client) hmr update /src/components/ui.tsx, /src/index.css
9:31:12 AM [vite] (client) hmr update /src/pages/PersonasPage.tsx, /src/index.css
9:31:12 AM [vite] (client) hmr update /src/pages/ThreadsAccountConnectionsPage.tsx, /src/index.css
9:31:12 AM [vite] (client) hmr update /src/pages/SettingsPage.tsx, /src/index.css
9:31:18 AM [vite] (client) hmr update /src/pages/PersonasPage.tsx, /src/index.css
9:31:18 AM [vite] (client) hmr update /src/pages/SettingsPage.tsx, /src/index.css
9:31:23 AM [vite] (client) hmr update /src/index.css

View File

@ -2,6 +2,6 @@
> haixun-master@0.1.0 worker:style-8d > haixun-master@0.1.0 worker:style-8d
> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts > . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
[8d-worker] started id=local-style-8d-node-34347 api=http://127.0.0.1:8890 [8d-worker] started id=local-style-8d-node-19105 api=http://127.0.0.1:8890
[8d-worker] claimed job=6a3aba8ce9d72622130957e0 template=style-8d [8d-worker] claimed job=6a3b33a151becf68faf9ecf9 template=style-8d
[8d-worker] completed job=6a3aba8ce9d72622130957e0 username=raymond0917 posts=12 [8d-worker] completed job=6a3b33a151becf68faf9ecf9 username=petopia_tw posts=12

View File

@ -1 +1 @@
34261 19030

View File

@ -1 +1 @@
34262 19031

View File

@ -21,6 +21,7 @@
## 設計文件 ## 設計文件
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。 - `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
- `docs/scan-placement-plan.md`:海巡獲客(流程 B— 知識圖譜、Brave 擴展、雙軌爬取7 天重點 / 30 天補充)、產品匹配、島民交接。
## 新增 API 流程 ## 新增 API 流程

View File

@ -0,0 +1,512 @@
# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge GraphBrave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
## 北極星
海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
流程 B 主線:
```text
Brave 知識圖譜擴散(周邊延伸)
→ 衍生雙維度搜尋 tag相關詞 + 近期求助詞)
→ 使用者手動勾選
→ 每個 tag 雙軌爬 Threads相關軌 + 近期軌7d 優先 / 30d 補充)
→ productFitScore 篩選
→ 島民協助撰寫獲客留言
```
## 已拍板決策
| 項目 | 決策 |
|------|------|
| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle不預設全選 |
| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
| Brave 預算 | 中等,每輪知識擴展 **1015 次查詢**(不足可 supplemental 1 輪) |
| 痛點 tag 候選 | 圖譜衍生 **≥12 候選**,其中痛點/求助類 **≥8** |
| 流程 A | 保留 `style-8d` 捷徑matrix + 留言收集疊加於 Phase 2 |
---
## 根因診斷(舊系統痛點少、關鍵字不準)
對照舊 Next.js[`lib/ai/prompts/research-map-placement.ts`](../../lib/ai/prompts/research-map-placement.ts)、[`lib/services/scan-tasks.ts`](../../lib/services/scan-tasks.ts)、[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
| 現象 | 根因 | 新系統對策 |
|------|------|------------|
| 痛點只抓到 12 個 | Placement 壓 `suggestedTags`**24**`PLACEMENT_QUERY_MAX = 8` | 圖譜衍生 ≥8 痛點 tag + supplemental 補充迴圈 |
| 關鍵字不夠精準 | AI 憑種子詞推測,無外部知識 | Brave `knowledge_expand` 建 TKG節點附 `evidence[]` |
| 只有「最相關」 | Recency 只是加分,無獨立近期軌 | **Tag 層**分 `relevance` / `recency`**Crawl 層**雙軌必跑 |
| 沒有周邊延伸 | Brave 只做 `site:threads.net` | `knowledge_expand` 做領域知識(成因、懷孕、換季…)再衍生 tag |
新後端原則見 [`AGENTS.md`](../AGENTS.md)**複製模式,不複製舊業務**——移植 Playwright/過濾規則,用 Mongo + Job 重建。
---
## 架構總覽
```mermaid
flowchart TB
subgraph input [輸入]
Seed["種子詞"]
ProductBrief["product_brief"]
Persona["人設"]
end
subgraph tkg [知識圖譜]
ExpandJob["expand-graph job"]
BraveK["Brave knowledge_expand"]
TKG["topic_knowledge_graphs"]
end
subgraph derive [Tag衍生]
DeriveFn["deriveSearchTagsFromGraph"]
RelQ["relevanceQueries"]
RecQ["recencyQueries"]
end
subgraph select [使用者選擇]
GraphUI["圖譜 UI 勾選節點/tag"]
end
subgraph scan [海巡]
ScanJob["scan job 每tag雙軌"]
Posts["scan_posts"]
end
subgraph outcome [驗收]
Fit["productFitScore"]
Outreach["outreach + 島民留言"]
end
Seed --> ExpandJob
ProductBrief --> ExpandJob
Persona --> ExpandJob
ExpandJob --> BraveK --> TKG
TKG --> DeriveFn
DeriveFn --> RelQ
DeriveFn --> RecQ
RelQ --> GraphUI
RecQ --> GraphUI
GraphUI --> ScanJob --> Posts --> Fit --> Outreach
```
---
## Tag 產生完整流水線
Tag **不是** AI 一次吐 24 個,而是五段流水線產出:
```mermaid
flowchart LR
S["1 種子詞+brief"] --> A["2 AI核心地圖"]
A --> B["3 Brave knowledge_expand"]
B --> G["4 合成TKG三層"]
G --> D["5 deriveSearchTagsFromGraph"]
D --> R["relevanceQueries"]
D --> C["recencyQueries"]
```
| 步驟 | 做什麼 | 產出 |
|------|--------|------|
| 1 | 讀 `seed_query`、`product_brief`、`target_audience` | 輸入包 |
| 2 | AI 產核心 questions/pillars/exclusions | 研究地圖骨架 |
| 3 | Brave 1015 次**一般網搜**(非 threadsOnly | snippets → 候選節點 |
| 4 | AI 合成 TKGL0/L1/L2+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
| 5 | 每節點壓成 28 字真人搜尋詞,分兩套 | `derivedTags` |
### 雙維度 Tag相關 + 近期都要)
每個圖譜節點衍生:
| 維度 | 用途 | 寫法範例 |
|------|------|----------|
| **`relevanceQueries`** | 相關軌:短詞、高命中 | `敏感肌`、`屏障受損` |
| **`recencyQueries`** | 近期軌:求助語境 + 時間窗 | `敏感肌 請問`、`換季泛紅 推薦` |
- `recencyQueries` 在 Brave `threads_discover` 時加 `after:{7天前日期}`(參考舊 [`scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) `buildPlacementKeywordQueries`
- 候選總量:**≥12 tag**(痛點/求助類 **≥8****使用者勾選後才 crawl**
---
## 痛點 Tag 保底機制
解決「只抓到一兩個痛點」:
```text
expand-graph 完成 → deriveSearchTagsFromGraph
IF 痛點/求助類 tag 數 < 8:
→ supplemental_round最多 1 次Brave +5 查詢)
→ 追加查詢例:{seed} 困擾、{seed} 求助、{L2節點} 請問、{seed} 推薦
→ AI 補節點 + 補 derivedTags
IF 仍 < 8:
→ job 標 warningUI + 島民提示「可重跑 expand 或手動加種子詞」
```
| 指標 | 舊系統 | 新系統 |
|------|--------|--------|
| Placement suggestedTags | 24 | 不沿用此上限 |
| 搜尋任務上限 | 8 | 候選 ≥12實 crawl = 勾選數 |
| 痛點類最低 | 無保證 | **≥8**(含 supplemental |
---
## Topic Knowledge GraphTKG
### Mongo collection`topic_knowledge_graphs`
`persona_id` + `seed_query`
```json
{
"seed": "敏感肌",
"nodes": [
{
"id": "n1",
"label": "敏感肌",
"nodeKind": "pain",
"type": "core",
"layer": 0,
"placementValue": "high",
"productFitScore": 95,
"selectedForScan": false,
"evidence": [],
"derivedTags": {
"relevance": ["敏感肌"],
"recency": ["敏感肌 請問", "敏感肌 推薦"]
}
},
{
"id": "n2",
"label": "懷孕嗅覺敏感",
"nodeKind": "cause",
"type": "cause",
"layer": 2,
"relation": "可能成因",
"placementValue": "medium",
"productFitScore": 40,
"selectedForScan": false,
"evidence": [{ "url": "...", "snippet": "..." }],
"derivedTags": {
"relevance": ["懷孕皮膚癢", "嗅覺敏感"],
"recency": ["懷孕 皮膚 癢 請益"]
}
},
{
"id": "n3",
"label": "屏障修復原理",
"nodeKind": "knowledge",
"type": "mechanism",
"layer": 1,
"productFitScore": 70,
"selectedForScan": false,
"derivedTags": {
"relevance": ["屏障受損"],
"recency": ["屏障受損 怎麼辦"]
}
}
],
"edges": [
{ "from": "n1", "to": "n2", "relation": "可能因" },
{ "from": "n1", "to": "n3", "relation": "機制" }
],
"braveSources": [{ "query": "敏感肌 懷孕 原因", "snippet": "...", "url": "..." }],
"painTagCount": 9,
"generatedAt": 0
}
```
### 三層擴散
```text
L0 核心:敏感肌
L1 直接相關:屏障受損、換季泛紅、刺癢
L2 周邊情境:懷孕荷爾蒙、嗅覺敏感、壓力熬夜、換洗臉產品過敏 …
```
### 節點語意
| 欄位 | 說明 |
|------|------|
| `nodeKind` | `pain`(痛點/求助)、`knowledge`(科普延伸)、`cause`、`symptom` |
| `placementValue` | 建議優先級,**不決定是否海巡** |
| `selectedForScan` | 使用者勾選後 `true`,才進 `scan` payload |
| `productFitScore` | 依 `product_brief`:產品解不解得了 |
| `derivedTags` | `relevance` + `recency` 兩套查詢詞 |
| `evidence[]` | L1/L2 必填Brave snippet 可追溯) |
- `knowledge` 節點:延伸話題/科普靈感,**預設不勾選**;若 snippet 含求助語境可升級為 `pain`
- `knowledge` 不強制進 placement crawl除非使用者勾選且 `productFitScore` 達標
---
## Brave 雙模式
| 模式 | `threadsOnly` | 用途 |
|------|---------------|------|
| `knowledge_expand` | `false` | 建 TKG找成因/周邊/知識 |
| `threads_discover` | `true` | 海巡時找 Threads 貼文 |
### L0/L1 查詢模板plan_queries上限 15/輪)
```text
{seed} 常見原因
{seed} 什麼情況會
{seed} 初期 症狀
{seed} 怎麼改善 困擾
{seed} 求助 推薦
```
### L2 周邊擴散查詢池(從 brief/受眾推導)
```text
{seed} 懷孕 相關
{seed} 壓力 熬夜
{seed} 換產品 過敏
{seed} 與 {受眾場景} 的關係
{L1節點} 原因
{L1節點} 困擾
```
Brave 回傳 title/snippet/url → AI 萃取節點與邊 → 寫入 TKG。實作`internal/library/knowledge/` + Brave adapter`BRAVE_SEARCH_API_KEY`;參考舊 [`lib/services/web-search.ts`](../../lib/services/web-search.ts))。
---
## 完整範例:敏感肌 Walkthrough
**輸入**
- 種子詞:`敏感肌`
- product_brief溫和修護、無香料、適合敏感/屏障受損肌
**Brave knowledge_expand節錄**
| 查詢 | snippet 線索 | 圖譜節點 |
|------|--------------|----------|
| `敏感肌 常見原因` | 屏障受損、過度清潔 | L1 symptom `屏障受損` |
| `敏感肌 懷孕` | 荷爾蒙、嗅覺/皮膚變敏感 | L2 cause `懷孕嗅覺敏感` |
| `換季 皮膚 泛紅` | 季節性刺激 | L1 symptom `換季泛紅` |
**衍生 tag候選勾選前不 crawl**
| 節點 | relevanceQuery | recencyQuery | productFit |
|------|----------------|--------------|------------|
| 敏感肌 | `敏感肌` | `敏感肌 請問` | 95 |
| 屏障受損 | `屏障受損` | `屏障受損 推薦` | 90 |
| 換季泛紅 | `換季泛紅` | `換季泛紅 請問` | 88 |
| 懷孕皮膚癢 | `懷孕皮膚癢` | `懷孕 皮膚 癢 請益` | 視產品而定 |
**使用者**:勾選 productFit 高的 4 個節點(可不勾懷孕若產品不適用)
**startScan**:每個勾選節點的 relevance + recency 詞都跑雙軌
| 軌道 | 行為 | 本例預期 |
|------|------|----------|
| 相關軌 | sort=relevance, limit≈12 | 高互動痛點貼文 |
| 近期軌 | 7d 優先,不足補 30d | 一週內求助帖 |
**合併** → gold / recent / relevant → `productFitScore` → 獲客台 → 島民 `generateOutreachReply` + fill
---
## 近期窗口
| 窗口 | 天數 | 行為 |
|------|------|------|
| **重點** | 7 天內 | 優先爬取、優先顯示、排序最高 |
| **補充** | 830 天 | 7 天內不足時才補,排序較低 |
| **排除** | >30 天 | 不進海巡與獲客清單 |
策略:
1. 每個勾選 tag 的**近期軌**先抓滿 7 天名額
2. 全輪痛點貼文不足目標時,自動放寬至 30 天
3. 獲客台預設篩「7 天內」,可切「含 30 天內補充」
---
## 雙軌海巡Tag + Crawl + UI 三層對齊)
**近期軌不是相關軌的副產品**——每個勾選 tag 的 relevance 與 recency 查詢都**必跑**。
| 層級 | 相關 | 近期 |
|------|------|------|
| **Tag** | `derivedTags.relevance` 短詞高命中 | `derivedTags.recency` 求助語境 + after 日期 |
| **Crawl** | 相關軌 sort=relevance, limit≈12 | 近期軌 7d 滿額 → 30d 補 |
| **UI** | 可篩 `priority=relevant` | 預設 7d + `priority=gold` 置頂 |
合併優先級:
1. 兩軌皆有 → `gold`
2. 僅近期軌 → `recent`
3. 僅相關軌 → `relevant`
過濾:移植 `hasPlacementIntent`、`looksLikeCasualChat`(舊 [`lib/topic-anchor.ts`](../../lib/topic-anchor.ts)、[`lib/scan-recency.ts`](../../lib/scan-recency.ts))。
### scan_posts 擴充欄位
- `placement_score`、`priority`gold/recent/relevant
- `product_fit_score`、`solved_by_product`
- `posted_at`、`search_tag`、`query_dimension`relevance/recency
- `graph_node_id`
- `replies[]`(可選,`scrape_replies: true`
---
## 產品匹配驗收
每篇海巡結果:
- **`productFitScore`**:痛點 vs `product_brief`
- **`solvedByProduct`**:獲客留言是否對應產品能力(生成時強制檢查)
獲客台 UI
- 預設排序7 天內 + 產品能解決
- 標示:可置入 / 需人工 / 超出產品範圍
- 獲客留言:**島民 fill 全文,不自動送出**
---
## 島民交接
### job.result.handoff
```json
{
"handoff": {
"flow": "placement",
"persona_id": "...",
"pain_tag_count": 9,
"summary": "12 候選 tag → 勾選 6 節點 → 38 篇;痛點 10核心 6 + 周邊 47 天內 8 篇",
"pain_breakdown": { "core": 6, "peripheral": 4, "recent_7d": 8 },
"top_peripheral_hits": ["懷孕皮膚癢", "換季泛紅"],
"next_route": "/personas/:id/outreach",
"needs_supplemental_expand": false,
"connection_required": false
}
}
```
JobMonitor → `islanderHandoffStore`[`buildIslanderContext`](../web/src/lib/islander/buildIslanderContext.ts) 注入【近期海巡交接】。
### Custom actions
| Action | 用途 |
|--------|------|
| `expandKnowledgeGraph` | 觸發 `expand-graph``supplemental=true` 補充迴圈 |
| `toggleGraphNode` | 勾選/取消節點 |
| `startScan` | `dual_track=true`,只爬 `selectedForScan` 節點 |
| `generateOutreachReply` | 產獲客留言 |
| `applyDraft` | fill 留言欄位 |
### 對話路徑(流程 B
```text
「幫我找敏感肌的痛點」
→ expand-graphBrave knowledge_expand + AI 合成 TKG
→ IF pain_tag_count < 8 島民要再補一輪 Brave supplemental_round
→ 研究頁:圖譜 + 雙維度 tag + productFitScore
→ 使用者手動勾選節點
→ startScan每詞雙軌相關 + 近期7d/30d
→ outreach → highlight gold/recent → generateOutreachReply + fill
```
---
## Job 模板
| Template | Steps | worker |
|----------|-------|--------|
| `expand-graph` | plan_queries → brave_knowledge → ai_synth → derive_tags → [supplemental?] → persist_tkg | go |
| `scan` | session → crawl_dual_track → replies? → store → filter → ai_fit → persist | node + go |
| `style-8d` | (既有) | node + go |
執行順序:**expand-graph → 勾選 tag → scan**。
---
## API 草案
```text
POST /api/v1/personas/:id/knowledge-graph/expand # ?supplemental=true
GET /api/v1/personas/:id/knowledge-graph
PATCH /api/v1/personas/:id/knowledge-graph/nodes # selectedForScan
POST /api/v1/personas/:id/scan-jobs # graph_id, selected_node_ids, dual_track
GET /api/v1/personas/:id/scan-posts # recent_7d, product_fit_min, priority
POST /api/v1/personas/:id/outreach-drafts/generate
```
Internal worker`POST /workers/scan-posts/batch`、Brave/AI 內部端點。
---
## 前端頁面
| 路徑 | 用途 | 島民 label |
|------|------|------------|
| `/personas/:id/research` | 圖譜、雙維度 tag、勾選 | 加入海巡、Brave 再擴展 |
| `/personas/:id/outreach` | 獲客貼文 + 留言 | 獲客留言、標記已處理 |
| `/personas/:id/matrix` | 流程 APhase 2 | 草稿內容 |
研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
---
## 實作分期
### Phase 0a — 知識圖譜 + Tag 流水線
- [ ] Go Brave adapter`knowledge_expand` / `threads_discover`
- [ ] `expand-graph` jobplan_queries → brave → ai_synth → derive_tags
- [ ] `supplemental_round`(痛點 tag < 8
- [ ] Mongo `topic_knowledge_graphs`(含 `derivedTags`、`painTagCount`
- [ ] `deriveSearchTagsFromGraph`relevance + recency 雙陣列)
- [ ] API expand / get / patch nodes
### Phase 0b — 島民 handoff
- [ ] handoff`pain_tag_count`、`needs_supplemental_expand`
- [ ] JobMonitor bridge
- [ ] custom actions + `ai.islander.system.md` 海巡專章
### Phase 1 — 雙軌 scan + 流程 B
- [ ] Node `crawl_dual_track`(每 tag 相關+近期7d/30d
- [ ] `productFitScore` + outreach UI
- [ ] **驗收**:敏感肌 → L2懷孕等→ 候選痛點 tag ≥8 → 勾選後貼文痛點 ≥8 → 7d 內 ≥5
### Phase 2 — 流程 A
- [ ] matrix + 留言收集 + 島民 fill
### Phase 3 — 自動化
- [ ] job_schedules、Brave 熔斷、Meta API 發留言
---
## 風險
| 議題 | 對策 |
|------|------|
| Brave 幻覺 | 節點必須有 `evidence[]` |
| 圖譜跑題 | exclusions + `productFitScore` |
| 查詢爆炸 | Brave ≤15/輪supplemental ≤5衍生 ≤20只爬勾選 |
| 醫療敏感 | `disclaimer`;留言不自動發 |
| 周邊節點產品不符 | 低 productFit 預設不勾;獲客台標 ✗ |
---
## 參考
- 舊海巡:[`lib/services/scan.ts`](../../lib/services/scan.ts)
- 舊網搜:[`lib/services/scan-web-discover.ts`](../../lib/services/scan-web-discover.ts)
- 舊研究地圖:[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
- Job 系統:[`docs/job-system-plan.md`](./job-system-plan.md)
- 島民:[`internal/library/prompt/files/ai.islander.system.md`](../internal/library/prompt/files/ai.islander.system.md)
- 既有 8D[`worker/style-8d-worker.ts`](../worker/style-8d-worker.ts)

View File

@ -32,7 +32,8 @@ export function AccountSwitcher() {
const navigate = useNavigate() const navigate = useNavigate()
const { accounts, activeAccountId, activeAccount, loading, switchAccount, createAccount } = const { accounts, activeAccountId, activeAccount, loading, switchAccount, createAccount } =
useThreadsAccount() useThreadsAccount()
const { hasAccounts, refresh: refreshOnboarding } = useOnboarding() const { nextStep, refresh: refreshOnboarding } = useOnboarding()
const accountGuideActive = !loading && nextStep === 'account'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [panel, setPanel] = useState<'list' | 'create'>('list') const [panel, setPanel] = useState<'list' | 'create'>('list')
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@ -103,16 +104,21 @@ export function AccountSwitcher() {
return ( return (
<div ref={rootRef} className="ac-account-switcher relative"> <div ref={rootRef} className="ac-account-switcher relative">
{accountGuideActive && !open ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setOpen((v) => !v) setOpen((v) => !v)
if (open) setPanel('list') if (open) setPanel('list')
}} }}
className={`ac-account-trigger ${!loading && !hasAccounts ? 'ac-account-trigger--prompt' : ''}`} className={`ac-account-trigger ${accountGuideActive ? 'hx-guide-glow ac-account-trigger--prompt' : ''}`}
aria-expanded={open} aria-expanded={open}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-label={`經營帳號:${label}`} aria-label={`經營帳號:${label}${accountGuideActive ? '(入門下一步:建立經營帳號)' : ''}`}
> >
<AccountAvatar connected={connected} /> <AccountAvatar connected={connected} />
<span className="ac-account-trigger-text hidden min-w-0 max-w-[7.5rem] truncate sm:inline"> <span className="ac-account-trigger-text hidden min-w-0 max-w-[7.5rem] truncate sm:inline">

View File

@ -1,5 +1,5 @@
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { navGroupsForOnboarding } from '../lib/onboarding' import { navGroupsForOnboarding, onboardingGlowClass, shouldGlowNav } from '../lib/onboarding'
import type { AcAppKey } from '../lib/acAssets' import type { AcAppKey } from '../lib/acAssets'
import { useOnboarding } from '../onboarding/OnboardingContext' import { useOnboarding } from '../onboarding/OnboardingContext'
import { AcIcon } from './AcIcon' import { AcIcon } from './AcIcon'
@ -10,12 +10,14 @@ function SidebarNavItem({
icon, icon,
end, end,
matchPrefix, matchPrefix,
guideGlow,
}: { }: {
to: string to: string
label: string label: string
icon: AcAppKey icon: AcAppKey
end?: boolean end?: boolean
matchPrefix?: string matchPrefix?: string
guideGlow?: boolean
}) { }) {
const { pathname } = useLocation() const { pathname } = useLocation()
return ( return (
@ -25,9 +27,14 @@ function SidebarNavItem({
className={({ isActive }) => { className={({ isActive }) => {
const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false
const active = isActive || prefixActive const active = isActive || prefixActive
return `ac-sidebar-nav-item ${active ? 'ac-sidebar-nav-item--active' : ''}` return `ac-sidebar-nav-item relative ${active ? 'ac-sidebar-nav-item--active' : ''} ${onboardingGlowClass(!!guideGlow)}`
}} }}
> >
{guideGlow ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<AcIcon app={icon} size="sm" className="ac-sidebar-nav-icon shrink-0" /> <AcIcon app={icon} size="sm" className="ac-sidebar-nav-icon shrink-0" />
<span className="min-w-0 truncate">{label}</span> <span className="min-w-0 truncate">{label}</span>
</NavLink> </NavLink>
@ -35,7 +42,8 @@ function SidebarNavItem({
} }
export function AppSidebar() { export function AppSidebar() {
const { isComplete } = useOnboarding() const { pathname } = useLocation()
const { isComplete, nextStep } = useOnboarding()
const groups = navGroupsForOnboarding(isComplete) const groups = navGroupsForOnboarding(isComplete)
return ( return (
@ -59,6 +67,7 @@ export function AppSidebar() {
icon={item.icon} icon={item.icon}
end={item.end} end={item.end}
matchPrefix={item.matchPrefix} matchPrefix={item.matchPrefix}
guideGlow={shouldGlowNav(nextStep, item.to, pathname)}
/> />
</li> </li>
))} ))}

View File

@ -21,32 +21,11 @@ const clouds = [
{ id: 7, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--7' }, { id: 7, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--7' },
] as const ] as const
function PalmTree({ className }: { className: string }) {
return (
<svg className={`ac-palm ${className}`} viewBox="0 0 96 140" fill="none" aria-hidden>
<path
d="M44 62c-2 18-3 36-4 54-1 12 2 18 8 20 4 1 8-2 10-8 2-8 0-18-2-28-1-8-2-18-2-28-1-14 0-26 2-38"
fill="currentColor"
opacity="0.85"
/>
<path
d="M48 58c8-22 28-38 48-34-14 10-22 24-24 38M48 54c-6-18-22-32-40-28 12 8 20 18 22 32M48 50c2-16 14-30 32-34-10 12-16 24-16 38M48 46c-10-12-26-18-42-12 14 4 24 12 28 24M48 42c6-10 18-16 30-14-8 6-14 14-14 24"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
/>
<ellipse cx="48" cy="38" rx="6" ry="10" fill="currentColor" opacity="0.7" />
</svg>
)
}
export function SceneDecor() { export function SceneDecor() {
return ( return (
<div className="hx-scene-deco" aria-hidden> <div className="hx-scene-deco" aria-hidden>
<span className="auth-scene-blob auth-scene-blob--sky" /> <span className="auth-scene-blob auth-scene-blob--sky" />
<span className="auth-scene-blob auth-scene-blob--sky-alt" /> <span className="auth-scene-blob auth-scene-blob--sky-alt" />
<span className="ac-island-horizon" />
<span className="ac-island-sand" />
{clouds.map((cloud) => ( {clouds.map((cloud) => (
<svg key={cloud.id} className={`auth-cloud ${cloud.className}`} viewBox={cloud.viewBox} fill="none"> <svg key={cloud.id} className={`auth-cloud ${cloud.className}`} viewBox={cloud.viewBox} fill="none">
@ -72,9 +51,6 @@ export function SceneDecor() {
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
<PalmTree className="ac-palm--left" />
<PalmTree className="ac-palm--right" />
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { onboardingNavApps } from '../lib/onboarding' import { onboardingGlowClass, onboardingNavApps, shouldGlowNav } from '../lib/onboarding'
import { useOnboarding } from '../onboarding/OnboardingContext' import { useOnboarding } from '../onboarding/OnboardingContext'
import { AcIcon } from './AcIcon' import { AcIcon } from './AcIcon'
@ -20,7 +20,7 @@ function isMoreActive(pathname: string, routes: { to: string }[]) {
} }
export function MobileBottomNav() { export function MobileBottomNav() {
const { isComplete } = useOnboarding() const { isComplete, nextStep } = useOnboarding()
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
@ -55,24 +55,32 @@ export function MobileBottomNav() {
<div <div
className={`mx-auto grid max-w-lg px-2 pt-1 ${isComplete ? 'grid-cols-4' : 'grid-cols-2'}`} className={`mx-auto grid max-w-lg px-2 pt-1 ${isComplete ? 'grid-cols-4' : 'grid-cols-2'}`}
> >
{mobileTabs.map((tab) => ( {mobileTabs.map((tab) => {
<NavLink const guideGlow = shouldGlowNav(nextStep, tab.to, location.pathname)
key={tab.to} return (
to={tab.to} <NavLink
end={'end' in tab ? tab.end : false} key={tab.to}
className={({ isActive }) => { to={tab.to}
const prefixActive = end={'end' in tab ? tab.end : false}
'matchPrefix' in tab && location.pathname.startsWith(tab.matchPrefix) className={({ isActive }) => {
const active = isActive || prefixActive const prefixActive =
return `ac-dock-btn flex min-h-[3.5rem] flex-col items-center justify-center gap-1 py-2 text-center text-[11px] font-semibold ${ 'matchPrefix' in tab && location.pathname.startsWith(tab.matchPrefix)
active ? 'ac-dock-btn--active' : '' const active = isActive || prefixActive
}` return `ac-dock-btn relative flex min-h-[3.5rem] flex-col items-center justify-center gap-1 py-2 text-center text-[11px] font-semibold ${
}} active ? 'ac-dock-btn--active' : ''
> } ${onboardingGlowClass(guideGlow)}`
<AcIcon app={tab.icon} size="sm" /> }}
<span>{tab.label}</span> >
</NavLink> {guideGlow ? (
))} <span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<AcIcon app={tab.icon} size="sm" />
<span>{tab.label}</span>
</NavLink>
)
})}
{isComplete ? ( {isComplete ? (
<button <button
type="button" type="button"

View File

@ -57,10 +57,15 @@ export function OnboardingBanner() {
return ( return (
<li <li
key={step.id} key={step.id}
className={`ac-onboarding-step rounded-[var(--radius-md)] border px-3 py-3 ${ className={`ac-onboarding-step relative rounded-[var(--radius-md)] border px-3 py-3 ${
active ? 'ac-onboarding-step--active' : '' active ? 'ac-onboarding-step--active hx-guide-glow' : ''
}`} }`}
> >
{active ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`ac-onboarding-step__dot ${done ? 'ac-onboarding-step__dot--done' : ''} ${ className={`ac-onboarding-step__dot ${done ? 'ac-onboarding-step__dot--done' : ''} ${

View File

@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import {
onboardingGlowClass,
onboardingStepLabel,
shouldGlowInPage,
type OnboardingStep,
} from '../lib/onboarding'
import { useOnboarding } from '../onboarding/OnboardingContext'
import { useLocation } from 'react-router-dom'
export function useOnboardingGuide(step: OnboardingStep) {
const { pathname } = useLocation()
const { loading, isComplete, nextStep } = useOnboarding()
const active = !loading && !isComplete && shouldGlowInPage(nextStep, step, pathname)
return {
active,
glowClass: onboardingGlowClass(active),
label: onboardingStepLabel(step),
}
}
export function OnboardingGuideTarget({
step,
children,
className = '',
label,
}: {
step: OnboardingStep
children: ReactNode
className?: string
label?: string
}) {
const { active, glowClass, label: defaultLabel } = useOnboardingGuide(step)
const badge = label ?? defaultLabel
return (
<div className={`hx-guide-target ${glowClass} ${className}`.trim()}>
{active ? (
<span className="hx-guide-badge" aria-hidden>
{badge}
</span>
) : null}
{children}
</div>
)
}

View File

@ -220,15 +220,24 @@ export function QuickLinkCard({
title, title,
desc, desc,
icon, icon,
className = '',
guideBadge = false,
}: { }: {
to: string to: string
title: string title: string
desc: string desc: string
icon: AcAppKey icon: AcAppKey
tag?: string tag?: string
className?: string
guideBadge?: boolean
}) { }) {
return ( return (
<Link to={to} className="ac-app-card group block p-5"> <Link to={to} className={`ac-app-card group relative block p-5 ${className}`.trim()}>
{guideBadge ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<AcIcon app={icon} size="lg" className="mx-auto" /> <AcIcon app={icon} size="lg" className="mx-auto" />
<h3 className="mt-3 text-center text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3> <h3 className="mt-3 text-center text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3>
<p className="mt-2 text-center text-sm leading-relaxed text-ink-secondary">{desc}</p> <p className="mt-2 text-center text-sm leading-relaxed text-ink-secondary">{desc}</p>

View File

@ -2,98 +2,84 @@
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css"; @import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css"; @import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
/* ══ 淺色:熱帶無人島(動森 NH 天空/沙灘/棕櫚綠 ══ */ /* ══ 淺色:沉穩田園色(低飽和、無貼圖感 ══ */
:root, :root,
[data-theme="light"] { [data-theme="light"] {
color-scheme: light; color-scheme: light;
--hx-canvas: #8ed4f0; --hx-canvas: #d8e2e8;
--hx-canvas-mid: #a8e4f8; --hx-canvas-grass: #d4dfd4;
--hx-canvas-grass: #8fd86a; --hx-surface: #faf7f2;
--hx-sand: #f2d9a8; --hx-surface-muted: #f1ece4;
--hx-sand-deep: #e4c48a; --hx-ink: #3a3530;
--hx-ocean: #5eb8d8; --hx-ink-secondary: #5a554e;
--hx-palm: #3d9e4a; --hx-muted: #5a6578;
--hx-coral: #f4845f; --hx-subtle: #8a847c;
--hx-bell: #f0b429; --hx-wood: #c4a882;
--hx-surface: #fff9eb; --hx-wood-dark: #9a7d5c;
--hx-surface-muted: #fff3d6; --hx-wood-deep: #6f5a44;
--hx-ink: #5c4a3a; --hx-line: #ddd4c8;
--hx-ink-secondary: #7a6552; --hx-brand: #5a8f7b;
--hx-muted: #9a8570; --hx-brand-hover: #4d7f6c;
--hx-subtle: #b5a48f; --hx-brand-shadow: #3f6b59;
--hx-wood: #d4a574; --hx-brand-soft: #e6f0eb;
--hx-wood-dark: #b8894f; --hx-glow: #dce8ee;
--hx-wood-deep: #8f6535; --hx-glow-alt: #ebe4d8;
--hx-line: #e8d4b0; --hx-accent: #6a8fa0;
--hx-brand: #4db88a; --hx-accent-hover: #5a7f90;
--hx-brand-hover: #3da878; --hx-accent-soft: #e8eff3;
--hx-brand-shadow: #2d8f66; --hx-device: #6a9488;
--hx-brand-soft: #dff5ea; --hx-device-dark: #4f7568;
--hx-glow: #c8ecfa; --hx-success: #4a7f5e;
--hx-glow-alt: #f5e6c4; --hx-success-soft: #e4efe8;
--hx-accent: #5eb8d8; --hx-warning: #9a7340;
--hx-accent-hover: #4aa8c8; --hx-warning-soft: #f2ead8;
--hx-accent-soft: #dff3fa; --hx-danger: #b05a50;
--hx-device: #6ec4a8; --hx-danger-soft: #f5e6e4;
--hx-device-dark: #4a9e82; --hx-shadow-soft: 0 8px 28px -8px rgb(58 53 48 / 0.14);
--hx-success: #3d9e5c; --hx-shadow-card: 0 1px 2px rgb(58 53 48 / 0.06), 0 6px 20px -4px rgb(58 53 48 / 0.1);
--hx-success-soft: #dff5e6; --hx-hero-gradient: linear-gradient(165deg, #eef3f0 0%, #faf7f2 60%, #f1ece4 100%);
--hx-warning: #e8a838;
--hx-warning-soft: #fff0cc;
--hx-danger: #e07060;
--hx-danger-soft: #ffe8e4;
--hx-shadow-soft: 0 10px 32px -10px rgb(92 74 58 / 0.22);
--hx-shadow-card: 0 2px 0 rgb(184 137 79 / 0.35), 0 8px 24px -6px rgb(92 74 58 / 0.18);
--hx-hero-gradient: linear-gradient(165deg, #fff9eb 0%, #fff3d6 55%, #f5e6c4 100%);
--pocket-width: 28rem; --pocket-width: 28rem;
--sidebar-width: 15.5rem; --sidebar-width: 15.5rem;
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem)); --pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
} }
/* ══ 深色:島嶼黃昏 ══ */ /* ══ 深色:黃昏低對比 ══ */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--hx-canvas: #2a4a62; --hx-canvas: #1e2a32;
--hx-canvas-mid: #345a72; --hx-canvas-grass: #243028;
--hx-canvas-grass: #2d5238; --hx-surface: #2c363c;
--hx-sand: #6a5848; --hx-surface-muted: #354048;
--hx-sand-deep: #524438; --hx-ink: #ece6dc;
--hx-ocean: #4a8aa8; --hx-ink-secondary: #c8c0b4;
--hx-palm: #4a9e58; --hx-muted: #b8c4d6;
--hx-coral: #c87860; --hx-subtle: #8a8278;
--hx-bell: #c89838; --hx-wood: #6a5a48;
--hx-surface: #3a4440; --hx-wood-dark: #524438;
--hx-surface-muted: #444e4a; --hx-wood-deep: #3a3028;
--hx-ink: #f5ebe0; --hx-line: #4a544c;
--hx-ink-secondary: #d8ccc0; --hx-brand: #7aab96;
--hx-muted: #b0a498; --hx-brand-hover: #8abba6;
--hx-subtle: #8a8078; --hx-brand-shadow: #4f7568;
--hx-wood: #8a6848; --hx-brand-soft: #2a3c34;
--hx-wood-dark: #6a5038; --hx-glow: #2a3840;
--hx-wood-deep: #4a3828; --hx-glow-alt: #3a342c;
--hx-line: #5a645c; --hx-accent: #7a9aa8;
--hx-brand: #6ec4a0; --hx-accent-hover: #8aaab8;
--hx-brand-hover: #7ed4b0; --hx-accent-soft: #2a3840;
--hx-brand-shadow: #3a8068; --hx-device: #5a8070;
--hx-brand-soft: #2a4038; --hx-device-dark: #3f5a50;
--hx-glow: #3a5060; --hx-success: #7aab96;
--hx-glow-alt: #4a4038; --hx-success-soft: #2a3c34;
--hx-accent: #6ab0c8; --hx-warning: #c8a060;
--hx-accent-hover: #7ac0d8; --hx-warning-soft: #3a3428;
--hx-accent-soft: #2a4048; --hx-danger: #c89088;
--hx-device: #5a9880; --hx-danger-soft: #3c2c2c;
--hx-device-dark: #3a7060; --hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.4);
--hx-success: #6ec4a0; --hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 6px 20px -4px rgb(0 0 0 / 0.32);
--hx-success-soft: #2a4038; --hx-hero-gradient: linear-gradient(165deg, #2a3840 0%, #2c363c 60%, #354048 100%);
--hx-warning: #d8a848;
--hx-warning-soft: #3a3828;
--hx-danger: #d89088;
--hx-danger-soft: #402c2c;
--hx-shadow-soft: 0 10px 32px -10px rgb(0 0 0 / 0.45);
--hx-shadow-card: 0 2px 0 rgb(0 0 0 / 0.25), 0 8px 24px -6px rgb(0 0 0 / 0.35);
--hx-hero-gradient: linear-gradient(165deg, #3a4440 0%, #444e4a 55%, #4a4038 100%);
} }
@theme { @theme {
@ -175,27 +161,23 @@ th {
font-weight: 600; font-weight: 600;
} }
/* 全站天空場景:熱帶島嶼地平線 */ /* 全站天空場景(登入頁與登入後共用) */
.hx-scene { .hx-scene {
min-height: 100vh; min-height: 100vh;
background: background: linear-gradient(
linear-gradient( 180deg,
180deg, var(--hx-canvas) 0%,
var(--hx-canvas) 0%, color-mix(in srgb, var(--hx-canvas) 55%, var(--hx-glow) 45%) 62%,
var(--hx-canvas-mid, var(--hx-canvas)) 38%, color-mix(in srgb, var(--hx-canvas-grass) 75%, var(--hx-canvas) 25%) 100%
color-mix(in srgb, var(--hx-glow) 70%, var(--hx-canvas) 30%) 58%, );
color-mix(in srgb, var(--hx-sand, var(--hx-glow-alt)) 55%, var(--hx-canvas-grass) 45%) 78%,
var(--hx-canvas-grass) 100%
);
} }
[data-theme="dark"] .hx-scene { [data-theme="dark"] .hx-scene {
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
color-mix(in srgb, var(--hx-canvas) 85%, #1a2830 15%) 0%, color-mix(in srgb, var(--hx-canvas) 92%, black 8%) 0%,
var(--hx-canvas) 42%, var(--hx-canvas) 48%,
color-mix(in srgb, var(--hx-sand-deep, var(--hx-canvas-grass)) 40%, var(--hx-canvas-grass) 60%) 78%, color-mix(in srgb, var(--hx-canvas-grass) 88%, var(--hx-canvas) 12%) 100%
var(--hx-canvas-grass) 100%
); );
} }
@ -220,7 +202,7 @@ th {
flex-direction: column; flex-direction: column;
align-self: stretch; align-self: stretch;
min-height: 100%; min-height: 100%;
border-right: 2px solid color-mix(in srgb, var(--hx-line) 80%, var(--hx-wood) 20%); border-right: 2px solid var(--hx-line);
background: color-mix(in srgb, var(--hx-surface) 90%, transparent 10%); background: color-mix(in srgb, var(--hx-surface) 90%, transparent 10%);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
@ -360,9 +342,9 @@ th {
.ac-workspace { .ac-workspace {
overflow: auto; overflow: auto;
background: color-mix(in srgb, var(--hx-surface) 86%, transparent 14%); background: color-mix(in srgb, var(--hx-surface) 94%, transparent 6%);
backdrop-filter: blur(6px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(8px);
} }
.ac-workspace-inner { .ac-workspace-inner {
@ -412,25 +394,20 @@ th {
.ac-dialog { .ac-dialog {
background: var(--hx-surface); background: var(--hx-surface);
border: 3px solid var(--hx-wood); border: 2px solid var(--hx-line);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
box-shadow: var(--hx-shadow-card); box-shadow: var(--hx-shadow-card);
} }
.ac-title-bar { .ac-title-bar {
margin: 0 0 1rem; margin: 0 0 1rem;
border-radius: var(--radius-lg); border-radius: var(--radius-md);
border: 2px solid var(--hx-wood-dark); border: 1px solid var(--hx-brand-shadow);
background: linear-gradient(180deg, var(--hx-device) 0%, var(--hx-device-dark) 55%, var(--hx-brand-shadow) 100%); background: linear-gradient(180deg, var(--hx-device) 0%, var(--hx-device-dark) 100%);
padding: 0.7rem 1.15rem; padding: 0.65rem 1rem;
color: #fffef8; color: #faf7f2;
font-weight: 800; font-weight: 700;
font-size: 1.05rem; font-size: 1rem;
letter-spacing: 0.02em;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.15);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.28),
0 2px 0 var(--hx-wood-dark);
} }
.auth-scene { .auth-scene {
@ -1201,6 +1178,69 @@ th {
box-shadow: 0 0 0 4px var(--hx-brand-soft); box-shadow: 0 0 0 4px var(--hx-brand-soft);
} }
/* ── 入門引導:下一步發光 ── */
.hx-guide-target {
position: relative;
}
.hx-guide-glow {
z-index: 1;
border-radius: inherit;
animation: hx-guide-glow-pulse 2.2s ease-in-out infinite;
}
.hx-guide-glow.ac-account-trigger,
.hx-guide-glow.ac-sidebar-nav-item,
.hx-guide-glow.ac-dock-btn {
border-color: var(--hx-brand);
}
.hx-guide-badge {
position: absolute;
top: -0.55rem;
right: -0.35rem;
z-index: 2;
border: 1.5px solid color-mix(in srgb, var(--hx-brand) 55%, var(--hx-line) 45%);
border-radius: var(--radius-pill);
background: var(--hx-brand);
padding: 0.15rem 0.5rem;
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 0.04em;
color: #faf7f2;
box-shadow: var(--hx-shadow-soft);
pointer-events: none;
white-space: nowrap;
}
.ac-account-switcher .hx-guide-badge {
top: auto;
right: 0;
bottom: calc(100% + 0.35rem);
}
.ac-sidebar-nav-item.hx-guide-glow .hx-guide-badge,
.ac-dock-btn.hx-guide-glow .hx-guide-badge {
top: 0.15rem;
right: 0.2rem;
font-size: 0.5625rem;
padding: 0.1rem 0.35rem;
}
@keyframes hx-guide-glow-pulse {
0%,
100% {
box-shadow:
0 0 0 3px color-mix(in srgb, var(--hx-brand-soft) 88%, transparent),
0 0 14px color-mix(in srgb, var(--hx-brand) 30%, transparent);
}
50% {
box-shadow:
0 0 0 5px color-mix(in srgb, var(--hx-brand-soft) 95%, transparent),
0 0 22px color-mix(in srgb, var(--hx-brand) 50%, transparent);
}
}
.ac-job-monitor { .ac-job-monitor {
position: fixed; position: fixed;
z-index: 45; z-index: 45;

View File

@ -86,3 +86,35 @@ export function onboardingRedirectPath(pathname: string, complete: boolean) {
export function onboardingNavApps(complete: boolean) { export function onboardingNavApps(complete: boolean) {
return navGroupsForOnboarding(complete).flatMap((group) => group.items) return navGroupsForOnboarding(complete).flatMap((group) => group.items)
} }
export function onboardingGlowClass(active: boolean) {
return active ? 'hx-guide-glow' : ''
}
/** 側欄/底欄:使用者還不在目標頁時,導覽項發光 */
export function shouldGlowNav(step: OnboardingStep | null, navTo: string, pathname: string) {
if (!step) return false
if (step === 'connection' && navTo === '/settings') return pathname !== '/settings'
if (step === 'persona' && navTo === '/personas') return !pathname.startsWith('/personas')
return false
}
/** 頁內操作區:使用者已在目標頁時,具體按鈕/卡片發光 */
export function shouldGlowInPage(
step: OnboardingStep | null,
targetStep: OnboardingStep,
pathname: string,
) {
if (step !== targetStep) return false
if (targetStep === 'connection') {
return pathname === '/settings' || pathname.includes('/connections')
}
if (targetStep === 'persona') return pathname.startsWith('/personas')
return false
}
export function onboardingStepLabel(step: OnboardingStep) {
if (step === 'account') return '先建立帳號'
if (step === 'connection') return '完成連線'
return '建立人設'
}

View File

@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, TableAction } from '../components/ui' import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, TableAction } from '../components/ui'
import { parseStyle8DProfile } from '../lib/styleProfile' import { parseStyle8DProfile } from '../lib/styleProfile'
import { OnboardingGuideTarget } from '../components/OnboardingGuide'
import { useOnboarding } from '../onboarding/OnboardingContext' import { useOnboarding } from '../onboarding/OnboardingContext'
import type { ListPersonasData, PersonaData } from '../types/api' import type { ListPersonasData, PersonaData } from '../types/api'
@ -70,21 +71,23 @@ export function PersonasPage() {
subtitle="人設可重複套用到不同 Threads 帳號;帳號切換器只負責連線與 API 身份。" subtitle="人設可重複套用到不同 Threads 帳號;帳號切換器只負責連線與 API 身份。"
/> />
<Card className="mb-4 grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end"> <OnboardingGuideTarget step="persona" className="mb-4">
<Field label="新增人設名稱(選填)"> <Card className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<Input <Field label="新增人設名稱(選填)">
value={newName} <Input
onChange={(e) => setNewName(e.target.value)} value={newName}
placeholder="例如:醫療衛教、個人品牌" onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { placeholder="例如:醫療衛教、個人品牌"
if (e.key === 'Enter') void create() onKeyDown={(e) => {
}} if (e.key === 'Enter') void create()
/> }}
</Field> />
<Button onClick={create} disabled={creating}> </Field>
{creating ? '建立中…' : '建立人設'} <Button onClick={create} disabled={creating}>
</Button> {creating ? '建立中…' : '建立人設'}
</Card> </Button>
</Card>
</OnboardingGuideTarget>
{loading ? ( {loading ? (
<Card> <Card>
@ -130,7 +133,9 @@ export function PersonasPage() {
</div> </div>
) : ( ) : (
<Card> <Card>
<p className="text-sm text-ink-secondary"> 8D </p> <p className="text-sm text-ink-secondary">
8D
</p>
</Card> </Card>
)} )}

View File

@ -4,11 +4,11 @@ import { AccountConnectionMode } from '../components/AccountConnectionMode'
import { AccountDisplayNameSettings } from '../components/AccountDisplayNameSettings' import { AccountDisplayNameSettings } from '../components/AccountDisplayNameSettings'
import { ExtensionInstallCard } from '../components/ExtensionInstallCard' import { ExtensionInstallCard } from '../components/ExtensionInstallCard'
import { useThreadsAccount } from '../threads/ThreadsAccountContext' import { useThreadsAccount } from '../threads/ThreadsAccountContext'
import { OnboardingGuideTarget } from '../components/OnboardingGuide'
import { Card, Notice, PageTitle, QuickLinkCard, SectionTitle } from '../components/ui' import { Card, Notice, PageTitle, QuickLinkCard, SectionTitle } from '../components/ui'
export function SettingsPage() { export function SettingsPage() {
const { activeAccountId, activeAccount, loading: accountsLoading } = useThreadsAccount() const { activeAccountId, activeAccount, loading: accountsLoading } = useThreadsAccount()
const connectionsPath = activeAccountId const connectionsPath = activeAccountId
? `/threads/${activeAccountId}/connections` ? `/threads/${activeAccountId}/connections`
: '/' : '/'
@ -57,9 +57,11 @@ export function SettingsPage() {
/> />
</Card> </Card>
<Card> <OnboardingGuideTarget step="connection">
<AccountConnectionMode accountId={activeAccountId} connectionsPath={connectionsPath} /> <Card>
</Card> <AccountConnectionMode accountId={activeAccountId} connectionsPath={connectionsPath} />
</Card>
</OnboardingGuideTarget>
<Card className="grid gap-4"> <Card className="grid gap-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useOutletContext, useParams } from 'react-router-dom' import { useOutletContext, useParams } from 'react-router-dom'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import { DevToolsPanel } from '../components/DevToolsPanel' import { DevToolsPanel } from '../components/DevToolsPanel'
import { OnboardingGuideTarget } from '../components/OnboardingGuide'
import { AcLink, Badge, Card, ErrorText, Notice, SectionTitle, SuccessText } from '../components/ui' import { AcLink, Badge, Card, ErrorText, Notice, SectionTitle, SuccessText } from '../components/ui'
import type { ThreadsAccountConnectionData, ThreadsAccountData } from '../types/api' import type { ThreadsAccountConnectionData, ThreadsAccountData } from '../types/api'
@ -37,10 +38,11 @@ export function ThreadsAccountConnectionsPage() {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Card className="grid gap-4"> <OnboardingGuideTarget step="connection">
<div className="flex flex-wrap items-start justify-between gap-3"> <Card className="grid gap-4">
<div className="grid gap-2"> <div className="flex flex-wrap items-start justify-between gap-3">
<SectionTitle>Threads API </SectionTitle> <div className="grid gap-2">
<SectionTitle>Threads API </SectionTitle>
<p className="text-sm leading-relaxed text-ink-secondary"> <p className="text-sm leading-relaxed text-ink-secondary">
API使 API使
</p> </p>
@ -68,11 +70,12 @@ export function ThreadsAccountConnectionsPage() {
/> />
)} )}
<p className="ac-hint"> <p className="ac-hint">
AI API key <AcLink to="/personas"></AcLink>{' '} AI API key <AcLink to="/personas"></AcLink>{' '}
<AcLink to={`/threads/${id}/publish`}></AcLink> <AcLink to={`/threads/${id}/publish`}></AcLink>
</p> </p>
</Card> </Card>
</OnboardingGuideTarget>
{id && connection ? ( {id && connection ? (
<DevToolsPanel <DevToolsPanel