fix dockerfile unhealth problem
This commit is contained in:
parent
f2736ced32
commit
7e58bdba45
|
|
@ -1 +1 @@
|
||||||
34256
|
19005
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
34261
|
19030
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
34262
|
19031
|
||||||
|
|
|
||||||
|
|
@ -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 流程
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
|
||||||
|
|
||||||
|
> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge Graph(Brave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
|
||||||
|
|
||||||
|
## 北極星
|
||||||
|
|
||||||
|
海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
|
||||||
|
|
||||||
|
流程 B 主線:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Brave 知識圖譜擴散(周邊延伸)
|
||||||
|
→ 衍生雙維度搜尋 tag(相關詞 + 近期求助詞)
|
||||||
|
→ 使用者手動勾選
|
||||||
|
→ 每個 tag 雙軌爬 Threads(相關軌 + 近期軌,7d 優先 / 30d 補充)
|
||||||
|
→ productFitScore 篩選
|
||||||
|
→ 島民協助撰寫獲客留言
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已拍板決策
|
||||||
|
|
||||||
|
| 項目 | 決策 |
|
||||||
|
|------|------|
|
||||||
|
| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
|
||||||
|
| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle,不預設全選) |
|
||||||
|
| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
|
||||||
|
| Brave 預算 | 中等,每輪知識擴展 **10–15 次查詢**(不足可 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)):
|
||||||
|
|
||||||
|
| 現象 | 根因 | 新系統對策 |
|
||||||
|
|------|------|------------|
|
||||||
|
| 痛點只抓到 1–2 個 | Placement 壓 `suggestedTags` 至 **2~4**;`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 一次吐 2~4 個,而是五段流水線產出:
|
||||||
|
|
||||||
|
```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 10–15 次**一般網搜**(非 threadsOnly) | snippets → 候選節點 |
|
||||||
|
| 4 | AI 合成 TKG(L0/L1/L2)+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
|
||||||
|
| 5 | 每節點壓成 2~8 字真人搜尋詞,分兩套 | `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 標 warning;UI + 島民提示「可重跑 expand 或手動加種子詞」
|
||||||
|
```
|
||||||
|
|
||||||
|
| 指標 | 舊系統 | 新系統 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| Placement suggestedTags | 2~4 | 不沿用此上限 |
|
||||||
|
| 搜尋任務上限 | 8 | 候選 ≥12,實 crawl = 勾選數 |
|
||||||
|
| 痛點類最低 | 無保證 | **≥8**(含 supplemental) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic Knowledge Graph(TKG)
|
||||||
|
|
||||||
|
### 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 天內 | 優先爬取、優先顯示、排序最高 |
|
||||||
|
| **補充** | 8~30 天 | 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 + 周邊 4);7 天內 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-graph(Brave 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` | 流程 A(Phase 2) | 草稿內容 |
|
||||||
|
|
||||||
|
研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 實作分期
|
||||||
|
|
||||||
|
### Phase 0a — 知識圖譜 + Tag 流水線
|
||||||
|
|
||||||
|
- [ ] Go Brave adapter(`knowledge_expand` / `threads_discover`)
|
||||||
|
- [ ] `expand-graph` job:plan_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)
|
||||||
Binary file not shown.
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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' : ''} ${
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,36 @@ 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 '建立人設'
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue