diff --git a/apps/backend/etc/callhome.md b/apps/backend/etc/callhome.md
new file mode 100644
index 0000000..601a82a
--- /dev/null
+++ b/apps/backend/etc/callhome.md
@@ -0,0 +1,57 @@
+# Call Home 會議重點與 Action Items
+
+**日期:** 2026/07/28
+
+## 會議重點
+
+### 1. 整合方向
+
+Call Home 可拆成三個部分:
+
+1. **Trigger/State**:事件收集、分類、去重與 Recovery。
+2. **Delivery**:Email、SMS、Phone、ServiceNow、Salesforce。
+3. **Service Process**:自動開單、Ticket 狀態與結案責任。
+
+### 2. 可以直接重用的能力
+
+- **SSM 團隊提供:** Event Mapping、事件白名單、資料收集、去重與 Recovery 邏輯。
+- **SCC-FLEX 提供:** Policy Engine、統一 Webhook、多通路通知與 Salesforce API。
+- **Local Administrator:** 沿用 SCC-FLEX 現有 Recipient/Receiver 設定,不另外開發獨立模組。
+
+### 3. 目前主要缺口
+
+- 尚未定義統一 Event Payload 與 Correlation Key。
+- SCC-FLEX 尚缺完整 Deduplication、Soft/Hard State 與 Event Lifecycle。
+- Service Team 可接受的 Event 白名單尚未確認。
+- Device Recovered 與 Ticket Closed 的關係尚未定義。
+- Salesforce Ticket 的 Owner、Close 權限與 Ask-to-Close 流程尚未確認。
+- Call Home License 模式尚未確認。
+
+### 4. 初步共識
+
+- Supermicro Service 情境傾向**自動建立 Ticket**。
+- 自動開單前必須先完成 Event 白名單、去重及 Idempotency。
+- 初期先支援 Compute/Supermicro Hardware。
+- CDU、PDU、Liquid Cooling 等產品後續再評估。
+
+## Action Items
+
+| Owner | Action Item | Status |
+|---|---|---|
+| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
+| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
+| Daniel/PM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
+| PM/Service Team | 提供可自動開單的 Event 白名單、必要 Payload/附件及維護 Owner。 | 待確認 |
+| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
+| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、Soft/Hard State、Deduplication 與 Idempotency。 | 待排程 |
+| Daniel/IT/Service Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
+| PM/產品團隊 | 決定 Event 選擇方式及 Ticket List/Ask-to-Close 是否列入產品範圍。 | 待討論 |
+
+## 下一步
+
+下一次會議只需要確認四件事:
+
+1. Service Team Event 白名單。
+2. SSM 可提供的資料與介面。
+3. Salesforce Ticket Lifecycle 與責任人。
+4. License 與產品範圍。
diff --git a/apps/backend/generate/api/growth_p2.api b/apps/backend/generate/api/growth_p2.api
index 5a774fb..d6d38b1 100644
--- a/apps/backend/generate/api/growth_p2.api
+++ b/apps/backend/generate/api/growth_p2.api
@@ -98,9 +98,11 @@ type (
}
UtmLinkPublic {
- Id string `json:"id"`
- Code string `json:"code"`
+ Id string `json:"id"`
+ Code string `json:"code"`
+ // TrackPath 保留給同源前端;TrackUrl 是後端算好的絕對網址,貼到 Threads 用這個。
TrackPath string `json:"track_path"`
+ TrackUrl string `json:"track_url"`
DestinationUrl string `json:"destination_url"`
OutcomeId string `json:"outcome_id,optional"`
Label string `json:"label,optional"`
diff --git a/apps/backend/internal/logic/benchmark/get_benchmark_logic.go b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go
index 9c7dd18..9b50d91 100644
--- a/apps/backend/internal/logic/benchmark/get_benchmark_logic.go
+++ b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go
@@ -27,12 +27,11 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
if !ok || uid <= 0 {
return nil, fmt.Errorf("unauthorized")
}
- niche := req.Niche
- if niche == "" {
- niche = "general"
- }
+ niche := usecase.NormalizeNiche(req.Niche)
var yourEng, yourViews float64
- // contribute + read from own posts if studio available
+ // 只讀自己的貼文算出對照值。這裡刻意不 ContributeBenchmark:查詢是讀路徑,
+ // 若順手寫入,任何人只要輸入某個利基名稱就能把自己的數字灌進那個桶。
+ // 貢獻的唯一入口是 insights summary(那裡的數字來自使用者自己的已同步貼文)。
if l.svcCtx.Studio != nil {
posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId)
if err == nil && len(posts) > 0 {
@@ -52,15 +51,14 @@ func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.Benchm
if views > 0 {
yourEng = float64(likes+replies) / float64(views)
}
- _ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, niche, yourEng, yourViews)
}
}
}
sample, medEng, medViews, avail, hint := l.svcCtx.Growth.GetBenchmark(l.ctx, niche, yourEng, yourViews)
return &types.BenchmarkData{
Available: avail, SampleSize: sample, Niche: niche,
- MedianEngRate: usecase.Round2(medEng), MedianViews: usecase.Round2(medViews),
- YourEngRate: usecase.Round2(yourEng), YourViews: usecase.Round2(yourViews),
+ MedianEngRate: usecase.RoundRate(medEng), MedianViews: usecase.Round2(medViews),
+ YourEngRate: usecase.RoundRate(yourEng), YourViews: usecase.Round2(yourViews),
PercentileHint: hint, Message: hint,
}, nil
}
diff --git a/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go
index 5c295a3..8a929e9 100644
--- a/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go
+++ b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go
@@ -80,7 +80,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
}
top = append(top, types.InsightsPostPublic{
Id: p.ID, TextPreview: prev, PublishedAt: ts,
- ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.Round2(eng),
+ ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.RoundRate(eng),
})
}
sort.Slice(top, func(i, j int) bool { return top[i].EngRate > top[j].EngRate })
@@ -98,7 +98,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
e = float64(a.likes+a.replies) / float64(a.views)
}
buckets = append(buckets, types.InsightsMonthBucket{
- MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.Round2(e),
+ MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.RoundRate(e),
})
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i].MonthKey < buckets[j].MonthKey })
@@ -116,7 +116,7 @@ func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryR
}
return &types.InsightsSummaryData{
AccountId: req.AccountId, PostCount: n, TotalViews: totalV, TotalLikes: totalL, TotalReplies: totalR,
- AvgEngRate: usecase.Round2(avgEng), TopPosts: top, MonthBuckets: buckets,
+ AvgEngRate: usecase.RoundRate(avgEng), TopPosts: top, MonthBuckets: buckets,
Conclusion: conclusion, Suggestions: sugs,
}, nil
}
diff --git a/apps/backend/internal/logic/utm/create_utm_link_logic.go b/apps/backend/internal/logic/utm/create_utm_link_logic.go
index 0fe27f5..c1cb5c6 100644
--- a/apps/backend/internal/logic/utm/create_utm_link_logic.go
+++ b/apps/backend/internal/logic/utm/create_utm_link_logic.go
@@ -30,9 +30,6 @@ func (l *CreateUtmLinkLogic) CreateUtmLink(req *types.CreateUtmLinkReq) (*types.
if err != nil {
return nil, err
}
- return &types.UtmLinkPublic{
- Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code,
- DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
- Clicks: u.Clicks, CreatedAt: u.CreatedAt,
- }, nil
+ out := publicView(l.svcCtx.Config, u)
+ return &out, nil
}
diff --git a/apps/backend/internal/logic/utm/list_utm_links_logic.go b/apps/backend/internal/logic/utm/list_utm_links_logic.go
index 9f07b4e..c572a48 100644
--- a/apps/backend/internal/logic/utm/list_utm_links_logic.go
+++ b/apps/backend/internal/logic/utm/list_utm_links_logic.go
@@ -32,11 +32,7 @@ func (l *ListUtmLinksLogic) ListUtmLinks() (*types.UtmLinkListData, error) {
}
out := make([]types.UtmLinkPublic, 0, len(list))
for _, u := range list {
- out = append(out, types.UtmLinkPublic{
- Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code,
- DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
- Clicks: u.Clicks, CreatedAt: u.CreatedAt,
- })
+ out = append(out, publicView(l.svcCtx.Config, u))
}
return &types.UtmLinkListData{List: out}, nil
}
diff --git a/apps/backend/internal/logic/utm/publicview.go b/apps/backend/internal/logic/utm/publicview.go
new file mode 100644
index 0000000..f5075bd
--- /dev/null
+++ b/apps/backend/internal/logic/utm/publicview.go
@@ -0,0 +1,38 @@
+package utm
+
+import (
+ "strings"
+
+ "apps/backend/internal/config"
+ "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/types"
+)
+
+const trackPathPrefix = "/api/v1/public/u/"
+
+// publicView 把追蹤連結轉成對外型別。TrackUrl 必須由後端算:連結會被貼進 Threads
+// 貼文,前端自己拼 window.location.origin 在 API 與前端不同 host 時會產出 404 連結。
+func publicView(c config.Config, u *domain.UtmLink) types.UtmLinkPublic {
+ path := trackPathPrefix + u.Code
+ return types.UtmLinkPublic{
+ Id: u.ID,
+ Code: u.Code,
+ TrackPath: path,
+ TrackUrl: publicAPIBase(c) + path,
+ DestinationUrl: u.DestinationURL,
+ OutcomeId: u.OutcomeID,
+ Label: u.Label,
+ Clicks: u.Clicks,
+ CreatedAt: u.CreatedAt,
+ }
+}
+
+// publicAPIBase 與 threads oauth callback 用同一套推導:PublicAPIBase 優先,否則
+// PublicWebBase(正式站 nginx、dev 的 vite 都會把 /api 轉到 gateway)。兩者皆空
+// 時回空字串,TrackUrl 退回相對路徑讓瀏覽器自己解。
+func publicAPIBase(c config.Config) string {
+ if b := strings.TrimRight(strings.TrimSpace(c.PublicAPIBase), "/"); b != "" {
+ return b
+ }
+ return strings.TrimRight(strings.TrimSpace(c.PublicWebBase), "/")
+}
diff --git a/apps/backend/internal/logic/utm/publicview_test.go b/apps/backend/internal/logic/utm/publicview_test.go
new file mode 100644
index 0000000..2f4466b
--- /dev/null
+++ b/apps/backend/internal/logic/utm/publicview_test.go
@@ -0,0 +1,36 @@
+package utm
+
+import (
+ "testing"
+
+ "apps/backend/internal/config"
+ "apps/backend/internal/module/growth/domain"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTrackUrlIsAbsoluteSoItCanBePastedIntoAPost(t *testing.T) {
+ var c config.Config
+ c.PublicAPIBase = "https://api.example.com/"
+ c.PublicWebBase = "https://app.example.com"
+
+ out := publicView(c, &domain.UtmLink{ID: "l1", Code: "abc123", DestinationURL: "https://shop.example.com/p/1"})
+
+ require.Equal(t, "https://api.example.com/api/v1/public/u/abc123", out.TrackUrl)
+ require.Equal(t, "/api/v1/public/u/abc123", out.TrackPath)
+}
+
+func TestTrackUrlFallsBackToWebBaseWhichProxiesApi(t *testing.T) {
+ var c config.Config
+ c.PublicWebBase = "http://127.0.0.1:5173"
+
+ out := publicView(c, &domain.UtmLink{Code: "xyz"})
+
+ require.Equal(t, "http://127.0.0.1:5173/api/v1/public/u/xyz", out.TrackUrl)
+}
+
+func TestTrackUrlStaysRelativeWhenNoBaseIsConfigured(t *testing.T) {
+ out := publicView(config.Config{}, &domain.UtmLink{Code: "xyz"})
+
+ require.Equal(t, "/api/v1/public/u/xyz", out.TrackUrl)
+}
diff --git a/apps/backend/internal/module/growth/usecase/p2.go b/apps/backend/internal/module/growth/usecase/p2.go
index c630356..cd8573d 100644
--- a/apps/backend/internal/module/growth/usecase/p2.go
+++ b/apps/backend/internal/module/growth/usecase/p2.go
@@ -292,11 +292,17 @@ func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string,
// --- Benchmark ---
-func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
- niche = strings.TrimSpace(niche)
+// NormalizeNiche 讓利基桶有機會對得上:大小寫與前後空白不該切出不同的桶。
+func NormalizeNiche(niche string) string {
+ niche = strings.ToLower(strings.TrimSpace(niche))
if niche == "" {
- niche = "general"
+ return "general"
}
+ return niche
+}
+
+func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
+ niche = NormalizeNiche(niche)
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche)))
return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{
Niche: niche, EngRate: engRate, AvgViews: avgViews,
@@ -305,10 +311,7 @@ func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche
}
func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) {
- niche = strings.TrimSpace(niche)
- if niche == "" {
- niche = "general"
- }
+ niche = NormalizeNiche(niche)
list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500)
if err != nil || len(list) < 5 {
return len(list), 0, 0, false, "樣本不足 5,暫不顯示中位數(多貢獻幾次成效後再看)"
@@ -534,3 +537,9 @@ func toPDFSafe(s string) string {
func Round2(v float64) float64 {
return math.Round(v*100) / 100
}
+
+// RoundRate 用在互動率這種比率(0.0234)。比率不能走 Round2 —— 那會輾成 0.02,
+// 前端 *100 後所有人都變成整數百分比。保留 4 位小數 = 百分比的 2 位。
+func RoundRate(v float64) float64 {
+ return math.Round(v*10000) / 10000
+}
diff --git a/apps/backend/internal/module/growth/usecase/service_test.go b/apps/backend/internal/module/growth/usecase/service_test.go
index dc84738..81e5adb 100644
--- a/apps/backend/internal/module/growth/usecase/service_test.go
+++ b/apps/backend/internal/module/growth/usecase/service_test.go
@@ -220,3 +220,26 @@ func TestUTM_RefusesToRedirectToAStoredBadDestination(t *testing.T) {
_, err = svc.TrackUtmClick(ctx, link.Code)
require.ErrorIs(t, err, domain.ErrValidation)
}
+
+// 互動率是比率,走 Round2 會被輾成整數百分比(0.0234 → 0.02 → 前端 2.00%)。
+func TestRoundRateKeepsTwoDecimalsOfAPercentage(t *testing.T) {
+ require.Equal(t, 0.0234, RoundRate(0.023449))
+ require.Equal(t, 0.1235, RoundRate(0.12345))
+ require.Equal(t, 0.0, RoundRate(0))
+ require.Equal(t, 0.02, Round2(0.023449), "Round2 仍供瀏覽數這類整數量使用")
+}
+
+func TestBenchmarkBucketsIgnoreCaseAndPadding(t *testing.T) {
+ svc := New(repository.NewMemory())
+ ctx := context.Background()
+
+ for uid := int64(1); uid <= 5; uid++ {
+ require.NoError(t, svc.ContributeBenchmark(ctx, uid, " SkinCare ", 0.02, 100))
+ }
+
+ size, medEng, medViews, available, _ := svc.GetBenchmark(ctx, "skincare", 0.02, 100)
+ require.True(t, available)
+ require.Equal(t, 5, size)
+ require.Equal(t, 0.02, medEng)
+ require.Equal(t, 100.0, medViews)
+}
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index aab05c6..2bd513e 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -1796,6 +1796,7 @@ type UtmLinkPublic struct {
Id string `json:"id"`
Code string `json:"code"`
TrackPath string `json:"track_path"`
+ TrackUrl string `json:"track_url"`
DestinationUrl string `json:"destination_url"`
OutcomeId string `json:"outcome_id,optional"`
Label string `json:"label,optional"`
diff --git a/apps/web/package.json b/apps/web/package.json
index 459af3e..8d8b72b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,7 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "oxlint",
+ "check:tokens": "node scripts/check-tokens.mjs",
"test": "vitest run",
"test:unit": "vitest run",
"preview": "vite preview --host 0.0.0.0"
diff --git a/apps/web/public/brand-mark.svg b/apps/web/public/brand-mark.svg
index fbd20a3..2099214 100644
--- a/apps/web/public/brand-mark.svg
+++ b/apps/web/public/brand-mark.svg
@@ -1,19 +1,19 @@
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
index 46c8710..206129c 100644
--- a/apps/web/public/favicon.svg
+++ b/apps/web/public/favicon.svg
@@ -1,15 +1,15 @@
diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg
deleted file mode 100644
index 788874e..0000000
--- a/apps/web/public/icons.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/apps/web/scripts/check-tokens.mjs b/apps/web/scripts/check-tokens.mjs
new file mode 100644
index 0000000..cc0d30a
--- /dev/null
+++ b/apps/web/scripts/check-tokens.mjs
@@ -0,0 +1,105 @@
+/**
+ * 樣式把關:顏色與字級只能來自 tokens.css。
+ *
+ * 會擋下的事:
+ * 1. css 寫死 font-size 數值(em 例外:那是刻意的相對縮放)
+ * 2. tsx 的 inline style 寫死 fontSize 或色碼
+ * 3. css 出現任何 css 檔都沒定義的 --hb-*(--hb-warn 這種拼錯永遠吃 fallback,很難發現)
+ * 4. css 出現寫死的顏色
+ * 5. 把兩個有彩度的 token 互相 color-mix(藍配橘是補色,混出來一定是濁褐)
+ *
+ * 第 4 條放過兩種情形:純黑純白(只用於陰影與高光的 color-mix,本身不是主題色),
+ * 以及 global.css 主題預覽色塊那 12 個值 —— 那裡必須同時顯示淺色與深色,
+ * 不能吃會隨主題切換的 var()。
+ */
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+const STYLE_DIR = "src/styles";
+const NEUTRAL = /^#(fff|000|ffffff|000000)$/i;
+/**
+ * 有彩度的 token。這些彼此不能混:主色在 H≈187、暖色在 H≈32,是補色關係,
+ * 任何比例混出來都是濁褐。要調淡就跟 surface/line/transparent 混,要同一顏色
+ * 的深淺兩階就用 X 與 X-deep。
+ */
+const CHROMATIC =
+ /^--hb-(brand|brand-hover|brand-deep|brand-dim|magic|accent-warm|warning|warning-deep|danger|danger-deep|success|gold|gold-bright)$/;
+const MIX = /color-mix\(in srgb,\s*var\((--hb-[a-z0-9-]+)\)[^,]*,\s*var\((--hb-[a-z0-9-]+)\)\s*\)/g;
+/** 主題預覽色塊:見 global.css 的 .hb-theme-picker__swatch--* */
+const HEX_BUDGET = { "global.css": 12, "layout.css": 0, "ui.css": 0 };
+
+const errors = [];
+
+function walk(dir) {
+ const out = [];
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) out.push(...walk(path));
+ else out.push(path);
+ }
+ return out;
+}
+
+const cssFiles = readdirSync(STYLE_DIR).filter((f) => f.endsWith(".css"));
+// 除了 tokens.css 的全域 token,元件內宣告的區域變數(如 --hb-top-ctrl)也算已定義。
+const defined = new Set(
+ cssFiles.flatMap((f) => [
+ ...readFileSync(join(STYLE_DIR, f), "utf8").matchAll(/^\s*(--hb-[a-z0-9-]+)\s*:/gm),
+ ].map((m) => m[1])),
+);
+
+for (const file of readdirSync(STYLE_DIR)) {
+ if (!file.endsWith(".css") || file === "tokens.css") continue;
+ const path = join(STYLE_DIR, file);
+ const text = readFileSync(path, "utf8");
+
+ text.split("\n").forEach((line, i) => {
+ const at = `${path}:${i + 1}`;
+ if (/font-size:\s*[0-9.]+(rem|px|pt)/.test(line)) {
+ errors.push(`${at} 寫死 font-size,請改用 var(--hb-text-*):${line.trim()}`);
+ }
+ for (const [, name] of line.matchAll(/var\((--hb-[a-z0-9-]+)/g)) {
+ if (!defined.has(name)) {
+ errors.push(`${at} 用了 tokens.css 沒有的 ${name}(拼錯?)`);
+ }
+ }
+ for (const [, a, b] of line.matchAll(MIX)) {
+ if (CHROMATIC.test(a) && CHROMATIC.test(b)) {
+ errors.push(
+ `${at} 混了兩個有彩度的顏色(${a} + ${b}),會變濁。` +
+ `要調淡請跟 surface/line/transparent 混,要深一階請用 -deep。`,
+ );
+ }
+ }
+ });
+
+ const hex = (text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).filter((h) => !NEUTRAL.test(h));
+ const budget = HEX_BUDGET[file] ?? 0;
+ if (hex.length > budget) {
+ errors.push(
+ `${path} 寫死顏色 ${hex.length} 處(上限 ${budget}):${[...new Set(hex)].join(" ")}。` +
+ `顏色請走 tokens.css,否則深淺模式會有一半畫面不跟著切。`,
+ );
+ }
+}
+
+for (const path of walk("src")) {
+ if (!path.endsWith(".tsx")) continue;
+ const text = readFileSync(path, "utf8");
+ text.split("\n").forEach((line, i) => {
+ const at = `${path}:${i + 1}`;
+ if (/fontSize:\s*"[0-9.]/.test(line)) {
+ errors.push(`${at} inline fontSize 寫死,請改用 var(--hb-text-*)`);
+ }
+ if (/(color|background|borderColor):\s*"#[0-9a-fA-F]{3,8}"/.test(line)) {
+ errors.push(`${at} inline 色碼寫死,請改用 var(--hb-*)`);
+ }
+ });
+}
+
+if (errors.length) {
+ console.error(`樣式把關失敗(${errors.length}):`);
+ for (const e of errors) console.error(" " + e);
+ process.exit(1);
+}
+console.log("樣式把關通過:顏色與字級都來自 tokens.css");
diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx
index ef66e99..8542705 100644
--- a/apps/web/src/components/layout/AccountMenu.tsx
+++ b/apps/web/src/components/layout/AccountMenu.tsx
@@ -32,7 +32,7 @@ export function AccountMenu() {
const avatarAccount = {
display_name: name,
username: member?.email || "user",
- avatar_color: "#20b49c",
+ avatar_color: "var(--hb-brand)",
avatar_url: member?.avatar_url,
};
diff --git a/apps/web/src/components/persona/PersonaWorkbench.tsx b/apps/web/src/components/persona/PersonaWorkbench.tsx
index f0f4f2e..b007cc1 100644
--- a/apps/web/src/components/persona/PersonaWorkbench.tsx
+++ b/apps/web/src/components/persona/PersonaWorkbench.tsx
@@ -377,7 +377,10 @@ export function PersonaWorkbench() {
{toneOf(p) || t("common.dash")}
{p.learning_summary ? (
-
+
{p.learning_summary}
) : null}
@@ -511,7 +514,7 @@ export function PersonaWorkbench() {
})
}
/>
-
+
{t("persona.guardChars", { n: selected.guard.maxChars })}
{selected.guard.banAiTone ? t("persona.banAi") : ""}
{!isPersonaReady(selected) ? t("persona.notReadySuffix") : ""}
@@ -613,7 +616,7 @@ export function PersonaWorkbench() {
{hasAnalysis ? (
+
{t("persona.sampleMeta", { n: selected.style.sampleCount })}
{" · "}
{selected.style.source === "benchmark"
@@ -643,7 +646,7 @@ export function PersonaWorkbench() {
) : null}
+
{t("persona.analyzeHint")}
+
{t("persona.fingerprintHint")}
+
{t("image.uploadingN", { n: uploadingN })}
+
{err}
+
{t("reply.notReady")}
+
{copyHint}
) : null} @@ -507,7 +507,7 @@ export function AdminUsersPage() { ) : null} {query ? ( -+
{t("admin.users.searchActive", { query })}
) : null} @@ -582,7 +582,7 @@ export function AdminUsersPage() { {selected.email}- + {t("invite.openFromAdmin")}
diff --git a/apps/web/src/pages/BenchmarkPage.tsx b/apps/web/src/pages/BenchmarkPage.tsx index e9f20b1..ee93710 100644 --- a/apps/web/src/pages/BenchmarkPage.tsx +++ b/apps/web/src/pages/BenchmarkPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { PageHeader } from "../components/layout/PageHeader"; -import { Button, Card, Input } from "../components/ui"; +import { Button, Card } from "../components/ui"; import { apiRequest } from "../data/live/http"; type Bench = { @@ -16,19 +16,19 @@ type Bench = { }; export function BenchmarkPage() { - const [niche, setNiche] = useState("general"); const [data, setData] = useState{err}
: null} {data ? (利基:{data.niche} · 樣本 {data.sample_size}
+樣本 {data.sample_size}
{data.available ? ( <>diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx index 96d6ec8..e3d1d29 100644 --- a/apps/web/src/pages/CrewPage.tsx +++ b/apps/web/src/pages/CrewPage.tsx @@ -214,7 +214,7 @@ export function CrewPage() { -
+
{t("crew.tokenRenewHint")}
{done.message}
-+
{t("forgot.checkInbox")}
-+
{t("forgot.nextStep")}
{t("forgot.openReset")} @@ -69,7 +69,7 @@ export function ForgotPasswordPage() { ) : (