fix frontend

This commit is contained in:
王性驊 2026-07-10 05:10:31 +00:00
parent 2e0b625735
commit c57987f6a8
147 changed files with 30839 additions and 172 deletions

View File

@ -34,7 +34,7 @@ A 地基(登入 / 權限 / Threads / Job / 通知)
```bash
cd apps/web
npm install
npm run dev # 本機開發
npm run dev # 本機開發,監聽 0.0.0.0:5173區網可連
npm run build # 型別檢查 + 生產建置
```

View File

@ -4,7 +4,36 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Harbor Desk · 巡樓</title>
<title>巡樓 · Lapras</title>
<!-- 先套主題,避免閃白/閃黑(與 harbor.ui.prefs 同步) -->
<script>
(function () {
try {
var theme = "system";
var raw = localStorage.getItem("harbor.ui.prefs");
if (raw) {
var p = JSON.parse(raw);
if (p && (p.theme === "light" || p.theme === "dark" || p.theme === "system")) {
theme = p.theme;
}
}
var dark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>
<!-- Inter (Latin); 中文由 Taipei Sans TCnpm承接 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View File

@ -1,15 +1,17 @@
{
"name": "web",
"name": "@harbor/web",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"name": "@harbor/web",
"version": "0.0.0",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"taipei-sans-tc": "^0.1.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
@ -738,6 +740,19 @@
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -1187,6 +1202,44 @@
"react": "^19.2.7"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@ -1227,6 +1280,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1237,6 +1296,12 @@
"node": ">=0.10.0"
}
},
"node_modules/taipei-sans-tc": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz",
"integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",

View File

@ -4,14 +4,16 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"taipei-sans-tc": "^0.1.1"
},
"devDependencies": {
"@types/node": "^24.13.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,50 +0,0 @@
.scaffold {
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.75rem;
padding: 2rem;
box-sizing: border-box;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #1e293b;
background: #f8fafc;
}
.scaffold-kicker {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #2a9d8f;
}
.scaffold h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
.scaffold-lead {
margin: 0;
max-width: 28rem;
line-height: 1.6;
color: #475569;
}
.scaffold-meta {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #64748b;
}
.scaffold code {
font-size: 0.85em;
padding: 0.1em 0.35em;
border-radius: 0.25rem;
background: #e2e8f0;
color: #0f172a;
}

View File

@ -1,19 +1,79 @@
import "./App.css";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { RequireAuth } from "./auth/RequireAuth";
import { AppShell } from "./components/layout/AppShell";
import { DataProvider } from "./data/DataContext";
import { I18nProvider } from "./i18n/I18nContext";
import { ThemeProvider } from "./theme/ThemeContext";
import { AdminUsersPage } from "./pages/AdminUsersPage";
import { BrandsPage } from "./pages/BrandsPage";
import { CrewPage } from "./pages/CrewPage";
import { JobDetailPage } from "./pages/JobDetailPage";
import { InsightsPage } from "./pages/InsightsPage";
import { JobsPage } from "./pages/JobsPage";
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
import { LoginPage } from "./pages/LoginPage";
import { OutboxDetailPage } from "./pages/OutboxDetailPage";
import { OutboxPage } from "./pages/OutboxPage";
import { ProfilePage } from "./pages/ProfilePage";
import { ResetPasswordPage } from "./pages/ResetPasswordPage";
import { ScoutPage } from "./pages/ScoutPage";
import { SettingsPage } from "./pages/SettingsPage";
import { PlanCheckoutPage } from "./pages/PlanCheckoutPage";
import { PlansPage } from "./pages/PlansPage";
import { UsagePage } from "./pages/UsagePage";
import { StudioPage } from "./pages/StudioPage";
import { VerifyEmailPage } from "./pages/VerifyEmailPage";
import { WizardPage } from "./pages/studio/WizardPage";
import { TodayPage } from "./pages/TodayPage";
/**
* T001 scaffold placeholder. Routing / Harbor Desk shell start at T004T005.
*/
export default function App() {
return (
<main className="scaffold">
<p className="scaffold-kicker">Harbor Desk</p>
<h1>apps/web</h1>
<p className="scaffold-lead">
Console T001 token App Shell
</p>
<p className="scaffold-meta">
<code>npm run dev</code> · <code>npm run build</code>
</p>
</main>
<ThemeProvider>
<I18nProvider>
<DataProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route
path="/app"
element={
<RequireAuth>
<AppShell />
</RequireAuth>
}
>
<Route index element={<Navigate to="today" replace />} />
<Route path="today" element={<TodayPage />} />
<Route path="crew" element={<CrewPage />} />
<Route path="studio" element={<StudioPage />} />
<Route path="studio/new" element={<WizardPage />} />
<Route path="studio/:playId" element={<WizardPage />} />
<Route path="outbox" element={<OutboxPage />} />
<Route path="outbox/:id" element={<OutboxDetailPage />} />
<Route path="scout" element={<ScoutPage />} />
<Route path="brands" element={<BrandsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="usage" element={<UsagePage />} />
<Route path="usage/plans" element={<PlansPage />} />
<Route path="usage/checkout" element={<PlanCheckoutPage />} />
<Route path="jobs" element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="insights" element={<InsightsPage />} />
</Route>
<Route path="/" element={<Navigate to="/app/today" replace />} />
<Route path="*" element={<Navigate to="/app/today" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</DataProvider>
</I18nProvider>
</ThemeProvider>
);
}

View File

@ -0,0 +1,79 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Member } from "../domain/types";
import type { MemberProfilePatch } from "../data/repos";
import { useRepos } from "../data/DataContext";
type AuthContextValue = {
member: Member | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
reload: () => Promise<void>;
updateProfile: (patch: MemberProfilePatch) => Promise<Member>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const repos = useRepos();
const [member, setMember] = useState<Member | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
// 已進過 app 後不要再把 loading=true否則 RequireAuth 會卸載整棵 /app
// 各頁本地 state如用量「全體」分頁會被重掛成預設。
try {
const me = await repos.auth.me();
setMember(me);
} finally {
setLoading(false);
}
}, [repos.auth]);
useEffect(() => {
void reload();
}, [reload]);
const login = useCallback(
async (email: string, password: string) => {
const { member: m } = await repos.auth.login(email, password);
setMember(m);
},
[repos.auth],
);
const logout = useCallback(async () => {
await repos.auth.logout();
setMember(null);
}, [repos.auth]);
const updateProfile = useCallback(
async (patch: MemberProfilePatch) => {
const next = await repos.auth.updateProfile(patch);
setMember(next);
return next;
},
[repos.auth],
);
const value = useMemo(
() => ({ member, loading, login, logout, reload, updateProfile }),
[member, loading, login, logout, reload, updateProfile],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@ -0,0 +1,32 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { member, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="hb-login">
<p className="text-muted"></p>
</div>
);
}
if (!member) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
// 未驗證信箱:擋下所有 /app導向驗證頁
if (!member.email_verified) {
return (
<Navigate
to="/verify-email"
replace
state={{ from: location.pathname + location.search }}
/>
);
}
return children;
}

View File

@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { AccountAvatar } from "../ui/AccountAvatar";
import { useI18n } from "../../i18n/I18nContext";
import { memberRoleSummary } from "../../lib/memberRole";
import { useTheme } from "../../theme/ThemeContext";
/**
*
* UsagePlanWidget
*/
export function AccountMenu() {
const { member, logout } = useAuth();
const { t } = useI18n();
const { resolved, toggle } = useTheme();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const name = member?.display_name || "…";
const role = memberRoleSummary(member, t);
const avatarAccount = {
display_name: name,
username: member?.email || "user",
avatar_color: "#6dbf7a",
avatar_url: member?.avatar_url,
};
return (
<div className="hb-account" ref={rootRef}>
<button
type="button"
className={`hb-account__trigger${open ? " is-open" : ""}`}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
title={`${member?.email || t("topbar.account")} · ${role.primaryLabel}`}
>
<AccountAvatar account={avatarAccount} className="hb-account__avatar" />
<span className="hb-account__name">{name}</span>
<span className="hb-account__chev" aria-hidden>
</span>
</button>
{open ? (
<div className="hb-account__panel" role="menu">
<div className="hb-account__meta">
<div className="hb-account__meta-head">
<AccountAvatar account={avatarAccount} size="md" />
<div className="hb-account__meta-text">
<strong>{name}</strong>
<span className="text-muted">{member?.email || "—"}</span>
</div>
</div>
<div className="hb-account__badges">
<span className={`hb-account__role${role.isAdmin ? " is-admin" : ""}`}>
{role.primaryLabel}
</span>
<span
className={`hb-account__role${member?.email_verified ? " is-verified" : " is-unverified"}`}
>
{member?.email_verified ? t("role.verified") : t("role.unverified")}
</span>
</div>
</div>
<nav className="hb-account__nav">
{role.isAdmin ? (
<Link
role="menuitem"
to="/app/users"
className="hb-account__link"
onClick={() => setOpen(false)}
>
{t("nav.users")}
</Link>
) : null}
<Link
role="menuitem"
to="/app/profile"
className="hb-account__link"
onClick={() => setOpen(false)}
>
{t("nav.profile")}
</Link>
<Link
role="menuitem"
to="/app/settings"
className="hb-account__link"
onClick={() => setOpen(false)}
>
{t("nav.settings")}
</Link>
<button
type="button"
role="menuitem"
className="hb-account__link hb-account__link--btn"
onClick={() => {
toggle();
}}
>
{resolved === "dark" ? t("settings.themeToLight") : t("settings.themeToDark")}
</button>
</nav>
<button
type="button"
role="menuitem"
className="hb-account__logout"
onClick={() => {
setOpen(false);
void logout().then(() => navigate("/login"));
}}
>
{t("nav.logout")}
</button>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,21 @@
import { Outlet } from "react-router-dom";
import { MobileDock } from "./MobileDock";
import { SidebarNav } from "./SidebarNav";
import { Topbar } from "./Topbar";
export function AppShell() {
return (
<div className="hb-shell">
<Topbar />
<div className="hb-shell__body">
<SidebarNav />
<main className="hb-main">
<div className="hb-main__inner">
<Outlet />
</div>
</main>
</div>
<MobileDock />
</div>
);
}

View File

@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useData, useRepos } from "../../data/DataContext";
import type { AppNotification } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { pathForNotification } from "../../lib/nav";
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
import { Button } from "../ui";
const PREVIEW = 5;
function kindLabel(kind: AppNotification["kind"], t: (k: string) => string): string {
if (kind === "job") return t("nav.jobs");
if (kind === "outbox") return t("nav.outbox");
return t("nav.settings");
}
export function BellMenu() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<AppNotification[]>([]);
const [unread, setUnread] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
void (async () => {
const list = await repos.notifications.list();
// 未讀優先,再依時間
const sorted = list.slice().sort((a, b) => {
const ar = a.read_at ? 1 : 0;
const br = b.read_at ? 1 : 0;
if (ar !== br) return ar - br;
return b.created_at - a.created_at;
});
setItems(sorted);
setUnread(await repos.notifications.unreadCount());
})();
}, [repos.notifications, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
async function openItem(n: AppNotification) {
await repos.notifications.markRead(n.id);
refresh();
setOpen(false);
navigate(pathForNotification(n));
}
const preview = items.slice(0, PREVIEW);
const more = Math.max(0, items.length - PREVIEW);
return (
<div className="hb-bell" ref={rootRef}>
<button
type="button"
className={`hb-bell__trigger${open ? " is-open" : ""}${unread > 0 ? " has-unread" : ""}`}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={
unread > 0 ? `${t("topbar.notifications")}, ${t("topbar.unread", { n: unread })}` : t("topbar.notifications")
}
title={t("topbar.notifications")}
>
<span className="hb-bell__icon" aria-hidden>
{/* 簡化鈴鐺:幾何,不用 emoji */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M6 9.5a6 6 0 1 1 12 0c0 3.2 1.2 4.6 1.8 5.2H4.2C4.8 14.1 6 12.7 6 9.5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M10 18.2a2 2 0 0 0 4 0"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</span>
{unread > 0 ? (
<span className="hb-bell__badge">{unread > 9 ? "9+" : unread}</span>
) : null}
</button>
{open ? (
<div className="hb-bell__panel" role="menu">
<div className="hb-bell__head">
<div className="hb-bell__head-title">
<strong>{t("topbar.notifications")}</strong>
{unread > 0 ? (
<span className="hb-bell__head-count">
{t("topbar.unread", { n: unread })}
</span>
) : null}
</div>
{items.length > 0 ? (
<button
type="button"
className="hb-bell__text-btn"
onClick={() => {
void repos.notifications.markAllRead().then(() => refresh());
}}
>
{t("topbar.markAllRead")}
</button>
) : null}
</div>
{items.length === 0 ? (
<p className="hb-bell__empty">{t("topbar.noNotifications")}</p>
) : (
<ul className="hb-bell__list">
{preview.map((n) => (
<li key={n.id}>
<button
type="button"
className={`hb-bell__item${!n.read_at ? " is-unread" : ""}`}
onClick={() => void openItem(n)}
>
<span className="hb-bell__item-top">
<span className="hb-bell__kind">{kindLabel(n.kind, t)}</span>
<span className="hb-bell__time" title={formatLocalDateTime(n.created_at)}>
{formatTimeAgo(n.created_at)}
</span>
</span>
<strong className="hb-bell__item-title">{n.title}</strong>
{n.body ? <span className="hb-bell__item-body">{n.body}</span> : null}
</button>
</li>
))}
</ul>
)}
<div className="hb-bell__foot">
{more > 0 ? (
<span className="text-muted hb-bell__more">
{t("topbar.moreOlder", { n: more })}
</span>
) : (
<span />
)}
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
navigate("/app/jobs");
}}
>
{t("topbar.jobsCenter")}
</Button>
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,141 @@
import { useEffect, useId, useRef, useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useI18n } from "../../i18n/I18nContext";
import {
isMoreNavActive,
isNavActive,
mobileDockMoreKeys,
mobileDockPrimaryKeys,
navItemsByKeys,
} from "../../lib/nav";
import { AppIcon } from "../ui/AppIcons";
export function MobileDock() {
const { pathname } = useLocation();
const { t } = useI18n();
const navigate = useNavigate();
const [moreOpen, setMoreOpen] = useState(false);
const sheetRef = useRef<HTMLDivElement>(null);
const moreBtnRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const dockItems = navItemsByKeys(mobileDockPrimaryKeys);
const moreItems = navItemsByKeys(mobileDockMoreKeys);
const moreActive = isMoreNavActive(pathname);
useEffect(() => {
setMoreOpen(false);
}, [pathname]);
useEffect(() => {
if (!moreOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setMoreOpen(false);
}
function onDoc(e: MouseEvent | TouchEvent) {
const node = e.target as Node;
if (sheetRef.current?.contains(node) || moreBtnRef.current?.contains(node)) return;
setMoreOpen(false);
}
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDoc);
document.addEventListener("touchstart", onDoc);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("touchstart", onDoc);
};
}, [moreOpen]);
useEffect(() => {
if (!moreOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [moreOpen]);
function goMore(path: string) {
setMoreOpen(false);
navigate(path);
}
return (
<>
{moreOpen ? (
<div className="hb-dock-more" role="presentation">
<button
type="button"
className="hb-dock-more__scrim"
aria-label={t("common.cancel")}
onClick={() => setMoreOpen(false)}
/>
<div
ref={sheetRef}
className="hb-dock-more__sheet"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="hb-dock-more__handle" aria-hidden />
<h2 id={titleId} className="hb-dock-more__title">
{t("nav.moreTitle")}
</h2>
<ul className="hb-dock-more__list">
{moreItems.map((item) => {
const active = isNavActive(pathname, item);
return (
<li key={item.key}>
<button
type="button"
className={`hb-dock-more__row${active ? " is-active" : ""}`}
onClick={() => goMore(item.path)}
>
<span className="hb-dock-more__ico">
<AppIcon name={item.key} size={20} />
</span>
<span className="hb-dock-more__row-label">{t(item.labelKey)}</span>
{active ? <span className="hb-dock-more__check" aria-hidden></span> : null}
</button>
</li>
);
})}
</ul>
</div>
</div>
) : null}
<nav className="hb-dock" aria-label={t("nav.navigate")}>
{dockItems.map((item) => {
const active = isNavActive(pathname, item);
return (
<NavLink
key={item.key}
to={item.path}
className={`hb-dock__item${active ? " hb-dock__item--active" : ""}`}
>
<span className="hb-dock__icon">
<AppIcon name={item.key} size={22} />
</span>
<span className="hb-dock__label">{t(item.labelKey)}</span>
</NavLink>
);
})}
<button
ref={moreBtnRef}
type="button"
className={`hb-dock__item hb-dock__item--btn${moreOpen || moreActive ? " hb-dock__item--active" : ""}`}
aria-expanded={moreOpen}
aria-haspopup="dialog"
onClick={() => setMoreOpen((v) => !v)}
>
<span className="hb-dock__icon">
<AppIcon name="more" size={22} />
</span>
<span className="hb-dock__label">{t("nav.more")}</span>
</button>
</nav>
</>
);
}

View File

@ -0,0 +1,14 @@
type Props = {
title: string;
description?: string;
};
export function PageHeader({ title, description }: Props) {
const desc = description?.trim();
return (
<header className="hb-page-title">
<h1>{title}</h1>
{desc ? <p>{desc}</p> : null}
</header>
);
}

View File

@ -0,0 +1,32 @@
import { NavLink, useLocation } from "react-router-dom";
import { useI18n } from "../../i18n/I18nContext";
import { isNavActive, primaryNav } from "../../lib/nav";
import { AppIcon } from "../ui/AppIcons";
export function SidebarNav() {
const { pathname } = useLocation();
const { t } = useI18n();
return (
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
<p className="hb-sidebar__label display-en">{t("nav.navigate")}</p>
<nav className="hb-nav">
{primaryNav.map((item) => {
const active = isNavActive(pathname, item);
return (
<NavLink
key={item.key}
to={item.path}
className={`hb-nav__item${active ? " hb-nav__item--active" : ""}`}
>
<span className="hb-nav__ico" aria-hidden>
<AppIcon name={item.key} size={18} />
</span>
<span>{t(item.labelKey)}</span>
</NavLink>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,33 @@
import { Link } from "react-router-dom";
import { BrandMark } from "../ui/BrandMark";
import { useI18n } from "../../i18n/I18nContext";
import { AccountMenu } from "./AccountMenu";
import { BellMenu } from "./BellMenu";
import { UsagePlanWidget } from "./UsagePlanWidget";
export function Topbar() {
const { t, locale } = useI18n();
return (
<header className="hb-topbar">
<Link to="/app/today" className="hb-topbar__brand" title={t("app.tagline")}>
<BrandMark size={32} className="hb-topbar__mark" />
<span className="hb-topbar__title">
<span className="hb-topbar__name">{t("app.name")}</span>
{locale === "zh-TW" ? (
<span className="hb-topbar__en display-en">{t("app.nameEn")}</span>
) : (
<span className="hb-topbar__en display-en">{t("app.nameZh")}</span>
)}
</span>
</Link>
<div className="hb-topbar__actions">
<div className="hb-topbar__cluster" role="group" aria-label={t("nav.navigate")}>
<UsagePlanWidget />
<BellMenu />
<AccountMenu />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useData, useRepos } from "../../data/DataContext";
import { useI18n } from "../../i18n/I18nContext";
import { getPlanRights } from "../../lib/planRights";
import type { PlanId, UsageMonthSummary } from "../../lib/usageMeter";
/**
* +
*
*/
export function UsagePlanWidget() {
const repos = useRepos();
const { tick } = useData();
const { t, formatPlanPrice } = useI18n();
const location = useLocation();
const [summary, setSummary] = useState<UsageMonthSummary | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let alive = true;
void repos.usage.getSummary().then((s) => {
if (alive) setSummary(s);
});
return () => {
alive = false;
};
}, [repos.usage, tick, location.pathname, location.key, open]);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
if (!summary) {
return (
<span className="hb-usage-widget hb-usage-widget--ghost" aria-hidden>
</span>
);
}
const planId = summary.plan.id as PlanId;
const rights = getPlanRights(planId, t);
const cap = summary.plan.monthly_credits;
const used = summary.total_credits;
const left = Math.max(0, summary.remaining_credits);
const pct = cap > 0 ? Math.min(100, Math.round((used / cap) * 100)) : 0;
const tight = !summary.unlimited && (pct >= 70 || used >= cap);
const over = !summary.unlimited && used > cap;
const isFree = planId === "free";
const price = formatPlanPrice(summary.plan.price_twd);
const triggerHint = summary.unlimited
? t("usage.widget.titleUnlimited", { name: summary.plan.name })
: t("usage.widget.titleUsed", { name: summary.plan.name, used, cap });
return (
<div className="hb-usage-widget" ref={rootRef}>
<button
type="button"
className={[
"hb-usage-widget__trigger",
open ? "is-open" : "",
tight ? "is-tight" : "",
isFree ? "is-free" : "",
]
.filter(Boolean)
.join(" ")}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
title={triggerHint}
>
<span className="hb-usage-widget__badge">{summary.plan.name}</span>
<span className="hb-usage-widget__sep" aria-hidden>
·
</span>
{summary.unlimited ? (
<span className="hb-usage-widget__stat" title={t("usage.widget.unlimited")}>
</span>
) : over ? (
<span className="hb-usage-widget__stat hb-usage-widget__stat--warn">
{t("usage.widget.overShort")}
</span>
) : isFree && pct < 70 ? (
<span className="hb-usage-widget__stat hb-usage-widget__stat--cta">
{t("usage.widget.upgradeShort")}
</span>
) : (
<span className="hb-usage-widget__stat">
{t("usage.widget.leftShort", { n: left })}
</span>
)}
</button>
{open ? (
<div
className="hb-usage-widget__panel"
role="dialog"
aria-label={t("usage.widget.dialog")}
>
<div className="hb-usage-widget__head">
<div className="hb-usage-widget__head-main">
<span className="hb-usage-widget__eyebrow">{t("usage.widget.currentPlan")}</span>
<strong className="hb-usage-widget__head-plan">
{summary.plan.name}
{summary.unlimited ? (
<span className="hb-usage-widget__inf" title={t("usage.widget.unlimited")}>
{" "}
</span>
) : null}
</strong>
<span className="hb-usage-widget__price">
{price}
<span className="hb-usage-widget__period">{t("usage.widget.perMonth")}</span>
</span>
</div>
<div className="hb-usage-widget__head-use">
<span className="hb-usage-widget__eyebrow">{t("usage.widget.monthUsage")}</span>
<strong className="hb-usage-widget__head-num">
{used}
<span className="hb-usage-widget__cap">/{cap}</span>
</strong>
<span className="hb-usage-widget__left">
{summary.unlimited
? t("usage.widget.unlimited")
: t("usage.widget.remaining", { n: left })}
</span>
</div>
</div>
{!summary.unlimited ? (
<div
className="hb-usage-widget__track"
role="meter"
aria-valuenow={used}
aria-valuemin={0}
aria-valuemax={cap}
aria-label={t("usage.widget.ariaUsed", { used, cap })}
>
<div
className={`hb-usage-widget__track-fill${over ? " is-over" : tight ? " is-warn" : ""}`}
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
) : null}
<ul className="hb-usage-widget__bullets">
{rights.bullets.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
{tight ? <p className="hb-usage-widget__nudge">{t("usage.widget.nudge")}</p> : null}
<div className="hb-usage-widget__actions">
<Link
to="/app/usage/plans"
className="hb-btn hb-btn--primary hb-usage-widget__btn"
onClick={() => setOpen(false)}
>
{isFree || tight ? t("usage.widget.upgrade") : t("usage.widget.changePlan")}
</Link>
<Link
to="/app/usage"
className="hb-btn hb-btn--ghost hb-usage-widget__btn"
onClick={() => setOpen(false)}
>
{t("usage.widget.usageDetail")}
</Link>
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,549 @@
import { useEffect, useMemo, useState } from "react";
import { Badge, Button, EmptyState, Input, Select, Textarea } from "../ui";
import { useData, useRepos } from "../../data/DataContext";
import type { Persona, StyleDimKey, ThreadsAccount } from "../../domain/types";
import { mockGenerateOwnPostReply, mockGenerateRoot } from "../../lib/mockAi";
import { newId } from "../../lib/id";
import { useI18n } from "../../i18n/I18nContext";
import {
DIM_ORDER,
buildPersonaPromptBlock,
createEmptyPersona,
isPersonaReady,
markReady,
serializeDraftText,
toneOf,
} from "../../lib/personaPrompt";
type AnalyzeMode = "account" | "text";
type DetailTab = "overview" | "analyze" | "fingerprint" | "preview";
export function PersonaWorkbench() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [personas, setPersonas] = useState<Persona[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [activeId, setActiveId] = useState("");
const [selectedId, setSelectedId] = useState("");
const [detailTab, setDetailTab] = useState<DetailTab>("overview");
const [analyzeMode, setAnalyzeMode] = useState<AnalyzeMode>("account");
const [benchmarkUsername, setBenchmarkUsername] = useState("");
const [refText, setRefText] = useState("");
const [sourceLabel, setSourceLabel] = useState("");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [previewPost, setPreviewPost] = useState("");
const [previewReply, setPreviewReply] = useState("");
const [promptPreview, setPromptPreview] = useState("");
const [showPrompt, setShowPrompt] = useState(false);
/** mobile: list vs detail */
const [mobileShowDetail, setMobileShowDetail] = useState(false);
const selected = useMemo(
() => personas.find((p) => p.id === selectedId) || personas[0],
[personas, selectedId],
);
function statusLabel(status: string): string {
if (status === "ready") return t("persona.statusReady");
if (status === "analyzing") return t("persona.statusAnalyzing");
return t("persona.statusPending");
}
async function load() {
const [list, active, accList] = await Promise.all([
repos.personas.list(),
repos.personas.getActiveId(),
repos.accounts.list(),
]);
setPersonas(list);
setAccounts(accList);
setActiveId(active);
setSelectedId((cur) => {
if (cur && list.some((p) => p.id === cur)) return cur;
return active || list[0]?.id || "";
});
}
useEffect(() => {
void load();
}, [tick, repos.personas, repos.accounts]); // eslint-disable-line react-hooks/exhaustive-deps
function selectPersona(id: string) {
setSelectedId(id);
setMessage("");
setPreviewPost("");
setPreviewReply("");
setPromptPreview("");
setShowPrompt(false);
setMobileShowDetail(true);
// 未 ready 預設帶到分析
const p = personas.find((x) => x.id === id);
if (p && !isPersonaReady(p)) setDetailTab("analyze");
else setDetailTab("overview");
}
async function createPersona() {
setBusy("create");
try {
const p = createEmptyPersona({
id: newId("persona"),
name: t("persona.newName"),
brief: "",
status: "empty",
});
await repos.personas.save(p);
setSelectedId(p.id);
setDetailTab("analyze");
setMobileShowDetail(true);
setMessage(t("persona.created"));
refresh();
await load();
} finally {
setBusy("");
}
}
async function saveBasics() {
if (!selected) return;
setBusy("save");
try {
const draftText =
selected.style.draftText.trim() || serializeDraftText(selected.style.draft);
const next = markReady({
...selected,
style: { ...selected.style, draftText },
voice: selected.style.draft.tone || selected.voice,
});
if (!draftText) next.status = "empty";
await repos.personas.save(next);
setMessage(t("persona.saved"));
refresh();
await load();
} finally {
setBusy("");
}
}
async function runAnalyzeFromText() {
if (!selected) return;
setBusy("analyze");
setMessage("");
try {
const next = await repos.personas.analyzeFromText(
selected.id,
refText,
sourceLabel || undefined,
);
setSelectedId(next.id);
setMessage(t("persona.textDone", { n: next.style.sampleCount }));
setRefText("");
setDetailTab("fingerprint");
refresh();
await load();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("persona.analyzeFail"));
} finally {
setBusy("");
}
}
async function runAnalyzeFromAccount() {
if (!selected) return;
setBusy("analyze");
setMessage(t("persona.reading"));
try {
const next = await repos.personas.analyzeFromAccount(selected.id, benchmarkUsername);
setSelectedId(next.id);
setMessage(
t("persona.accountDone", {
user: next.style.benchmarkUsername || benchmarkUsername,
n: next.style.sampleCount,
}),
);
setDetailTab("fingerprint");
refresh();
await load();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("persona.analyzeFail"));
} finally {
setBusy("");
}
}
async function setDefault() {
if (!selected) return;
await repos.personas.setActiveId(selected.id);
setActiveId(selected.id);
setMessage(t("persona.setDefaultMsg", { name: selected.name }));
refresh();
}
async function removePersona() {
if (!selected) return;
if (!window.confirm(t("persona.confirmDelete", { name: selected.name }))) return;
setBusy("delete");
try {
await repos.personas.remove(selected.id);
setSelectedId("");
setMobileShowDetail(false);
setMessage(t("persona.deleted"));
refresh();
await load();
} finally {
setBusy("");
}
}
function patchSelected(partial: Partial<Persona>) {
if (!selected) return;
setPersonas((list) => list.map((p) => (p.id === selected.id ? { ...p, ...partial } : p)));
}
function patchDraftText(text: string) {
if (!selected) return;
patchSelected({
style: { ...selected.style, draftText: text },
status: text.trim() ? "ready" : selected.status,
});
}
function runPreview() {
if (!selected || !isPersonaReady(selected)) {
setMessage(t("persona.needReady"));
return;
}
const samplePost = t("persona.previewTopic");
setPreviewPost(mockGenerateRoot(samplePost, selected));
setPreviewReply(
mockGenerateOwnPostReply({
postText: samplePost,
replyText: t("persona.previewReplySample"),
persona: selected,
}),
);
setPromptPreview(buildPersonaPromptBlock(selected, "post"));
}
const hasAnalysis =
selected &&
(selected.status === "ready" || Object.keys(selected.style.dimensions).length > 0);
const listPane = (
<div className="hb-md-list">
<div className="hb-toolbar">
<Button type="button" onClick={() => void createPersona()} disabled={busy === "create"}>
{busy === "create" ? "…" : t("persona.add")}
</Button>
</div>
{personas.length === 0 ? (
<EmptyState title={t("persona.empty")} description={t("persona.emptyDesc")} />
) : (
<ul className="hb-persona-list">
{personas.map((p) => (
<li key={p.id}>
<button
type="button"
className={`hb-persona-item ${selected?.id === p.id ? "is-active" : ""}`}
onClick={() => selectPersona(p.id)}
>
<span className="hb-persona-item__name">{p.name}</span>
<span className="hb-inline-badges">
<Badge
tone={
p.status === "ready" ? "success" : p.status === "analyzing" ? "warning" : "neutral"
}
>
{statusLabel(p.status)}
</Badge>
{activeId === p.id ? <Badge tone="brand">{t("persona.default")}</Badge> : null}
</span>
<span className="text-muted hb-persona-item__tone">{toneOf(p) || t("common.dash")}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
const detailPane = selected ? (
<div className="hb-md-detail">
<div className="hb-md-detail__head">
<button
type="button"
className="hb-back-link"
onClick={() => setMobileShowDetail(false)}
>
{t("persona.backList")}
</button>
<div className="hb-md-detail__title">
<strong>{selected.name}</strong>
<Badge
tone={
selected.status === "ready"
? "success"
: selected.status === "analyzing"
? "warning"
: "neutral"
}
>
{statusLabel(selected.status)}
</Badge>
</div>
</div>
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
<div className="hb-tabs hb-tabs--sm" role="tablist">
{(
[
["overview", "persona.tabOverview"],
["analyze", "persona.tabAnalyze"],
["fingerprint", "persona.tabFingerprint"],
["preview", "persona.tabPreview"],
] as const
).map(([key, labelKey]) => (
<button
key={key}
type="button"
className={`hb-tab ${detailTab === key ? "is-active" : ""}`}
onClick={() => setDetailTab(key)}
>
{t(labelKey)}
</button>
))}
</div>
{detailTab === "overview" ? (
<div className="hb-stack">
<Input
label={t("persona.name")}
value={selected.name}
onChange={(e) => patchSelected({ name: e.target.value })}
/>
<Textarea
label={t("persona.brief")}
value={selected.brief}
onChange={(e) => patchSelected({ brief: e.target.value })}
placeholder={t("persona.briefPh")}
rows={3}
/>
<Input
label={t("persona.avoid")}
value={(selected.guard.avoid || []).join("、")}
onChange={(e) =>
patchSelected({
guard: {
...selected.guard,
avoid: e.target.value
.split(/[、,]/)
.map((s) => s.trim())
.filter(Boolean),
},
})
}
/>
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("persona.guardChars", { n: selected.guard.maxChars })}
{selected.guard.banAiTone ? t("persona.banAi") : ""}
{!isPersonaReady(selected) ? t("persona.notReadySuffix") : ""}
</p>
<div className="hb-wizard-actions">
<Button type="button" onClick={() => void saveBasics()} disabled={busy === "save"}>
{t("common.save")}
</Button>
<Button type="button" variant="ghost" onClick={() => void setDefault()}>
{t("persona.setDefault")}
</Button>
<Button
type="button"
variant="danger"
onClick={() => void removePersona()}
disabled={busy === "delete"}
>
{t("common.delete")}
</Button>
</div>
</div>
) : null}
{detailTab === "analyze" ? (
<div className="hb-stack">
<div className="hb-tabs hb-tabs--sm" role="tablist">
<button
type="button"
className={`hb-tab ${analyzeMode === "account" ? "is-active" : ""}`}
onClick={() => setAnalyzeMode("account")}
>
{t("persona.modeAccount")}
</button>
<button
type="button"
className={`hb-tab ${analyzeMode === "text" ? "is-active" : ""}`}
onClick={() => setAnalyzeMode("text")}
>
{t("persona.modeText")}
</button>
</div>
{analyzeMode === "account" ? (
<div className="hb-stack">
<Input
label={t("persona.username")}
value={benchmarkUsername}
onChange={(e) => setBenchmarkUsername(e.target.value.replace(/^@/, ""))}
placeholder="harbor_main"
/>
{accounts.length > 0 ? (
<Select
label={t("persona.fromBound")}
value=""
onChange={(e) => {
if (e.target.value) setBenchmarkUsername(e.target.value);
}}
>
<option value="">{t("persona.select")}</option>
{accounts.map((a) => (
<option key={a.id} value={a.username}>
{a.display_name} · @{a.username}
</option>
))}
</Select>
) : null}
<Button
type="button"
onClick={() => void runAnalyzeFromAccount()}
disabled={busy === "analyze" || !benchmarkUsername.trim()}
>
{busy === "analyze" ? t("persona.crawlBusy") : t("persona.crawlAnalyze")}
</Button>
</div>
) : (
<div className="hb-stack">
<Textarea
label={t("persona.refText")}
value={refText}
onChange={(e) => setRefText(e.target.value)}
placeholder={t("persona.refTextPh")}
rows={6}
/>
<Input
label={t("persona.sourceLabel")}
value={sourceLabel}
onChange={(e) => setSourceLabel(e.target.value)}
placeholder={t("persona.sourcePh")}
/>
<Button
type="button"
onClick={() => void runAnalyzeFromText()}
disabled={busy === "analyze" || !refText.trim()}
>
{busy === "analyze" ? t("persona.analyzeBusy") : t("persona.analyzeText")}
</Button>
</div>
)}
{hasAnalysis ? (
<div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}>
{t("persona.sampleMeta", { n: selected.style.sampleCount })}
{" · "}
{selected.style.source === "benchmark"
? `@${selected.style.benchmarkUsername || t("common.dash")}`
: selected.style.source === "manual"
? `${t("persona.sourceManual")}${selected.style.sourceLabel ? ` · ${selected.style.sourceLabel}` : ""}`
: selected.style.source}
</p>
<div className="hb-dim-grid">
{DIM_ORDER.map((key) => {
const d = selected.style.dimensions[key as StyleDimKey];
if (!d?.summary) return null;
return (
<div key={key} className="hb-dim-card">
<strong>{t(`persona.dim.${key}`)}</strong>
<p>{d.summary}</p>
</div>
);
})}
</div>
{selected.style.samplePreviews?.length ? (
<ul className="hb-evidence" style={{ paddingLeft: "1.1rem" }}>
{selected.style.samplePreviews.slice(0, 3).map((s) => (
<li key={s.slice(0, 20)}>{s}</li>
))}
</ul>
) : null}
</div>
) : (
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
{t("persona.analyzeHint")}
</p>
)}
</div>
) : null}
{detailTab === "fingerprint" ? (
<div className="hb-stack">
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}>
{t("persona.fingerprintHint")}
</p>
<Textarea
label={t("persona.fingerprint")}
value={selected.style.draftText}
onChange={(e) => patchDraftText(e.target.value)}
placeholder={t("persona.fingerprintPh")}
rows={14}
/>
<Button type="button" onClick={() => void saveBasics()} disabled={busy === "save"}>
{t("persona.saveFingerprint")}
</Button>
</div>
) : null}
{detailTab === "preview" ? (
<div className="hb-stack">
<Button type="button" onClick={runPreview} disabled={!isPersonaReady(selected)}>
{t("persona.tryGen")}
</Button>
{!isPersonaReady(selected) ? (
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
{t("persona.notReadyMsg")}
</p>
) : null}
{previewPost ? (
<>
<Textarea label={t("persona.rootPost")} value={previewPost} readOnly rows={5} />
<Textarea label={t("persona.reply")} value={previewReply} readOnly rows={5} />
<Button type="button" variant="ghost" onClick={() => setShowPrompt((v) => !v)}>
{showPrompt ? t("persona.hidePrompt") : t("persona.showPrompt")}
</Button>
{showPrompt ? (
<Textarea label={t("persona.promptBlock")} value={promptPreview} readOnly rows={8} />
) : null}
</>
) : null}
</div>
) : null}
</div>
) : (
<div className="hb-md-detail">
<EmptyState title={t("persona.pickOne")} description={t("persona.pickDesc")} />
</div>
);
return (
<div
className={`hb-master-detail ${mobileShowDetail ? "is-detail" : "is-list"}`}
>
{listPane}
{detailPane}
</div>
);
}

View File

@ -0,0 +1,109 @@
import { useRef, useState } from "react";
import { Button } from "../ui";
import { useI18n } from "../../i18n/I18nContext";
import {
filesToAttachedImages,
MAX_ATTACHED_IMAGES,
type AttachedImage,
} from "../../lib/attachImage";
type Props = {
images: AttachedImage[];
onChange: (next: AttachedImage[]) => void;
/** 最多張數,預設 10 */
max?: number;
disabled?: boolean;
/** 按鈕文案 */
label?: string;
};
export function ImageAttach({
images,
onChange,
max = MAX_ATTACHED_IMAGES,
disabled,
label,
}: Props) {
const { t } = useI18n();
const buttonLabel = label ?? t("image.attach");
const inputRef = useRef<HTMLInputElement>(null);
const [err, setErr] = useState("");
async function onPick(files: FileList | null) {
if (!files?.length) return;
setErr("");
try {
const added = await filesToAttachedImages(files, {
max,
existingCount: images.length,
});
onChange([...images, ...added]);
} catch (e) {
setErr(e instanceof Error ? e.message : t("image.attachFail"));
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
function remove(id: string) {
onChange(images.filter((i) => i.id !== id));
}
const full = images.length >= max;
return (
<div className="hb-image-attach">
{images.length > 0 ? (
<div className="hb-thumb-row" aria-label={t("image.attachedAria")}>
{images.map((img) => (
<div key={img.id} className="hb-thumb-item">
<img
src={img.url}
alt={img.name || img.prompt || t("image.alt")}
className="hb-thumb"
title={img.name || img.prompt || ""}
/>
<button
type="button"
className="hb-thumb-item__remove"
aria-label={t("image.remove")}
disabled={disabled}
onClick={() => remove(img.id)}
>
×
</button>
</div>
))}
</div>
) : null}
<div className="hb-wizard-actions" style={{ margin: 0 }}>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hb-file-input"
disabled={disabled || full}
onChange={(e) => void onPick(e.target.files)}
/>
<Button
type="button"
variant="ghost"
disabled={disabled || full}
onClick={() => inputRef.current?.click()}
>
{full
? t("image.full", { max })
: images.length
? t("image.more", { n: images.length, max })
: buttonLabel}
</Button>
</div>
{err ? (
<p className="hb-form-error" role="alert" style={{ margin: 0, fontSize: "0.8rem" }}>
{err}
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,64 @@
import type { OwnPost } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
type Props = {
post: OwnPost;
/** compact = 一排 badgedetail = 網格全欄位 */
variant?: "compact" | "detail";
};
function n(v: number | undefined | null): number {
return typeof v === "number" && !Number.isNaN(v) ? v : 0;
}
/** Threads API 可對齊的成效列:讚/回/轉發/引用/瀏覽/分享 */
export function PostMetrics({ post, variant = "compact" }: Props) {
const { t, locale } = useI18n();
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
const rows: { key: string; label: string; value: number }[] = [
{ key: "like", label: t("metrics.like"), value: n(post.like_count) },
{ key: "reply", label: t("metrics.reply"), value: n(post.reply_count) },
{ key: "repost", label: t("metrics.repost"), value: n(post.repost_count) },
{ key: "quote", label: t("metrics.quote"), value: n(post.quote_count) },
{ key: "view", label: t("metrics.view"), value: n(post.view_count) },
{ key: "share", label: t("metrics.share"), value: n(post.share_count) },
];
if (variant === "detail") {
return (
<div className="hb-metrics-grid" role="group" aria-label={t("metrics.aria")}>
{rows.map((r) => (
<div key={r.key} className="hb-metrics-cell">
<span className="hb-metrics-cell__val">{r.value.toLocaleString(dateLocale)}</span>
<span className="hb-metrics-cell__label">{r.label}</span>
</div>
))}
</div>
);
}
return (
<div className="hb-metrics-inline" role="group" aria-label={t("metrics.aria")}>
{rows.map((r) => (
<span key={r.key} className="hb-metrics-chip">
<strong>{r.value.toLocaleString(dateLocale)}</strong> {r.label}
</span>
))}
</div>
);
}
export function postTypeLabel(
post: OwnPost,
t: (key: string) => string,
): string {
if (post.is_quote_post) return t("metrics.type.quote");
if (post.is_reply) return t("metrics.type.reply");
const mt = (post.media_type || "").toUpperCase();
if (mt.includes("IMAGE")) return t("metrics.type.image");
if (mt.includes("VIDEO")) return t("metrics.type.video");
if (mt.includes("CAROUSEL")) return t("metrics.type.carousel");
if (mt.includes("REPOST")) return t("metrics.type.repost");
if (mt.includes("TEXT")) return t("metrics.type.text");
return post.media_type || t("metrics.type.post");
}

View File

@ -0,0 +1,103 @@
import { Button, Select, Textarea } from "../ui";
import type { Persona, ThreadsAccount } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import type { AttachedImage } from "../../lib/attachImage";
import { isPersonaReady, personaOptionLabel } from "../../lib/personaPrompt";
import { ImageAttach } from "./ImageAttach";
export type ReplySelection = {
accountId: string;
personaId: string;
};
type Props = {
accounts: ThreadsAccount[];
personas: Persona[];
selection: ReplySelection;
onSelectionChange: (next: ReplySelection) => void;
text: string;
onTextChange: (text: string) => void;
onGenerate: () => void;
onSend: () => void;
generating?: boolean;
sending?: boolean;
/** 主貼回覆 / 回留言 */
label?: string;
/** 附圖(選填) */
images?: AttachedImage[];
onImagesChange?: (next: AttachedImage[]) => void;
};
export function ReplyComposer({
accounts,
personas,
selection,
onSelectionChange,
text,
onTextChange,
onGenerate,
onSend,
generating,
sending,
label,
images,
onImagesChange,
}: Props) {
const { t } = useI18n();
const draftLabel = label ?? t("reply.draft");
const usable = accounts.filter((a) => a.is_usable);
const persona = personas.find((p) => p.id === selection.personaId);
const ready = isPersonaReady(persona);
const canAttach = Boolean(onImagesChange);
return (
<div className="hb-reply-composer">
<div className="hb-reply-composer__meta">
<Select
label={t("reply.account")}
value={selection.accountId}
onChange={(e) => onSelectionChange({ ...selection, accountId: e.target.value })}
>
{usable.map((a) => (
<option key={a.id} value={a.id}>
{a.display_name} · @{a.username}
</option>
))}
</Select>
<Select
label={t("reply.persona")}
value={selection.personaId}
onChange={(e) => onSelectionChange({ ...selection, personaId: e.target.value })}
>
{personas.map((p) => (
<option key={p.id} value={p.id}>
{personaOptionLabel(p)}
</option>
))}
</Select>
</div>
{!ready ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("reply.notReady")}
</p>
) : null}
<Textarea label={draftLabel} value={text} onChange={(e) => onTextChange(e.target.value)} rows={4} />
{canAttach ? (
<ImageAttach
images={images || []}
onChange={onImagesChange!}
disabled={sending}
label={t("reply.attach")}
/>
) : null}
<div className="hb-wizard-actions">
<Button type="button" variant="ghost" disabled={!ready || generating} onClick={onGenerate}>
{generating ? t("reply.generating") : t("reply.ai")}
</Button>
<Button type="button" disabled={!text.trim() || sending || !selection.accountId} onClick={onSend}>
{sending ? t("reply.sending") : t("reply.send")}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { useI18n } from "../../i18n/I18nContext";
import { WIZARD_STEPS } from "../../pages/studio/wizardState";
type Props = {
activeIndex: number;
onSelect?: (index: number) => void;
};
export function Stepper({ activeIndex, onSelect }: Props) {
const { t } = useI18n();
return (
<ol className="hb-stepper" aria-label={t("wizard.stepperAria")}>
{WIZARD_STEPS.map((labelKey, index) => {
const active = index === activeIndex;
const done = index < activeIndex;
return (
<li key={labelKey}>
<button
type="button"
className={`hb-stepper__item${active ? " is-active" : ""}${done ? " is-done" : ""}`}
onClick={() => onSelect?.(index)}
>
<span className="hb-stepper__num">{index + 1}</span>
<span>{t(labelKey)}</span>
</button>
</li>
);
})}
</ol>
);
}

View File

@ -0,0 +1,54 @@
import type { ThreadsAccount } from "../../domain/types";
type Props = {
account: Pick<ThreadsAccount, "display_name" | "username" | "avatar_color"> & {
avatar_url?: string | null;
};
size?: "sm" | "md" | "lg";
className?: string;
};
function initialsOf(account: Props["account"]): string {
const name = (account.display_name || account.username || "?").trim();
// 取顯示名最後一段中文/英文
const parts = name.replace(/·/g, " ").split(/\s+/).filter(Boolean);
const last = parts[parts.length - 1] || name;
if (/[\u4e00-\u9fff]/.test(last)) return last.slice(0, 1);
return last.slice(0, 2).toUpperCase();
}
export function AccountAvatar({ account, size = "md", className = "" }: Props) {
const sizeClass = size === "lg" ? "hb-avatar--lg" : size === "sm" ? "hb-avatar--sm" : "";
const url = account.avatar_url?.trim();
if (url) {
return (
<img
className={`hb-avatar hb-avatar--img ${sizeClass} ${className}`.trim()}
src={url}
alt=""
width={size === "lg" ? 44 : size === "sm" ? 22 : 28}
height={size === "lg" ? 44 : size === "sm" ? 22 : 28}
loading="lazy"
referrerPolicy="no-referrer"
/>
);
}
return (
<span
className={`hb-avatar hb-avatar--initials ${sizeClass} ${className}`.trim()}
style={{ background: account.avatar_color || "#8b95a8" }}
aria-hidden
>
{initialsOf(account)}
</span>
);
}
/** mock 頭像 URLDicebear 軟粉彩;接真後改 Threads profile picture */
export function mockAvatarUrl(username: string): string {
const seed = encodeURIComponent(username.replace(/^@/, "") || "harbor");
// mint / sky / lavender / peach / cream — 對齊小繽紛氛圍
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf,b6f3e4`;
}

View File

@ -0,0 +1,129 @@
import type { ReactNode, SVGProps } from "react";
import type { NavKey } from "../../lib/nav";
export type AppIconName = NavKey | "more" | "spark";
type Props = {
name: AppIconName;
size?: number;
className?: string;
} & Omit<SVGProps<SVGSVGElement>, "name">;
/**
* 稿 icon
* currentColor light / dark
*/
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={`hb-app-icon ${className}`.trim()}
aria-hidden
{...rest}
>
{glyphs[name]}
</svg>
);
}
const stroke = {
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
/** 小星芒 */
function Spark({ x = 18, y = 5, s = 1 }: { x?: number; y?: number; s?: number }) {
return (
<g opacity={0.85} transform={`translate(${x} ${y}) scale(${s})`}>
<path d="M0-2.2V2.2M-2.2 0H2.2" {...stroke} strokeWidth={1.35} />
</g>
);
}
const glyphs: Record<AppIconName, ReactNode> = {
// 今日:圓角日曆 + 星
today: (
<>
<rect x="3.5" y="5" width="17" height="15" rx="3.5" {...stroke} />
<path d="M8 3.75v3.1M16 3.75v3.1M3.5 10.25h17" {...stroke} />
<circle cx="9.2" cy="14.6" r="1.05" fill="currentColor" opacity={0.9} />
<Spark x={16.2} y={14.4} s={0.72} />
</>
),
// 帳號:雙圓 + 柔弧(島民)
crew: (
<>
<circle cx="9" cy="8.6" r="3" {...stroke} />
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
<Spark x={18.8} y={5.2} s={0.55} />
</>
),
// 創作:羽毛筆 + 星
studio: (
<>
<path
d="M5.2 19.2 14.6 5.4a1.7 1.7 0 0 1 2.45-.15l1.9 1.9a1.7 1.7 0 0 1-.15 2.45L9.3 19.2H5.2z"
{...stroke}
/>
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
<Spark x={18.5} y={5.5} s={0.7} />
</>
),
// 海巡:水晶球 / 羅盤弧 + 十字光
scout: (
<>
<circle cx="11.2" cy="11.2" r="6.4" {...stroke} />
<circle cx="11.2" cy="11.2" r="2.15" {...stroke} opacity={0.55} />
<path d="M11.2 6.4v9.6M6.4 11.2h9.6" {...stroke} opacity={0.55} />
<path d="M15.8 15.8 20.2 20.2" {...stroke} />
<Spark x={18.2} y={6.2} s={0.62} />
</>
),
// 發送:紙飛機 + 軌跡星
outbox: (
<>
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
<Spark x={7.2} y={7.5} s={0.5} />
</>
),
// 任務:魔法卷軸/手提袋簡化
jobs: (
<>
<rect x="4.2" y="7.2" width="15.6" height="12.2" rx="2.6" {...stroke} />
<path d="M9 7.2V5.9a2.1 2.1 0 0 1 2.1-2.1h1.8A2.1 2.1 0 0 1 15 5.9v1.3" {...stroke} />
<path d="M4.2 12.1h15.6" {...stroke} opacity={0.55} />
<Spark x={17.5} y={15.8} s={0.55} />
</>
),
// 品牌:旗幟堡壘簡化 + 星
brands: (
<>
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
<Spark x={17.8} y={6.4} s={0.58} />
</>
),
more: (
<>
<circle cx="6.5" cy="12" r="1.45" fill="currentColor" />
<circle cx="12" cy="12" r="1.45" fill="currentColor" />
<circle cx="17.5" cy="12" r="1.45" fill="currentColor" />
</>
),
spark: (
<>
<Spark x={12} y={12} s={1.35} />
<circle cx="12" cy="12" r="1.1" fill="currentColor" opacity={0.35} />
</>
),
};

View File

@ -0,0 +1,16 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BadgeTone = "neutral" | "brand" | "success" | "danger" | "warning";
type Props = HTMLAttributes<HTMLSpanElement> & {
tone?: BadgeTone;
children: ReactNode;
};
export function Badge({ tone = "neutral", className = "", children, ...rest }: Props) {
return (
<span className={`hb-badge hb-badge--${tone} ${className}`.trim()} {...rest}>
{children}
</span>
);
}

View File

@ -0,0 +1,23 @@
type Props = {
size?: number;
className?: string;
title?: string;
};
/**
* public/brand-mark.jpg
* +
*/
export function BrandMark({ size = 32, className = "", title }: Props) {
return (
<img
src="/brand-mark.jpg"
width={size}
height={size}
alt={title || ""}
draggable={false}
className={`hb-brand-mark ${className}`.trim()}
aria-hidden={title ? undefined : true}
/>
);
}

View File

@ -0,0 +1,16 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
children: ReactNode;
};
export function Button({ variant = "primary", className = "", children, type = "button", ...rest }: Props) {
return (
<button type={type} className={`hb-btn hb-btn--${variant} ${className}`.trim()} {...rest}>
{children}
</button>
);
}

View File

@ -0,0 +1,15 @@
import type { HTMLAttributes, ReactNode } from "react";
type Props = HTMLAttributes<HTMLElement> & {
title?: string;
children: ReactNode;
};
export function Card({ title, children, className = "", ...rest }: Props) {
return (
<section className={`hb-card ${className}`.trim()} {...rest}>
{title ? <h2 className="hb-card__title">{title}</h2> : null}
<div className="hb-card__body">{children}</div>
</section>
);
}

View File

@ -0,0 +1,17 @@
import type { ReactNode } from "react";
type Props = {
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ title, description, action }: Props) {
return (
<div className="hb-empty">
<h2 className="hb-empty__title">{title}</h2>
{description ? <p className="hb-empty__desc">{description}</p> : null}
{action}
</div>
);
}

View File

@ -0,0 +1,17 @@
import type { InputHTMLAttributes } from "react";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
hint?: string;
};
export function Input({ label, hint, id, className = "", ...rest }: Props) {
const inputId = id || rest.name;
return (
<label className="hb-field" htmlFor={inputId}>
{label ? <span className="hb-field__label">{label}</span> : null}
<input id={inputId} className={`hb-input ${className}`.trim()} {...rest} />
{hint ? <span className="hb-field__hint">{hint}</span> : null}
</label>
);
}

View File

@ -0,0 +1,90 @@
import { Button } from "./Button";
import { Select } from "./Select";
import {
clampPage,
DEFAULT_PAGE_SIZES,
pageCount,
pageRangeLabel,
} from "../../lib/pagination";
import { useI18n } from "../../i18n/I18nContext";
type Props = {
/** 總筆數 */
total: number;
/** 目前頁(從 1 起) */
page: number;
pageSize: number;
onPageChange: (page: number) => void;
/** 有傳才顯示每頁筆數 */
onPageSizeChange?: (size: number) => void;
pageSizeOptions?: readonly number[];
/** 僅一頁時是否仍顯示(預設否) */
showWhenSingle?: boolean;
className?: string;
};
/**
*
* 調 pageSize
*/
export function Pager({
total,
page,
pageSize,
onPageChange,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZES,
showWhenSingle = false,
className = "",
}: Props) {
const { t } = useI18n();
const totalPages = pageCount(total, pageSize);
const safePage = clampPage(page, totalPages);
const single = totalPages <= 1;
const hide = total <= 0 || (single && !showWhenSingle && !onPageSizeChange);
if (hide) return null;
return (
<div className={`hb-pager ${className}`.trim()} role="navigation" aria-label={t("pager.nav")}>
<span className="hb-pager__meta text-muted">{pageRangeLabel(safePage, pageSize, total)}</span>
<div className="hb-pager__controls">
{onPageSizeChange ? (
<Select
label=""
aria-label={t("pager.pageSize")}
value={String(pageSize)}
onChange={(e) => {
onPageSizeChange(Number(e.target.value) || pageSize);
onPageChange(1);
}}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{t("pager.perPage", { n })}
</option>
))}
</Select>
) : null}
<Button
type="button"
variant="ghost"
disabled={safePage <= 1}
onClick={() => onPageChange(safePage - 1)}
>
{t("pager.prev")}
</Button>
<span className="hb-pager__page" aria-current="page">
{safePage} / {totalPages}
</span>
<Button
type="button"
variant="ghost"
disabled={safePage >= totalPages}
onClick={() => onPageChange(safePage + 1)}
>
{t("pager.next")}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import type { SelectHTMLAttributes, ReactNode } from "react";
type Props = SelectHTMLAttributes<HTMLSelectElement> & {
label?: string;
hint?: string;
children: ReactNode;
};
export function Select({ label, hint, id, className = "", children, ...rest }: Props) {
const inputId = id || rest.name;
return (
<label className="hb-field" htmlFor={inputId}>
{label ? <span className="hb-field__label">{label}</span> : null}
<select id={inputId} className={`hb-select ${className}`.trim()} {...rest}>
{children}
</select>
{hint ? <span className="hb-field__hint">{hint}</span> : null}
</label>
);
}

View File

@ -0,0 +1,17 @@
import type { TextareaHTMLAttributes } from "react";
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string;
hint?: string;
};
export function Textarea({ label, hint, id, className = "", ...rest }: Props) {
const inputId = id || rest.name;
return (
<label className="hb-field" htmlFor={inputId}>
{label ? <span className="hb-field__label">{label}</span> : null}
<textarea id={inputId} className={`hb-textarea ${className}`.trim()} {...rest} />
{hint ? <span className="hb-field__hint">{hint}</span> : null}
</label>
);
}

View File

@ -0,0 +1,14 @@
export { Button } from "./Button";
export { AppIcon } from "./AppIcons";
export type { AppIconName } from "./AppIcons";
export { BrandMark } from "./BrandMark";
export type { ButtonVariant } from "./Button";
export { Input } from "./Input";
export { Textarea } from "./Textarea";
export { Select } from "./Select";
export { Card } from "./Card";
export { Badge } from "./Badge";
export type { BadgeTone } from "./Badge";
export { EmptyState } from "./EmptyState";
export { Pager } from "./Pager";
export { AccountAvatar, mockAvatarUrl } from "./AccountAvatar";

View File

@ -0,0 +1,77 @@
import { useI18n } from "../../i18n/I18nContext";
import { getPlanRights, planCtaLabel } from "../../lib/planRights";
import { PLANS, type PlanId } from "../../lib/usageMeter";
import { Button } from "../ui/Button";
import { Badge } from "../ui/Badge";
type Props = {
currentId: PlanId;
formatPrice: (twd: number) => string;
onChoose: (id: PlanId) => void;
/** 強調哪一案(預設 starter */
highlighted?: PlanId;
};
/**
* SaaS pricing
*/
export function PlanPricingGrid({
currentId,
formatPrice,
onChoose,
highlighted = "starter",
}: Props) {
const { t } = useI18n();
return (
<div className="hb-pricing">
{(Object.keys(PLANS) as PlanId[]).map((id) => {
const p = PLANS[id];
const rights = getPlanRights(id, t);
const current = currentId === id;
const popular = id === highlighted;
const cta = planCtaLabel(currentId, id, t);
return (
<article
key={id}
className={`hb-pricing__card${current ? " is-current" : ""}${popular ? " is-popular" : ""}`}
>
<header className="hb-pricing__head">
<div className="hb-pricing__title-row">
<h3 className="hb-pricing__name">{p.name}</h3>
{current ? <Badge tone="brand">{t("plans.inUse")}</Badge> : null}
{!current && popular ? <Badge tone="neutral">{t("plans.recommended")}</Badge> : null}
</div>
<p className="hb-pricing__blurb">{rights.headline}</p>
<p className="hb-pricing__price">
<strong>{formatPrice(p.price_twd)}</strong>
<span className="hb-pricing__period">{t("plans.perMonth")}</span>
</p>
<p className="hb-pricing__credits">
{t("plans.creditsPerMonth", { n: p.monthly_credits })}
</p>
</header>
<ul className="hb-pricing__bullets">
{rights.bullets.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
<div className="hb-pricing__cta">
<Button
type="button"
variant={current ? "ghost" : popular ? "primary" : "secondary"}
disabled={current}
onClick={() => onChoose(id)}
>
{cta}
</Button>
</div>
</article>
);
})}
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import { useI18n } from "../../i18n/I18nContext";
import type { UsageEvent } from "../../lib/usageMeter";
import { formatLocalDateTime } from "../../lib/time";
import { Pager } from "../ui/Pager";
type Props = {
events: UsageEvent[];
page: number;
pageSize: number;
onPageChange: (p: number) => void;
pageSlice: <T>(list: T[], page: number, size: number) => T[];
emptyAction?: ReactNode;
};
export function UsageLedger({
events,
page,
pageSize,
onPageChange,
pageSlice,
emptyAction,
}: Props) {
const { t } = useI18n();
if (events.length === 0) {
return <div className="hb-usage-ledger-empty">{emptyAction}</div>;
}
return (
<div className="hb-stack">
<ul className="hb-usage-ledger">
{pageSlice(events, page, pageSize).map((e) => {
const meterLabel = t(`usage.meter.${e.meter}`);
return (
<li
key={e.id}
className="hb-usage-ledger__row"
title={`${meterLabel} · ${e.source}`}
>
<div className="hb-usage-ledger__main">
<strong>{e.label}</strong>
<span className="text-muted hb-usage-ledger__when">
{formatLocalDateTime(e.created_at)}
</span>
</div>
<span
className="hb-usage-ledger__cost"
aria-label={t("usage.ledger.costAria", { n: e.credits })}
>
{e.credits}
</span>
</li>
);
})}
</ul>
<Pager total={events.length} page={page} pageSize={pageSize} onPageChange={onPageChange} />
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import { useI18n } from "../../i18n/I18nContext";
import { METER_META, type UsageMeter } from "../../lib/usageMeter";
export type MeterBarRow = {
meter: UsageMeter;
credits: number;
count: number;
soft_cap: number;
};
type Props = {
rows: MeterBarRow[];
unlimited?: boolean;
};
/**
* hover
*/
export function UsageMeterBars({ rows, unlimited = false }: Props) {
const { t } = useI18n();
const [active, setActive] = useState<UsageMeter | null>(null);
const ordered = rows
.slice()
.sort((a, b) => METER_META[a.meter].order - METER_META[b.meter].order);
return (
<ul className="hb-usage-mbars" onMouseLeave={() => setActive(null)}>
{ordered.map((row) => {
const label = t(`usage.meter.${row.meter}`);
const pct =
row.soft_cap > 0
? Math.min(100, Math.round((row.count / row.soft_cap) * 100))
: 0;
const on = active === row.meter;
const over = row.soft_cap > 0 && row.count > row.soft_cap;
return (
<li key={row.meter}>
<button
type="button"
className={`hb-usage-mbar${on ? " is-on" : ""}${over ? " is-over" : ""}`}
onMouseEnter={() => setActive(row.meter)}
onFocus={() => setActive(row.meter)}
aria-label={t("usage.meter.barAria", {
label,
count: row.count,
credits: row.credits,
cap: row.soft_cap,
})}
>
<span className="hb-usage-mbar__label">{label}</span>
<span className="hb-usage-mbar__track" aria-hidden>
<span
className="hb-usage-mbar__fill"
style={{ width: `${Math.max(pct, row.count > 0 ? 3 : 0)}%` }}
/>
</span>
<span className="hb-usage-mbar__val">
<strong>{row.count}</strong>
<span className="text-muted">/{row.soft_cap}</span>
{on ? <span className="hb-usage-mbar__pts">{row.credits}pt</span> : null}
{unlimited && over ? <span className="hb-usage-mbar__inf"></span> : null}
</span>
</button>
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,158 @@
import { useMemo, useState } from "react";
import { useI18n } from "../../i18n/I18nContext";
import type { UsageTimeBucket } from "../../lib/usageMeter";
type Props = {
series: UsageTimeBucket[];
/** 未 hover 時顯示的區間總計 */
totals?: { purchased: number; consumed: number };
maxLabels?: number;
};
/**
* hoverfocus
*/
export function UsageSeriesChart({ series, totals, maxLabels = 12 }: Props) {
const { t } = useI18n();
const [active, setActive] = useState<number | null>(null);
const maxVal = useMemo(
() =>
Math.max(
1,
...series.map((b) => Math.max(b.purchased_credits, b.consumed_credits)),
totals?.purchased ?? 0,
totals?.consumed ?? 0,
),
[series, totals],
);
if (!series.length) {
return <div className="hb-usage-chart hb-usage-chart--empty" aria-hidden />;
}
const W = 640;
const H = 200;
const padL = 8;
const padR = 8;
const padT = 8;
const padB = 28;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const n = series.length;
const slot = plotW / n;
const barW = Math.max(2, Math.min(16, slot * 0.34));
const gap = Math.max(1, barW * 0.12);
const showEvery = Math.max(1, Math.ceil(n / maxLabels));
const focus = active != null ? series[active] : null;
const readPurchased = focus ? focus.purchased_credits : (totals?.purchased ?? 0);
const readConsumed = focus ? focus.consumed_credits : (totals?.consumed ?? 0);
const readLabel = focus?.label ?? "";
return (
<div
className="hb-usage-chart"
onMouseLeave={() => setActive(null)}
>
<div className="hb-usage-chart__readout" aria-live="polite">
<span className={`hb-usage-chart__period${focus ? " is-focus" : ""}`}>
{readLabel || t("usage.chart.period")}
</span>
<span className="hb-usage-chart__metric hb-usage-chart__metric--buy">
<i className="hb-usage-chart__dot" aria-hidden />
<span className="hb-usage-chart__name">{t("usage.chart.allocated")}</span>
<strong>{readPurchased}</strong>
</span>
<span className="hb-usage-chart__metric hb-usage-chart__metric--use">
<i className="hb-usage-chart__dot" aria-hidden />
<span className="hb-usage-chart__name">{t("usage.chart.consumed")}</span>
<strong>{readConsumed}</strong>
</span>
{readPurchased > 0 ? (
<span className="hb-usage-chart__pct" title={t("usage.chart.pctTitle")}>
{Math.min(999, Math.round((readConsumed / readPurchased) * 100))}%
</span>
) : null}
</div>
<svg
className="hb-usage-chart__svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={t("usage.chart.aria")}
>
{[0.25, 0.5, 0.75].map((tGrid) => {
const y = padT + plotH * (1 - tGrid);
return (
<line
key={tGrid}
x1={padL}
x2={W - padR}
y1={y}
y2={y}
className="hb-usage-chart__grid"
/>
);
})}
{series.map((b, i) => {
const cx = padL + slot * i + slot / 2;
const buyH = (b.purchased_credits / maxVal) * plotH;
const useH = (b.consumed_credits / maxVal) * plotH;
const buyX = cx - barW - gap / 2;
const useX = cx + gap / 2;
const buyY = padT + plotH - buyH;
const useY = padT + plotH - useH;
const showLabel = i % showEvery === 0 || i === n - 1;
const isOn = active === i;
return (
<g
key={b.key}
className={`hb-usage-chart__col${isOn ? " is-on" : ""}`}
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
tabIndex={0}
role="listitem"
aria-label={t("usage.chart.colAria", {
label: b.label,
purchased: b.purchased_credits,
consumed: b.consumed_credits,
})}
>
{/* 寬熱區 */}
<rect
x={padL + slot * i}
y={padT}
width={slot}
height={plotH}
className="hb-usage-chart__hit"
/>
<rect
x={buyX}
y={buyY}
width={barW}
height={Math.max(0, buyH)}
rx={2}
className="hb-usage-chart__bar hb-usage-chart__bar--buy"
/>
<rect
x={useX}
y={useY}
width={barW}
height={Math.max(0, useH)}
rx={2}
className="hb-usage-chart__bar hb-usage-chart__bar--use"
/>
{showLabel ? (
<text x={cx} y={H - 8} textAnchor="middle" className="hb-usage-chart__axis">
{b.label}
</text>
) : null}
</g>
);
})}
</svg>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import type { DataSource } from "../domain/types";
import { createRepos } from "./createRepos";
import type { Repos } from "./repos";
import { getDataSource, setDataSource as persistDataSource } from "./mock/store";
type DataContextValue = {
repos: Repos;
dataSource: DataSource;
setDataSource: (ds: DataSource) => void;
refresh: () => void;
tick: number;
};
const DataContext = createContext<DataContextValue | null>(null);
export function DataProvider({ children }: { children: ReactNode }) {
const [tick, setTick] = useState(0);
const [dataSource, setDs] = useState<DataSource>(() => getDataSource());
const refresh = useCallback(() => setTick((t) => t + 1), []);
const setDataSource = useCallback(
(ds: DataSource) => {
persistDataSource(ds);
setDs(ds);
setTick((t) => t + 1);
},
[],
);
const repos = useMemo(() => createRepos(dataSource), [dataSource, tick]);
const value = useMemo(
() => ({ repos, dataSource, setDataSource, refresh, tick }),
[repos, dataSource, setDataSource, refresh, tick],
);
return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
}
export function useData(): DataContextValue {
const ctx = useContext(DataContext);
if (!ctx) throw new Error("useData must be used within DataProvider");
return ctx;
}
export function useRepos(): Repos {
return useData().repos;
}

View File

@ -0,0 +1,16 @@
import type { DataSource } from "../domain/types";
import type { Repos } from "./repos";
import { createMockRepos, resumeRunningJobs } from "./mock/repos";
import { getDataSource } from "./mock/store";
export function createRepos(source?: DataSource): Repos {
const ds = source ?? getDataSource();
if (ds === "live") {
// Phase B: live not wired — fall back to mock with flag for UI.
const mock = createMockRepos();
return { ...mock, dataSource: "live" };
}
const repos = createMockRepos();
resumeRunningJobs();
return repos;
}

View File

@ -0,0 +1,59 @@
export const KEYS = {
session: "harbor.session",
/** UI 語言與幣別 { locale, currency } */
uiPrefs: "harbor.ui.prefs",
/** mock 會員 email密碼改資料後 logout 仍可登) */
memberCredentials: "harbor.member.credentials",
/** mock 會員公開資料display_name 等) */
memberProfile: "harbor.member.profile",
/** 租戶會員列表管理員用TenantUserRecord[] */
tenantUsers: "harbor.tenant.users",
/** 管理員最近揭示的臨時密碼(可重整;手動關閉才清除) */
adminTempPassword: "harbor.admin.temp_password",
/** mock 重設密碼 token { token, email, expires_at } */
passwordReset: "harbor.auth.password_reset",
/** mock 信箱驗證碼 { email, code, expires_at } */
emailVerification: "harbor.auth.email_verification",
/** AI搜尋用量事件 UsageEvent[](含 uid */
usageEvents: "harbor.usage.events",
/** @deprecated 舊全域方案;改讀 usageMemberPrefs */
usagePlanId: "harbor.usage.plan",
/** 每人方案無限Record<uid, UsageMemberPrefs> */
usageMemberPrefs: "harbor.usage.member_prefs",
/** mock 方案購買紀錄 PlanPurchase[] */
planPurchases: "harbor.usage.plan_purchases",
datasource: "harbor.datasource",
accounts: "harbor.accounts",
plays: "harbor.plays",
outbox: "harbor.outbox",
jobs: "harbor.jobs",
notifications: "harbor.notifications",
personas: "harbor.personas",
ownPosts: "harbor.own_posts",
inspirations: "harbor.inspirations",
trends: "harbor.trends",
mentions: "harbor.mentions",
viralSamples: "harbor.viral_samples",
brands: "harbor.brands",
brandProducts: "harbor.brand_products",
scoutTopics: "harbor.scout_topics",
scoutPosts: "harbor.scout_posts",
/** 探查已完成的功課(依主題) */
scoutHomework: "harbor.scout.homework",
/** 探查今日進度 { date, done, goalActivity, goalValue } */
scoutToday: "harbor.scout.today",
/** 靈感可重用元素庫 */
inspireElements: "harbor.inspire.elements",
/** 靈感當前聊天 session */
inspireSession: "harbor.inspire.session",
aiSettings: "harbor.ai_settings",
placementSettings: "harbor.placement_settings",
/** bump when seed shape changes so local mock re-seeds */
seeded: "harbor.seeded.v8",
playDraft: "harbor.play_draft",
activePersonaId: "harbor.active_persona_id",
activeBrandId: "harbor.active_brand_id",
ownPostsSyncedAt: "harbor.own_posts_synced_at",
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */
accountInsightHistory: "harbor.account.insight_history",
} as const;

View File

@ -0,0 +1,91 @@
import type { OutboxBundle, OutboxStep } from "../../domain/types";
import { nowUnixNano } from "../../lib/time";
function recomputeStatus(steps: OutboxStep[]): OutboxBundle["status"] {
if (steps.every((s) => s.status === "published")) return "completed";
if (steps.some((s) => s.status === "failed")) return "partial_failed";
if (steps.some((s) => s.status === "publishing" || s.status === "published")) return "active";
return "scheduling";
}
export function simulateSuccess(bundle: OutboxBundle): OutboxBundle {
const now = nowUnixNano();
const steps = bundle.steps.map((s) => ({ ...s }));
for (const step of steps) {
if (step.status === "published") continue;
step.status = "published";
step.published_at = now;
step.error = undefined;
}
return {
...bundle,
steps,
status: recomputeStatus(steps),
updated_at: now,
};
}
export function simulateRootFail(bundle: OutboxBundle): OutboxBundle {
const now = nowUnixNano();
const steps = bundle.steps.map((s) => {
if (s.kind === "root") {
return {
...s,
status: "failed" as const,
error: "主貼發送失敗(可重試)",
published_at: undefined,
};
}
return {
...s,
status: "blocked" as const,
error: "等待主貼成功",
published_at: undefined,
};
});
return {
...bundle,
steps,
status: recomputeStatus(steps),
updated_at: now,
};
}
export function retryStep(bundle: OutboxBundle, stepId: string): OutboxBundle {
const now = nowUnixNano();
const steps = bundle.steps.map((s) => ({ ...s }));
const idx = steps.findIndex((s) => s.id === stepId);
if (idx < 0) return bundle;
const target = steps[idx];
if (target.kind === "reply") {
const root = steps.find((s) => s.kind === "root");
if (!root || root.status !== "published") {
target.status = "blocked";
target.error = "主貼尚未成功,無法重試回覆";
return { ...bundle, steps, status: recomputeStatus(steps), updated_at: now };
}
}
target.status = "published";
target.published_at = now;
target.error = undefined;
// Unblock later steps if root is good
const rootOk = steps.find((s) => s.kind === "root")?.status === "published";
if (rootOk) {
for (const s of steps) {
if (s.kind === "reply" && s.status === "blocked") {
s.status = "scheduled";
s.error = undefined;
}
}
}
return {
...bundle,
steps,
status: recomputeStatus(steps),
updated_at: now,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,816 @@
import { mockAvatarUrl } from "../../components/ui/AccountAvatar";
import { newId } from "../../lib/id";
import { buildSeedReadyStyle } from "../../lib/mockStyleAnalyze";
import { emptyGuard, emptyStyle } from "../../lib/personaPrompt";
import { nowUnixNano } from "../../lib/time";
import type {
AppNotification,
Brand,
BrandProduct,
InspirationIdea,
InspireElement,
Job,
MentionItem,
OwnPost,
OutboxBundle,
Persona,
ScoutPost,
ScoutTopic,
ThreadPlay,
ThreadsAccount,
AiSettings,
PlacementSettings,
TrendItem,
ViralSample,
} from "../../domain/types";
export function buildSeedAccounts(): ThreadsAccount[] {
const now = nowUnixNano();
const day = 86_400_000_000_000;
const hour = 3_600_000_000_000;
return [
{
id: "acc_lead_demo",
username: "harbor_main",
display_name: "主號 · 阿港",
connection: "connected",
is_usable: true,
avatar_color: "#5ec4a8",
avatar_url: mockAvatarUrl("harbor_main"),
session_expires_at: now + 14 * day,
session_refreshed_at: now - 2 * day,
},
{
id: "acc_cast_a",
username: "harbor_side_a",
display_name: "配角 · 小潮",
connection: "connected",
is_usable: true,
avatar_color: "#5b8fd4",
avatar_url: mockAvatarUrl("harbor_side_a"),
session_expires_at: now + 20 * hour,
session_refreshed_at: now - 10 * day,
},
{
id: "acc_cast_b",
username: "harbor_broken",
display_name: "故障號",
connection: "error",
is_usable: false,
avatar_color: "#f08a72",
avatar_url: mockAvatarUrl("harbor_broken"),
error_message: "token / session 已過期",
session_expires_at: now - 3 * day,
session_refreshed_at: now - 40 * day,
},
];
}
export function buildSeedPersonas(): Persona[] {
return [
{
id: "persona_friend",
name: "懂你的朋友",
brief: "像私訊朋友:先接情緒,再給生活向建議。",
status: "ready",
voice: "口語、共感、少術語",
style: buildSeedReadyStyle({
identity: "懂你的朋友",
tone: "口語、共感、少術語",
audience: "正在卡關、想聽真實經驗的人",
examples: "懂你…我之前也這樣,後來是先抓使用情境再選。",
avoid: "說教、硬廣、假裝專業唬人",
}),
guard: { ...emptyGuard(), avoid: ["說教", "硬廣", "AI 腔"] },
},
{
id: "persona_expert",
name: "務實專家",
brief: "用對比與情境說明;有依據、不裝神。",
status: "ready",
voice: "清楚、有依據、不裝神",
style: buildSeedReadyStyle({
identity: "務實專家",
tone: "清楚、有依據、不裝神",
audience: "想做決定、需要取捨理由的人",
examples: "若你在意 A選這向若在意 B另一向通常更穩。",
avoid: "空話、保證療效、恐嚇行銷",
}),
guard: { ...emptyGuard(), avoid: ["空話", "保證", "恐嚇"] },
},
{
id: "persona_playful",
name: "輕鬆吐槽",
brief: "幽默短句,可吐槽痛點,不嘲諷留言者。",
status: "ready",
voice: "幽默一點、短句",
style: buildSeedReadyStyle({
identity: "輕鬆吐槽型網友",
tone: "幽默、短句、一點自嘲",
audience: "想被逗一下又想聽幹貨的人",
examples: "不是我不想研究,是規格表比小說還長…",
avoid: "嘲諷對方、陰陽怪氣、人身攻擊",
}),
guard: { ...emptyGuard(), avoid: ["嘲諷", "陰陽", "人身攻擊"] },
},
{
id: "persona_blank",
name: "待分析人設",
brief: "",
status: "empty",
voice: "",
style: emptyStyle(),
guard: emptyGuard(),
},
];
}
export function buildSeedOwnPosts(): OwnPost[] {
const now = nowUnixNano();
return [
{
id: "own_1",
account_id: "acc_lead_demo",
media_id: "media_own_1",
text: "週末想找能坐久的咖啡店,插座要多、不要太吵。大家有推的嗎?",
like_count: 42,
reply_count: 3,
repost_count: 6,
quote_count: 2,
view_count: 1280,
share_count: 4,
media_type: "TEXT_POST",
topic_tag: "咖啡",
shortcode: "Cabc111",
formula_summary: "提問開場 + 明確條件(插座/安靜)→ 回覆率高",
insight: "條件明確的提問貼,留言願意給店名與細節。",
permalink: "https://www.threads.net/@harbor_main/post/Cabc111",
insights_status: "ok",
published_at: now - 86_400_000_000_000,
replies: [
{
id: "or_1",
username: "bean_lover",
text: "大安那間北港烘豆還行,週末早一點去。",
like_count: 3,
reply_status: "replied",
replied_by: "harbor_main",
replied_at: now - 75_000_000_000_000,
created_at: now - 80_000_000_000_000,
},
{
id: "or_1_child",
username: "harbor_main",
text: "謝謝!插座多嗎?我可能要帶筆電~",
like_count: 1,
reply_status: "replied",
parent_reply_id: "or_1",
is_mine: true,
created_at: now - 74_000_000_000_000,
},
{
id: "or_1_child2",
username: "bean_lover",
text: "多,靠窗那排都有。",
like_count: 0,
reply_status: "pending",
parent_reply_id: "or_1",
created_at: now - 73_000_000_000_000,
},
{
id: "or_2",
username: "quiet_work",
text: "插座多但人很多,要卡位。",
like_count: 1,
reply_status: "pending",
created_at: now - 70_000_000_000_000,
},
],
},
{
id: "own_2",
account_id: "acc_lead_demo",
media_id: "media_own_2",
text: "敏感肌洗劑怎麼挑?香味一重我就頭痛…求無香或低敏經驗。",
like_count: 88,
reply_count: 5,
repost_count: 18,
quote_count: 7,
view_count: 3200,
share_count: 11,
media_type: "TEXT_POST",
topic_tag: "敏感肌",
shortcode: "Cabc222",
formula_summary: "痛點明確 + 求經驗 → 適合外展與互回",
insight: "強貼:身體感受(頭痛)比抽象「推薦」更易引發經驗文。轉發/引用也偏高。",
permalink: "https://www.threads.net/@harbor_main/post/Cabc222",
insights_status: "ok",
published_at: now - 172_800_000_000_000,
replies: [
{
id: "or_3",
username: "skin_care_tw",
text: "我改無香之後真的好很多,成分表會看香精那欄。",
like_count: 8,
reply_status: "pending",
created_at: now - 160_000_000_000_000,
},
{
id: "or_3b",
username: "new_mom",
text: "寶寶衣也想無香,有推的嗎?",
like_count: 2,
reply_status: "pending",
created_at: now - 150_000_000_000_000,
},
],
},
{
id: "own_3",
account_id: "acc_cast_a",
media_id: "media_own_3",
text: "(配角帳)試過三款護唇膏,只有一款冬天不會起屑。",
like_count: 19,
reply_count: 1,
repost_count: 2,
quote_count: 0,
view_count: 540,
share_count: 1,
media_type: "IMAGE",
media_url: "https://api.dicebear.com/9.x/shapes/svg?seed=lipcare&backgroundColor=ffd5dc",
thumbnail_url: "https://api.dicebear.com/9.x/shapes/svg?seed=lipcare&backgroundColor=ffd5dc",
topic_tag: "護唇",
shortcode: "Cabc333",
insight: "對比句(三款只一款)製造好奇,適合接續開箱串。",
permalink: "https://www.threads.net/@harbor_side_a/post/Cabc333",
insights_status: "partial",
insights_message: "部分 insights 延遲",
published_at: now - 50_000_000_000_000,
replies: [
{
id: "or_4",
username: "dry_lip",
text: "是哪一款啊!求標。",
like_count: 0,
reply_status: "replied",
replied_by: "harbor_side_a",
replied_at: now - 35_000_000_000_000,
created_at: now - 40_000_000_000_000,
},
],
},
{
id: "own_4",
account_id: "acc_lead_demo",
media_id: "media_own_4",
text: "(轉發+短評)這篇講「第三空間」太準了,遠端真的需要一個能待一下午的地方。",
like_count: 31,
reply_count: 4,
repost_count: 0,
quote_count: 12,
view_count: 980,
share_count: 3,
media_type: "TEXT_POST",
is_quote_post: true,
topic_tag: "引用",
shortcode: "Cabc444",
insight: "引用貼:原帖流量 + 自己觀點quote 數常高於純轉發。",
permalink: "https://www.threads.net/@harbor_main/post/Cabc444",
insights_status: "ok",
published_at: now - 30_000_000_000_000,
replies: [],
},
];
}
export function buildSeedInspirations(): InspirationIdea[] {
return [
{
id: "insp_1",
title: "週末不踩雷咖啡",
hook: "用「插座 + 能坐久」兩個條件開問,比空泛「推咖啡」好回。",
angle: "主號提問 → 配角推店 → 主號追問細節",
source: "formula",
},
{
id: "insp_2",
title: "敏感肌選品經驗",
hook: "先講身體感受(頭痛/刺癢),再問無香經驗。",
angle: "強貼覆盤:痛點具體的貼文回覆率較高",
source: "formula",
trend_id: "trend_tag_1",
},
];
}
/** 靈感元素庫預設:角色指令 + 片段(人設/品牌在 UI 從現有資料 pin */
export function buildSeedInspireElements(): InspireElement[] {
const now = nowUnixNano();
return [
{
id: "el_role_pro",
kind: "role",
title: "專業 Threads 寫手",
body: "你是一位專業的 Threads 寫手:短句、有鉤子、可讀完、避免長篇論文腔;每則像真人隨手發。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_role_friend",
kind: "role",
title: "像朋友私訊",
body: "你用像私訊朋友的語氣寫:先接情緒、少術語、可以碎念,不要官方腔。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_role_real",
kind: "role",
title: "反業配真實感",
body: "寫出真實用過的感覺:可講猶豫、可講不完美;禁止硬銷與誇大承諾。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_role_hook",
kind: "role",
title: "短句鉤子優先",
body: "前兩行必須有鉤子或具體場景;寧可短也不要鋪陳。可在結尾留一個好回的問句。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_snip_no_hype",
kind: "snippet",
title: "禁止誇大療效",
body: "不要寫「保證改善」「絕對有效」「醫美級」等誇大字眼;只寫個人經驗與感受。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_snip_ask",
kind: "snippet",
title: "結尾可留問句",
body: "正文結尾可加一句自然問句,邀請留言分享經驗(不要問卷腔)。",
reusable: true,
created_at: now,
updated_at: now,
},
{
id: "el_snip_short",
kind: "snippet",
title: "控制在短貼感",
body: "目標長度像一則可滑完的短貼(約 80180 字感),不要寫成長文。",
reusable: true,
created_at: now,
updated_at: now,
},
];
}
/** 外面正在熱的題材Threads 標籤、網搜關鍵字、時事 */
export function buildSeedTrends(): TrendItem[] {
const now = nowUnixNano();
const hour = 3_600_000_000_000;
return [
{
id: "trend_tag_1",
kind: "threads_tag",
label: "#敏感肌",
summary: "Threads 上近期高互動標籤;討論集中在「溫和」到底算不算、求真實用過名單。",
heat: 92,
keywords: ["敏感肌", "無香", "溫和", "刺癢"],
samples: [
"每罐都寫溫和,到底什麼叫溫和?求真正用過的人講。",
"換季一到頭皮就炸,有人也是嗎?",
],
source_label: "Threads 熱門標籤",
observed_at: now - 2 * hour,
},
{
id: "trend_tag_2",
kind: "threads_tag",
label: "#第三空間",
summary: "遠端/咖啡廳座位話題回溫;插座、能坐久、安靜度是高回覆關鍵字。",
heat: 84,
keywords: ["第三空間", "咖啡廳", "遠端", "插座"],
samples: [
"家裡沒有第三空間真的會瘋,你們週末都去哪裝忙?",
"北車附近能坐下午的店求推(要有插座)。",
],
source_label: "Threads 熱門標籤",
observed_at: now - 5 * hour,
},
{
id: "trend_tag_3",
kind: "threads_tag",
label: "#護唇膏地雷",
summary: "選品吐槽串持續;「擦了更乾」類標題容易帶動留言。",
heat: 71,
keywords: ["護唇", "乾裂", "成分", "踩雷"],
samples: ["護唇擦越多越乾是什麼原理…有人懂嗎?"],
source_label: "Threads 熱門標籤",
observed_at: now - 8 * hour,
},
{
id: "trend_kw_1",
kind: "web_keyword",
label: "無香洗髮精 推薦",
summary: "搜尋量上升:使用者常交叉比「無香 vs 低敏」「頭皮屑」。適合經驗向開問。",
heat: 78,
keywords: ["無香洗髮精", "低敏", "頭皮", "推薦"],
samples: [
"搜尋結果多是業配清單;真實討論多在「用完頭皮會不會悶」。",
],
source_label: "網搜熱門關鍵字",
observed_at: now - 3 * hour,
},
{
id: "trend_kw_2",
kind: "web_keyword",
label: "換季保養 步驟",
summary: "教學向關鍵字;反差切入「其實不用整櫃換掉」互動通常較好。",
heat: 66,
keywords: ["換季", "保養步驟", "精簡"],
samples: ["多數清單文很長;短句吐槽行銷話術比較容易被轉貼。"],
source_label: "網搜熱門關鍵字",
observed_at: now - 10 * hour,
},
{
id: "trend_news_1",
kind: "news",
label: "午後雷陣雨 / 通勤窘境",
summary: "氣象話題帶動生活吐槽;適合輕量開場,再接到「包包裡常備什麼」。",
heat: 88,
keywords: ["下雨", "通勤", "包包", "常備"],
samples: [
"捷運站一出就變落湯雞,有人也把折傘當命嗎?",
],
source_label: "最近熱門時事",
observed_at: now - 1 * hour,
},
{
id: "trend_news_2",
kind: "news",
label: "連假/旅遊人潮",
summary: "假期前後「去哪避人」與「在家充電」兩派並存,易做二選一開問。",
heat: 74,
keywords: ["連假", "人潮", "在家", "短途"],
samples: ["連假不想人擠人,你們都怎麼過?"],
source_label: "最近熱門時事",
observed_at: now - 6 * hour,
},
{
id: "trend_news_3",
kind: "news",
label: "物價/小確幸預算",
summary: "「少花一點的小儀式」類貼文容易共鳴;可接到選品經驗而非硬廣。",
heat: 69,
keywords: ["物價", "小確幸", "預算", "儀式感"],
samples: ["咖啡從 80 變 100 之後,我改成一週只犒賞兩次。"],
source_label: "最近熱門時事",
observed_at: now - 12 * hour,
},
];
}
export function buildSeedMentions(): MentionItem[] {
const now = nowUnixNano();
return [
{
id: "men_1",
account_id: "acc_lead_demo",
from_username: "cafe_hunter",
text: "@harbor_main 你上次推的那間還開著嗎?想帶筆電去。",
context_snippet: "回覆你的咖啡貼",
status: "pending",
created_at: now - 5_000_000_000_000,
},
{
id: "men_2",
account_id: "acc_lead_demo",
from_username: "sensitive_skin_day",
text: "@harbor_main 無香那篇太有感,可以再講講成分表怎麼看嗎?",
context_snippet: "提及你的敏感肌貼",
status: "pending",
created_at: now - 12_000_000_000_000,
},
{
id: "men_3",
account_id: "acc_cast_a",
from_username: "lip_care",
text: "@harbor_side_a 護唇那款求連結!",
context_snippet: "提及配角帳貼文",
status: "replied",
draft_text: "私訊你了~冬天那款我用起來最穩。",
created_at: now - 20_000_000_000_000,
},
];
}
export function buildSeedViralSamples(): ViralSample[] {
return [
{
id: "viral_1",
author: "trend_mom",
text: "不是我不想買好的,是每罐都寫「溫和」…到底什麼叫溫和?求真正用過的人講。",
like_count: 420,
reply_count: 96,
topic: "選品痛點",
},
{
id: "viral_2",
author: "office_escape",
text: "遠端第三年,我終於承認:家裡沒有「第三空間」真的會瘋。你們週末都去哪裝忙?",
like_count: 880,
reply_count: 210,
topic: "生活情境",
},
];
}
export function buildSeedBrands(): Brand[] {
return [
{
id: "brand_demo",
display_name: "港岸日常",
brief: "敏感受眾向的生活選品;語氣務實、不硬廣。",
target_audience: "對香精敏感、重視成分的日常消費者",
goals: "軟性經驗分享,必要時輕帶可查的選品連結",
},
{
id: "brand_coffee",
display_name: "北港烘豆",
brief: "咖啡與第三空間;強調座位與工作友善。",
target_audience: "遠端/需要插座久坐的人",
goals: "推第三空間店型,不硬塞豆子",
},
];
}
export function buildSeedProducts(): BrandProduct[] {
const now = nowUnixNano();
return [
{
id: "prod_unscented",
brand_id: "brand_demo",
label: "無香洗衣精",
product_context:
"真無香、洗淨力不靠香精掩蓋;適合敏感肌與換季頭皮/鼻腔不適的人。介紹時先講使用感受再提規格。",
match_tags: ["無香", "敏感肌", "洗衣精", "香精", "刺鼻"],
pain_points: ["香精一沾就刺鼻纏整天", "換季頭皮/鼻腔不適", "找不到真的無香洗劑"],
placement_url: "https://example.com/products/unscented-detergent",
created_at: now,
updated_at: now,
},
{
id: "prod_baby",
brand_id: "brand_demo",
label: "溫和嬰幼兒洗劑",
product_context: "低刺激配方、洗淨與溫和並重;適合寶媽怕洗不乾淨又怕刺膚。",
match_tags: ["寶寶", "溫和", "嬰幼兒", "敏感"],
pain_points: ["寶寶衣物怕洗不乾淨", "怕洗劑太刺激"],
placement_url: "https://example.com/products/baby-wash",
created_at: now,
updated_at: now,
},
{
id: "prod_cafe_seat",
brand_id: "brand_coffee",
label: "久坐工作座位",
product_context: "店內強調插座密度、不趕客時段;分享時講實際座位經驗比推豆子重要。",
match_tags: ["插座", "咖啡廳", "筆電", "不限時", "安靜"],
pain_points: ["平日下午沒地方待", "咖啡廳沒插座", "人多坐不久"],
placement_url: "https://example.com/stores/taipei-work-cafe",
created_at: now,
updated_at: now,
},
];
}
export function buildSeedScoutTopics(): ScoutTopic[] {
return [
{
id: "topic_sensitive",
brand_id: "brand_demo",
name: "敏感肌洗劑",
keywords: ["無香", "敏感肌", "洗衣精", "抗敏"],
preferred_product_id: "prod_unscented",
},
{
id: "topic_cafe",
brand_id: "brand_coffee",
name: "可久坐咖啡",
keywords: ["插座", "咖啡廳", "筆電", "安靜"],
preferred_product_id: "prod_cafe_seat",
},
];
}
export function buildSeedScoutPosts(): ScoutPost[] {
return [
{
id: "scan_1",
brand_id: "brand_demo",
topic_id: "topic_sensitive",
author: "itchy_days",
text: "有沒有真的無香的洗衣精?香精一沾就被刺鼻味纏整天。",
search_tag: "無香",
opportunity: "對上「無香洗衣精」· 痛點/語境:香精一沾就刺鼻纏整天",
outreach_status: "new",
score: 94,
matched_product_id: "prod_unscented",
matched_product_label: "無香洗衣精",
scout_mode: "product",
theme_key: "product|prod_unscented|seed-unscented",
theme_label: "無香洗衣精",
intent_snippet: "找無香洗衣精痛點",
},
{
id: "scan_2",
brand_id: "brand_demo",
topic_id: "topic_sensitive",
author: "new_mom_tw",
text: "寶寶衣物想用溫和一點的,但怕洗不乾淨…",
search_tag: "敏感肌",
opportunity: "對上「溫和嬰幼兒洗劑」· 痛點/語境:寶寶衣物怕洗不乾淨",
outreach_status: "new",
score: 78,
matched_product_id: "prod_baby",
matched_product_label: "溫和嬰幼兒洗劑",
scout_mode: "product",
theme_key: "product|prod_baby|seed-baby",
theme_label: "溫和嬰幼兒洗劑",
intent_snippet: "寶寶衣物溫和洗",
},
{
id: "scan_3",
brand_id: "brand_coffee",
topic_id: "topic_cafe",
author: "remote_worker",
text: "台北哪裡有平日能待下午的咖啡?要插座。",
search_tag: "插座",
opportunity: "對上「久坐工作座位」· 痛點/語境:平日下午沒地方待",
outreach_status: "new",
score: 88,
matched_product_id: "prod_cafe_seat",
matched_product_label: "久坐工作座位",
scout_mode: "product",
theme_key: "product|prod_cafe_seat|seed-cafe",
theme_label: "久坐工作座位",
intent_snippet: "平日插座咖啡廳",
},
];
}
export function buildDefaultAiSettings(): AiSettings {
return {
provider: "opencode-go",
model: "deepseek-v4-pro",
research_provider: "opencode-go",
research_model: "deepseek-v4-pro",
api_key_configured: false,
research_api_key_configured: false,
};
}
export function buildDefaultPlacementSettings(): PlacementSettings {
return {
web_search_provider: "brave",
expand_strategy: "hybrid",
brave_api_key_configured: false,
exa_api_key_configured: false,
dev_mode_enabled: false,
};
}
/** 掛在已發貼文下的互回方案seed */
export function buildSeedPlays(): ThreadPlay[] {
const now = nowUnixNano();
return [
{
id: "play_scheme_a",
title: "方案 A · 配角先推店",
topic: "掛在:週末咖啡貼下",
status: "draft",
lead_account_id: "acc_lead_demo",
cast_account_ids: ["acc_cast_a"],
target_own_post_id: "own_1",
interval_minutes: 5,
steps: [
{
id: "s1",
sort_order: 0,
kind: "reply",
account_id: "acc_cast_a",
text: "大安那間「北港烘豆」還行,週末要早一點去。",
delay_from_previous_sec: 300,
},
{
id: "s2",
sort_order: 1,
kind: "reply",
account_id: "acc_lead_demo",
text: "好耶,插座多嗎?我可能要帶筆電。",
delay_from_previous_sec: 300,
},
],
schedule_start_at: now + 3_600_000_000_000,
created_at: now,
updated_at: now,
},
{
id: "play_scheme_b",
title: "方案 B · 主帳先接再配角補",
topic: "掛在:週末咖啡貼下",
status: "draft",
lead_account_id: "acc_lead_demo",
cast_account_ids: ["acc_cast_a"],
target_own_post_id: "own_1",
interval_minutes: 8,
steps: [
{
id: "sb1",
sort_order: 0,
kind: "reply",
account_id: "acc_lead_demo",
text: "我自己目前比較在意安靜+有插座,有人也是嗎?",
delay_from_previous_sec: 180,
},
{
id: "sb2",
sort_order: 1,
kind: "reply",
account_id: "acc_cast_a",
text: "那可以看大安巷子那間,平日人少一點。",
delay_from_previous_sec: 480,
},
],
schedule_start_at: now + 7_200_000_000_000,
created_at: now,
updated_at: now,
},
{
id: "play_scheme_skin",
title: "方案 · 共感再補經驗",
topic: "掛在:敏感肌貼下",
status: "draft",
lead_account_id: "acc_lead_demo",
cast_account_ids: ["acc_cast_a"],
target_own_post_id: "own_2",
interval_minutes: 5,
steps: [
{
id: "sc1",
sort_order: 0,
kind: "reply",
account_id: "acc_cast_a",
text: "懂頭痛那感…我後來都先看成分表香精欄。",
delay_from_previous_sec: 300,
},
],
schedule_start_at: now + 3_600_000_000_000,
created_at: now,
updated_at: now,
},
];
}
/** @deprecated 用 buildSeedPlays */
export function buildSeedPlay(): ThreadPlay {
return buildSeedPlays()[0];
}
export function buildSeedJob(): Job {
const now = nowUnixNano();
return {
id: "job_demo_running",
template_type: "demo_long_task",
status: "running",
progress_summary: "任務執行中…",
progress_percent: 35,
created_at: now - 60_000_000_000,
updated_at: now,
};
}
export function buildSeedNotification(): AppNotification {
return {
id: newId("ntf"),
title: "歡迎使用 巡樓 · Lapras",
body: "創作可試 AI 視角、已發文回覆與海巡。",
kind: "system",
ref_type: "none",
read_at: null,
created_at: nowUnixNano(),
};
}
export function buildEmptyOutbox(): OutboxBundle[] {
return [];
}

View File

@ -0,0 +1,308 @@
import { readJson, writeJson } from "../../lib/storage";
import type {
AiSettings,
AppNotification,
Brand,
BrandProduct,
DataSource,
InspirationIdea,
InspireElement,
InspireSession,
Job,
MentionItem,
OwnPost,
OutboxBundle,
Persona,
PlacementSettings,
ScoutHomeworkRecord,
ScoutPost,
ScoutTopic,
ThreadPlay,
ThreadsAccount,
TrendItem,
ViralSample,
} from "../../domain/types";
import { emptyInspireSession } from "../../lib/mockInspireChat";
import { KEYS } from "./keys";
import { normalizePersona } from "../../lib/personaPrompt";
import { nowUnixNano } from "../../lib/time";
import {
buildDefaultAiSettings,
buildDefaultPlacementSettings,
buildEmptyOutbox,
buildSeedAccounts,
buildSeedBrands,
buildSeedInspirations,
buildSeedInspireElements,
buildSeedJob,
buildSeedMentions,
buildSeedNotification,
buildSeedOwnPosts,
buildSeedPersonas,
buildSeedPlays,
buildSeedProducts,
buildSeedScoutPosts,
buildSeedScoutTopics,
buildSeedTrends,
buildSeedViralSamples,
} from "./seed";
/** 舊 own post 補齊 Threads 成效欄位 */
function normalizeOwnPost(raw: OwnPost): OwnPost {
return {
...raw,
media_type: raw.media_type === "TEXT" ? "TEXT_POST" : raw.media_type || "TEXT_POST",
like_count: raw.like_count ?? 0,
reply_count: raw.reply_count ?? raw.replies?.length ?? 0,
repost_count: raw.repost_count ?? 0,
quote_count: raw.quote_count ?? 0,
view_count: raw.view_count ?? 0,
share_count: raw.share_count ?? 0,
replies: (raw.replies || []).map((r) => ({
...r,
reply_status: r.reply_status || "pending",
parent_reply_id: r.parent_reply_id ?? null,
})),
insights_status: raw.insights_status || "ok",
};
}
/** 舊 localStorage 帳號補 session頭像欄位 */
function normalizeAccount(raw: ThreadsAccount): ThreadsAccount {
let next = { ...raw };
if (!next.avatar_url && next.username) {
// lazy: avoid circular import path issues — inline same pattern as mockAvatarUrl
const seed = encodeURIComponent(next.username.replace(/^@/, "") || "harbor");
next.avatar_url = `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf`;
}
if (next.session_expires_at != null || next.session_refreshed_at != null) return next;
const now = nowUnixNano();
const day = 86_400_000_000_000;
if (next.connection === "error" || !next.is_usable) {
return {
...next,
session_expires_at: now - day,
session_refreshed_at: now - 30 * day,
error_message: next.error_message || "session 可能已過期",
};
}
return {
...next,
session_expires_at: now + 7 * day,
session_refreshed_at: now - day,
};
}
export type HarborStore = {
accounts: ThreadsAccount[];
plays: ThreadPlay[];
outbox: OutboxBundle[];
jobs: Job[];
notifications: AppNotification[];
personas: Persona[];
ownPosts: OwnPost[];
inspirations: InspirationIdea[];
trends: TrendItem[];
mentions: MentionItem[];
viralSamples: ViralSample[];
brands: Brand[];
brandProducts: BrandProduct[];
scoutTopics: ScoutTopic[];
scoutPosts: ScoutPost[];
};
function loadRaw(): HarborStore {
return {
accounts: readJson(KEYS.accounts, [] as ThreadsAccount[]).map(normalizeAccount),
plays: readJson(KEYS.plays, [] as ThreadPlay[]),
outbox: readJson(KEYS.outbox, [] as OutboxBundle[]),
jobs: readJson(KEYS.jobs, [] as Job[]),
notifications: readJson(KEYS.notifications, [] as AppNotification[]),
personas: readJson(KEYS.personas, [] as Persona[]).map((p) => normalizePersona(p)),
ownPosts: readJson(KEYS.ownPosts, [] as OwnPost[]).map(normalizeOwnPost),
inspirations: readJson(KEYS.inspirations, [] as InspirationIdea[]),
trends: readJson(KEYS.trends, [] as TrendItem[]),
mentions: readJson(KEYS.mentions, [] as MentionItem[]),
viralSamples: readJson(KEYS.viralSamples, [] as ViralSample[]),
brands: readJson(KEYS.brands, [] as Brand[]),
brandProducts: readJson(KEYS.brandProducts, [] as BrandProduct[]),
scoutTopics: readJson(KEYS.scoutTopics, [] as ScoutTopic[]),
scoutPosts: readJson(KEYS.scoutPosts, [] as ScoutPost[]),
};
}
export function ensureSeeded(): HarborStore {
const flagged = readJson(KEYS.seeded, false as boolean);
if (flagged) {
const raw = loadRaw();
// migrate soft: fill missing collections if user had older partial store
if (!raw.personas.length) raw.personas = buildSeedPersonas();
if (!raw.ownPosts.length) raw.ownPosts = buildSeedOwnPosts();
if (!raw.inspirations.length) raw.inspirations = buildSeedInspirations();
if (!raw.trends.length) raw.trends = buildSeedTrends();
if (!raw.mentions.length) raw.mentions = buildSeedMentions();
if (!raw.viralSamples.length) raw.viralSamples = buildSeedViralSamples();
if (!raw.brands.length) raw.brands = buildSeedBrands();
if (!raw.brandProducts.length) raw.brandProducts = buildSeedProducts();
if (!raw.scoutTopics.length) raw.scoutTopics = buildSeedScoutTopics();
if (!raw.scoutPosts.length) raw.scoutPosts = buildSeedScoutPosts();
if (!getInspireElements().length) setInspireElements(buildSeedInspireElements());
persist(raw);
return raw;
}
const store: HarborStore = {
accounts: buildSeedAccounts(),
plays: buildSeedPlays(),
outbox: buildEmptyOutbox(),
jobs: [buildSeedJob()],
notifications: [buildSeedNotification()],
personas: buildSeedPersonas(),
ownPosts: buildSeedOwnPosts(),
inspirations: buildSeedInspirations(),
trends: buildSeedTrends(),
mentions: buildSeedMentions(),
viralSamples: buildSeedViralSamples(),
brands: buildSeedBrands(),
brandProducts: buildSeedProducts(),
scoutTopics: buildSeedScoutTopics(),
scoutPosts: buildSeedScoutPosts(),
};
persist(store);
writeJson(KEYS.seeded, true);
writeJson(KEYS.aiSettings, buildDefaultAiSettings());
writeJson(KEYS.placementSettings, buildDefaultPlacementSettings());
writeJson(KEYS.activePersonaId, "persona_friend");
writeJson(KEYS.activeBrandId, "brand_demo");
writeJson(KEYS.ownPostsSyncedAt, nowUnixNano());
setInspireElements(buildSeedInspireElements());
setInspireSession(emptyInspireSession());
return store;
}
export function persist(store: HarborStore): void {
writeJson(KEYS.accounts, store.accounts);
writeJson(KEYS.plays, store.plays);
writeJson(KEYS.outbox, store.outbox);
writeJson(KEYS.jobs, store.jobs);
writeJson(KEYS.notifications, store.notifications);
writeJson(KEYS.personas, store.personas);
writeJson(KEYS.ownPosts, store.ownPosts);
writeJson(KEYS.inspirations, store.inspirations);
writeJson(KEYS.trends, store.trends);
writeJson(KEYS.mentions, store.mentions);
writeJson(KEYS.viralSamples, store.viralSamples);
writeJson(KEYS.brands, store.brands);
writeJson(KEYS.brandProducts, store.brandProducts);
writeJson(KEYS.scoutTopics, store.scoutTopics);
writeJson(KEYS.scoutPosts, store.scoutPosts);
}
export function getStore(): HarborStore {
return ensureSeeded();
}
export function updateStore(mutator: (s: HarborStore) => void): HarborStore {
const s = getStore();
mutator(s);
persist(s);
return s;
}
export function getDataSource(): DataSource {
const v = readJson<DataSource>(KEYS.datasource, "mock");
return v === "live" ? "live" : "mock";
}
export function setDataSource(ds: DataSource): void {
writeJson(KEYS.datasource, ds);
}
export function getAiSettings(): AiSettings {
return readJson(KEYS.aiSettings, buildDefaultAiSettings());
}
export function setAiSettings(settings: AiSettings): void {
writeJson(KEYS.aiSettings, settings);
}
export function getPlacementSettings(): PlacementSettings {
return readJson(KEYS.placementSettings, buildDefaultPlacementSettings());
}
export function setPlacementSettings(settings: PlacementSettings): void {
writeJson(KEYS.placementSettings, settings);
}
export function getActivePersonaId(): string {
return readJson(KEYS.activePersonaId, "persona_friend");
}
export function setActivePersonaId(id: string): void {
writeJson(KEYS.activePersonaId, id);
}
export function getActiveBrandId(): string {
return readJson(KEYS.activeBrandId, "brand_demo");
}
export function setActiveBrandId(id: string): void {
writeJson(KEYS.activeBrandId, id);
}
export function getInspireElements(): InspireElement[] {
return readJson(KEYS.inspireElements, [] as InspireElement[]);
}
export function setInspireElements(list: InspireElement[]): void {
writeJson(KEYS.inspireElements, list);
}
export function getInspireSession(): InspireSession {
const raw = readJson<InspireSession | null>(KEYS.inspireSession, null);
if (raw?.id && Array.isArray(raw.messages)) return raw;
return emptyInspireSession();
}
export function setInspireSession(session: InspireSession): void {
writeJson(KEYS.inspireSession, session);
}
export function getScoutHomework(): ScoutHomeworkRecord[] {
return readJson(KEYS.scoutHomework, [] as ScoutHomeworkRecord[]);
}
export function setScoutHomework(list: ScoutHomeworkRecord[]): void {
writeJson(KEYS.scoutHomework, list);
}
export type SessionState = {
member: {
tenant_id: string;
uid: string;
email: string;
display_name: string;
roles: ("member" | "admin")[];
bio?: string;
timezone?: string;
notify_email?: boolean;
avatar_url?: string | null;
email_verified?: boolean;
email_verified_at?: number | null;
status?: "active" | "suspended";
};
/** mock 登入密碼(改密後寫入;未設則仍用 demo */
password?: string;
};
export function getSession(): SessionState | null {
return readJson<SessionState | null>(KEYS.session, null);
}
export function setSession(session: SessionState | null): void {
if (!session) {
writeJson(KEYS.session, null);
return;
}
writeJson(KEYS.session, session);
}

418
apps/web/src/data/repos.ts Normal file
View File

@ -0,0 +1,418 @@
import type {
AiSettings,
AppNotification,
Brand,
BrandProduct,
DataSource,
ExternalThreadTarget,
InspirationIdea,
InspireAngle,
InspireChatMessage,
InspireElement,
InspireSession,
Job,
Member,
MentionItem,
OwnPost,
OutboxBundle,
Persona,
PlacementSettings,
ResearchHit,
Role,
ScoutHomeworkRecord,
ScoutPost,
ScoutRunBrief,
ScoutScanContext,
ScoutTopic,
ThreadPlay,
ThreadsAccount,
TokenPair,
TrendItem,
TrendKind,
ViralAnalysis,
ViralSample,
} from "../domain/types";
import type { AdminUserListPage, MemberAdminView } from "../lib/tenantUsers";
import type { PlanPurchase } from "../lib/planPurchase";
import type {
PlanId,
TenantAnalyticsQuery,
TenantUsageAnalytics,
TenantUsageSummary,
UsageEvent,
UsageMemberPrefs,
UsageMonthSummary,
} from "../lib/usageMeter";
export type MemberProfilePatch = {
display_name?: string;
email?: string;
bio?: string;
timezone?: string;
notify_email?: boolean;
/**
* URLdata URL https
* `null` `undefined`
*/
avatar_url?: string | null;
/** 改密時必填 */
current_password?: string;
new_password?: string;
};
export type AuthRepo = {
login(email: string, password: string): Promise<{ tokens: TokenPair; member: Member }>;
logout(): Promise<void>;
me(): Promise<Member | null>;
/** 更新自己的會員資料mock 寫 session */
updateProfile(patch: MemberProfilePatch): Promise<Member>;
/**
* mock
* mock_reset_path 便
*/
requestPasswordReset(email: string): Promise<{
ok: true;
message: string;
/** 僅 mock可點的重設路徑例如 /reset-password?token=… */
mock_reset_path?: string;
}>;
/** 用 token 設定新密碼mock */
resetPassword(token: string, newPassword: string): Promise<{ ok: true; message: string }>;
/** 寄送信箱驗證碼(需已登入) */
sendEmailVerificationCode(): Promise<{
ok: true;
message: string;
/** 僅 mock畫面上顯示驗證碼方便測試 */
mock_code?: string;
}>;
/** 輸入驗證碼完成信箱驗證 */
verifyEmail(code: string): Promise<Member>;
};
/** 管理者新增島民 */
export type AdminCreateMemberInput = {
display_name: string;
email: string;
password?: string;
roles?: Role[];
email_verified?: boolean;
bio?: string;
};
/** 管理員:島民列表與可寫操作 */
export type AdminUsersRepo = {
/** @deprecated 請用 listUsersPage */
listUsers(): Promise<MemberAdminView[]>;
/** 分頁列表 page 從 1 起query 可搜名稱 / Email / uid */
listUsersPage(opts?: {
page?: number;
pageSize?: number;
query?: string;
}): Promise<AdminUserListPage>;
getUser(uid: string): Promise<MemberAdminView | null>;
/** 新增島民(回傳臨時密碼) */
createMember(
input: AdminCreateMemberInput,
): Promise<{ user: MemberAdminView; temporary_password: string }>;
/** 停權 / 復權 */
setSuspended(uid: string, suspended: boolean): Promise<MemberAdminView>;
/** 改驗證狀態 */
setEmailVerified(uid: string, verified: boolean): Promise<MemberAdminView>;
/** 指派角色member / admin */
setRoles(uid: string, roles: Role[]): Promise<MemberAdminView>;
/**
* 使 newPassword
* temporary_password
*/
resetPassword(
uid: string,
newPassword?: string,
): Promise<{ user: MemberAdminView; temporary_password: string }>;
};
export type AccountsRepo = {
list(): Promise<ThreadsAccount[]>;
createMock(): Promise<ThreadsAccount>;
remove(id: string): Promise<void>;
refreshSession(id: string): Promise<ThreadsAccount>;
};
export type PlaysRepo = {
list(): Promise<ThreadPlay[]>;
listByPost(ownPostId: string): Promise<ThreadPlay[]>;
/** 依外部 Threads 連結列出方案URL 正規化後比對) */
listByExternalUrl(url: string): Promise<ThreadPlay[]>;
/** 貼連結 → mock 解析目標貼文 */
resolveExternalLink(url: string): Promise<ExternalThreadTarget>;
get(id: string): Promise<ThreadPlay | null>;
save(play: ThreadPlay): Promise<ThreadPlay>;
remove(id: string): Promise<void>;
submit(id: string): Promise<OutboxBundle>;
};
export type OutboxRepo = {
list(): Promise<OutboxBundle[]>;
get(id: string): Promise<OutboxBundle | null>;
simulateSuccess(id: string): Promise<OutboxBundle>;
simulateRootFail(id: string): Promise<OutboxBundle>;
retryStep(bundleId: string, stepId: string): Promise<OutboxBundle>;
/** 刪除整筆發送佇列項目 */
remove(id: string): Promise<void>;
};
export type JobsRepo = {
list(): Promise<Job[]>;
get(id: string): Promise<Job | null>;
startDemo(): Promise<Job>;
};
export type NotificationsRepo = {
list(): Promise<AppNotification[]>;
unreadCount(): Promise<number>;
markRead(id: string): Promise<void>;
markAllRead(): Promise<void>;
};
export type SettingsRepo = {
getDataSource(): DataSource;
setDataSource(ds: DataSource): void;
getAi(): Promise<AiSettings>;
saveAi(patch: Partial<AiSettings> & { api_key?: string; research_api_key?: string }): Promise<AiSettings>;
getPlacement(): Promise<PlacementSettings>;
savePlacement(
patch: Partial<PlacementSettings> & { brave_api_key?: string; exa_api_key?: string },
): Promise<PlacementSettings>;
listModels(provider: string): Promise<string[]>;
};
export type PersonasRepo = {
list(): Promise<Persona[]>;
get(id: string): Promise<Persona | null>;
getActiveId(): Promise<string>;
setActiveId(id: string): Promise<void>;
save(persona: Persona): Promise<Persona>;
remove(id: string): Promise<void>;
analyzeFromText(id: string, rawText: string, sourceLabel?: string): Promise<Persona>;
analyzeFromAccount(id: string, username: string): Promise<Persona>;
};
export type OwnPostsRepo = {
list(accountId?: string): Promise<OwnPost[]>;
lastSyncedAt(): Promise<number | null>;
sync(accountId: string): Promise<OwnPost[]>;
generateReply(opts: {
postId: string;
replyId?: string;
personaId?: string;
}): Promise<string>;
sendReply(opts: {
postId: string;
replyId?: string;
text: string;
/** 用哪個帳號送出mock 寫入 username */
accountId?: string;
/** 附圖 URLmock 記張數/可選縮圖) */
imageUrls?: string[];
}): Promise<OwnPost>;
analyzePost(postId: string): Promise<OwnPost>;
generateFromFormula(postId: string, personaId?: string): Promise<{ title: string; topic: string; root: string }>;
};
export type MentionsRepo = {
list(accountId?: string): Promise<MentionItem[]>;
generateReply(id: string, personaId?: string): Promise<MentionItem>;
markReplied(id: string, text?: string, imageUrls?: string[]): Promise<MentionItem>;
skip(id: string): Promise<MentionItem>;
};
export type InspirationRepo = {
list(): Promise<InspirationIdea[]>;
/** 預設靈感榜Threads 熱標(提示用) */
listTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
refreshTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
searchTrends(query: string): Promise<TrendItem[]>;
/** 元素庫 */
listElements(): Promise<InspireElement[]>;
saveElement(el: InspireElement): Promise<InspireElement>;
removeElement(id: string): Promise<void>;
/** 當前聊天 session單一會話 */
getSession(): Promise<InspireSession>;
saveSession(session: InspireSession): Promise<InspireSession>;
clearSession(): Promise<InspireSession>;
/**
* pinnedIds session
* mode=generate assistant draft.body
*/
chat(opts: {
message: string;
pinnedIds: string[];
mode: "chat" | "generate";
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
/** @deprecated 舊角度流;保留相容 */
sparkAngles(
trendId: string,
opts?: { personaId?: string | null; brandId?: string | null },
): Promise<InspireAngle[]>;
/** @deprecated */
sparkAnglesForTopic(
label: string,
opts?: { personaId?: string | null; brandId?: string | null; samples?: string[] },
): Promise<{ trend: TrendItem; angles: InspireAngle[] }>;
bookmarkTrend(trendId: string): Promise<InspirationIdea>;
saveIdea(idea: InspirationIdea): Promise<InspirationIdea>;
generate(topic: string, personaId?: string): Promise<InspirationIdea>;
listViral(): Promise<ViralSample[]>;
/** @deprecated */
sparkFromTrend(
trendId: string,
personaId?: string,
opts?: { save?: boolean },
): Promise<InspirationIdea>;
};
export type ResearchRepo = {
search(query: string): Promise<ResearchHit[]>;
};
export type MediaRepo = {
generateImage(prompt: string): Promise<{ id: string; url: string; prompt: string }>;
/** 本機選檔 → 可預覽附圖Phase B mock不真上傳 */
attachLocal(files: FileList | File[]): Promise<{ id: string; url: string; name?: string }[]>;
};
export type ComposeRepo = {
mimic(sourceText: string, personaId?: string): Promise<string>;
analyzeViral(text: string): Promise<ViralAnalysis>;
/** 單篇送出 Outbox */
publishSingle(opts: {
accountId: string;
text: string;
title?: string;
imageUrls?: string[];
}): Promise<OutboxBundle>;
};
export type ScoutRepo = {
listBrands(): Promise<Brand[]>;
get(id: string): Promise<Brand | null>;
getActiveBrandId(): Promise<string>;
setActiveBrandId(id: string): Promise<void>;
/** 對齊舊 BrandsPage建立牌子 */
createBrand(input?: { display_name?: string; brief?: string }): Promise<Brand>;
saveBrand(brand: Brand): Promise<Brand>;
removeBrand(id: string): Promise<void>;
listProducts(brandId: string): Promise<BrandProduct[]>;
/** 全部產品(探查下拉用) */
listAllProducts(): Promise<BrandProduct[]>;
getProduct(id: string): Promise<BrandProduct | null>;
saveProduct(product: BrandProduct): Promise<BrandProduct>;
removeProduct(id: string): Promise<void>;
/**
* 稿
* Phase B mocklive
*/
importProductFromUrl(url: string): Promise<{
label: string;
product_context: string;
pain_points: string[];
match_tags: string[];
placement_url: string;
source_note: string;
}>;
listTopics(brandId?: string): Promise<ScoutTopic[]>;
saveTopic(topic: ScoutTopic): Promise<ScoutTopic>;
removeTopic(id: string): Promise<void>;
/**
* + brief
* = theme
*/
prepareBrief(opts: {
intent: string;
brandId?: string | null;
productId?: string | null;
/** value痛點置入預設activity關鍵字活躍 */
purpose?: "value" | "activity";
/**
*
* false deep
*/
deep?: boolean;
}): Promise<ScoutRunBrief>;
/** 依 brief含使用者勾選後的 scan_terms海巡 */
runScanFromBrief(brief: ScoutRunBrief): Promise<ScoutPost[]>;
/** @deprecated 用 prepareBrief */
getScanContext(brandId: string, productId?: string | null): Promise<ScoutScanContext>;
/** 列出命中brandId 空 = 全部(含主題模式) */
listPosts(brandId?: string | null): Promise<ScoutPost[]>;
/** @deprecated 用 runScanFromBrief */
runScan(
brandId: string,
productId?: string | null,
extraTerms?: string[],
): Promise<ScoutPost[]>;
draftOutreach(postId: string, personaId?: string): Promise<ScoutPost>;
skipOutreach(postId: string): Promise<ScoutPost>;
markPublished(postId: string): Promise<ScoutPost>;
/**
* 稿 published
*/
sendOutreach(opts: {
postId: string;
text: string;
accountId?: string;
personaId?: string;
}): Promise<ScoutPost>;
/** 刪除單則命中 */
removePost(postId: string): Promise<void>;
/** 刪除同一主題分組下全部命中 */
removeTheme(themeKey: string): Promise<void>;
/** 已完成的功課列表(持久化) */
listHomework(): Promise<ScoutHomeworkRecord[]>;
/** 存一輪功課(同 theme_key 覆蓋) */
saveHomework(record: ScoutHomeworkRecord): Promise<ScoutHomeworkRecord>;
getHomework(themeKey: string): Promise<ScoutHomeworkRecord | null>;
removeHomework(themeKey: string): Promise<void>;
};
export type UsageRepo = {
/** 目前登入者(或指定 uid本月摘要 + 明細 */
getSummary(monthKey?: string, uid?: string): Promise<UsageMonthSummary>;
listEvents(limit?: number, uid?: string): Promise<UsageEvent[]>;
getPlanId(): Promise<PlanId>;
setPlanId(id: PlanId): Promise<void>;
getMemberPrefs(uid?: string): Promise<UsageMemberPrefs>;
/** 管理員:設某人方案/無限 */
setMemberPrefs(uid: string, patch: Partial<UsageMemberPrefs>): Promise<UsageMemberPrefs>;
/** 管理員:全體本月用量 */
getTenantSummary(monthKey?: string): Promise<TenantUsageSummary>;
/** 管理員:全體區間分析(日/月/年 + 購買 vs 消耗) */
getTenantAnalytics(query?: TenantAnalyticsQuery): Promise<TenantUsageAnalytics>;
/**
* mock plan
* setMemberPrefs
*/
purchasePlan(plan_id: PlanId, opts?: { mock_ref?: string }): Promise<PlanPurchase>;
/** 自己的購買紀錄 */
listMyPurchases(limit?: number): Promise<PlanPurchase[]>;
};
export type Repos = {
dataSource: DataSource;
auth: AuthRepo;
accounts: AccountsRepo;
plays: PlaysRepo;
outbox: OutboxRepo;
jobs: JobsRepo;
notifications: NotificationsRepo;
settings: SettingsRepo;
personas: PersonasRepo;
ownPosts: OwnPostsRepo;
mentions: MentionsRepo;
inspiration: InspirationRepo;
research: ResearchRepo;
media: MediaRepo;
compose: ComposeRepo;
scout: ScoutRepo;
usage: UsageRepo;
adminUsers: AdminUsersRepo;
};

View File

@ -0,0 +1,66 @@
import type { ThreadPlay } from "./types";
export function speakersOf(play: Pick<ThreadPlay, "lead_account_id" | "cast_account_ids">): string[] {
const set = new Set<string>([play.lead_account_id, ...play.cast_account_ids]);
return [...set].filter(Boolean);
}
export function isUnderPostScheme(
play: Pick<ThreadPlay, "target_own_post_id" | "target_external">,
): boolean {
return Boolean(play.target_own_post_id) || Boolean(play.target_external?.url);
}
/**
*
* i18n keyplay.err.* null
*/
export function validatePlaySteps(
play: Pick<
ThreadPlay,
"lead_account_id" | "cast_account_ids" | "steps" | "target_own_post_id" | "target_external"
>,
): string | null {
if (isUnderPostScheme(play)) {
return validateUnderPostScheme(play);
}
if (!play.lead_account_id) return "play.err.needLead";
if (!play.steps.length) return "play.err.needRoot";
const root = play.steps.find((s) => s.kind === "root") || play.steps[0];
if (!root || root.kind !== "root") return "play.err.firstMustRoot";
if (root.account_id !== play.lead_account_id) return "play.err.rootMustLead";
if (!root.text.trim()) return "play.err.rootEmpty";
const speakers = new Set(speakersOf(play));
for (const step of play.steps) {
if (step.kind === "reply") {
if (!speakers.has(step.account_id)) return "play.err.replyAccount";
if (!step.text.trim()) return "play.err.replyEmpty";
}
}
return null;
}
/**
* i18n key null
*/
export function validateUnderPostScheme(
play: Pick<
ThreadPlay,
"lead_account_id" | "cast_account_ids" | "steps" | "target_own_post_id" | "target_external"
>,
): string | null {
if (!play.target_own_post_id && !play.target_external?.url) {
return "play.err.needTarget";
}
if (!play.steps.length) return "play.err.needReplies";
const speakers = new Set(speakersOf(play));
if (speakers.size === 0) return "play.err.needReplyAccounts";
for (const step of play.steps) {
if (!step.account_id || !speakers.has(step.account_id)) {
return "play.err.stepAccount";
}
if (!step.text.trim()) return "play.err.stepEmpty";
}
return null;
}

View File

@ -0,0 +1,646 @@
export type Role = "member" | "admin";
/** 島民帳號狀態 */
export type MemberStatus = "active" | "suspended";
export type Member = {
tenant_id: string;
uid: string;
email: string;
display_name: string;
roles: Role[];
/** active 正常 · suspended 停權(不可登入/使用) */
status?: MemberStatus;
/** 顯示用簡短自我介紹(選填) */
bio?: string;
/** IANA timezone預設 Asia/Taipei */
timezone?: string;
/** 是否接收系統/任務 email 通知mock */
notify_email?: boolean;
/**
* mockdata URL https UI
*/
avatar_url?: string | null;
/**
* 使 /app
*/
email_verified: boolean;
/** 信箱驗證完成時間 unix nanoseconds UTC選填 */
email_verified_at?: number | null;
};
export type ConnectionStatus = "unknown" | "connected" | "error";
export type ThreadsAccount = {
id: string;
username: string;
display_name: string;
connection: ConnectionStatus;
is_usable: boolean;
avatar_color: string;
/** 頭像圖 URL空則 UI 顯示縮寫色塊 */
avatar_url?: string | null;
error_message?: string;
/** OAuth / session token 過期時間unix nanoseconds UTC */
session_expires_at?: number | null;
/** 上次成功刷新 session 的時間unix nanoseconds UTC */
session_refreshed_at?: number | null;
};
export type PlayStatus = "draft" | "ready" | "scheduling" | "active" | "completed" | "partial_failed" | "archived";
export type StepKind = "root" | "reply";
export type PlayStep = {
id: string;
sort_order: number;
kind: StepKind;
account_id: string;
text: string;
delay_from_previous_sec: number;
/** 可選:此則 AI 語氣人設 */
persona_id?: string | null;
/** 可選:此則 AI 帶入的品牌視角 */
brand_id?: string | null;
/** 附圖 URLmock本機 data URL 過長時改存佔位圖) */
image_urls?: string[];
};
/**
* Threads
* Phase B mock live media id
*/
export type ExternalThreadTarget = {
/** 正規化後的 permalink */
url: string;
/** 使用者貼上的原文 */
raw_url?: string;
shortcode?: string;
author_username?: string;
/** 正文預覽mock 或抓取) */
text_preview?: string;
/** 若已解析到 numeric media id */
media_id?: string;
resolved_at?: number;
};
/**
*
* - target_own_post_id
* - target_external Threads
* -
* title
*/
export type ThreadPlay = {
id: string;
/** 方案名稱,例如「方案 A · 溫和接話」 */
title: string;
topic: string;
status: PlayStatus;
/** 貼文所屬主帳own 時 = 該貼 accountexternal 時 = 預設主回帳) */
lead_account_id: string;
/** 可出場回覆的帳號池 */
cast_account_ids: string[];
/** 掛在哪則自己的貼文下 */
target_own_post_id?: string | null;
/** 掛在外部 Threads 貼文下(與 own 互斥) */
target_external?: ExternalThreadTarget | null;
steps: PlayStep[];
schedule_start_at: number;
/** 回覆間隔預設(分鐘),編輯用 */
interval_minutes?: number;
created_at: number;
updated_at: number;
};
export type OutboxStepStatus =
| "draft"
| "scheduled"
| "publishing"
| "published"
| "failed"
| "cancelled"
| "blocked";
export type OutboxStep = {
id: string;
step_id: string;
sort_order: number;
kind: StepKind;
account_id: string;
text: string;
status: OutboxStepStatus;
error?: string;
scheduled_at?: number;
published_at?: number;
};
export type OutboxBundleStatus = "scheduling" | "active" | "completed" | "partial_failed" | "cancelled";
export type OutboxBundle = {
id: string;
play_id: string;
title: string;
status: OutboxBundleStatus;
steps: OutboxStep[];
created_at: number;
updated_at: number;
};
export type JobStatus =
| "pending"
| "queued"
| "running"
| "succeeded"
| "failed"
| "cancelled"
| "cancel_requested";
export type Job = {
id: string;
template_type: string;
status: JobStatus;
progress_summary: string;
progress_percent: number;
error?: string;
created_at: number;
updated_at: number;
completed_at?: number;
};
export type NotificationKind = "job" | "outbox" | "system";
export type AppNotification = {
id: string;
title: string;
body: string;
kind: NotificationKind;
ref_type: "job" | "outbox" | "none";
ref_id?: string;
read_at?: number | null;
created_at: number;
};
export type DataSource = "mock" | "live";
export type TokenPair = {
access_token: string;
refresh_token: string;
};
/** 人設就緒狀態 */
export type PersonaStatus = "empty" | "analyzing" | "ready";
export type StyleDimKey =
| "d1Tone"
| "d2Structure"
| "d3Interaction"
| "d4Topics"
| "d5Rhythm"
| "d6Visual"
| "d7Conversion"
| "d8Risk";
export type StyleDimension = {
summary: string;
evidence: string[];
};
/** 語言指紋結構化欄位(產文主力) */
export type PersonaDraftFields = {
identity: string;
tone: string;
audience: string;
hooks: string;
languageFingerprint: string;
rhythm: string;
punctuation: string;
contentPatterns: string;
knowledgeTranslation: string;
ctaStyle: string;
examples: string;
avoid: string;
};
export type PersonaStyle = {
dimensions: Partial<Record<StyleDimKey, StyleDimension>>;
draft: PersonaDraftFields;
/** 合成/可手改的指紋全文,優先餵 prompt */
draftText: string;
source: "manual" | "benchmark" | "seed";
/** 對標帳號(公開貼文爬取來源) */
benchmarkUsername?: string;
/** 手動貼文來源說明 */
sourceLabel?: string;
sampleCount: number;
analyzedAt?: number;
/** 分析用的樣本摘要(可審) */
samplePreviews?: string[];
};
export type PersonaGuard = {
avoid: string[];
maxChars: number;
banAiTone: boolean;
};
/**
*
* - brief = who
* - style = how8D +
* - guard = don't
* voice/notes seed
*/
export type Persona = {
id: string;
name: string;
brief: string;
status: PersonaStatus;
style: PersonaStyle;
guard: PersonaGuard;
/** @deprecated 相容;顯示用 tone 請用 style.draft.tone */
voice?: string;
/** @deprecated 相容 */
notes?: string;
};
/** 留言是否已被我們回過 */
export type OwnPostReplyStatus = "pending" | "replied";
export type OwnPostReply = {
id: string;
username: string;
text: string;
created_at: number;
/** 該則留言的讚(若 API 有給) */
like_count?: number;
/** 未回覆 / 已回覆(我們是否已回這則) */
reply_status?: OwnPostReplyStatus;
/** 我們回覆時用的帳號 usernamemock */
replied_by?: string;
replied_at?: number;
/**
* id =
* =
*/
parent_reply_id?: string | null;
/** 是否為我們自己帳號發出的回覆 */
is_mine?: boolean;
};
/** 對齊 Threads API media / insights 常見欄位mock 先齊,接真直接 map */
export type ThreadsMediaType =
| "TEXT_POST"
| "IMAGE"
| "VIDEO"
| "CAROUSEL_ALBUM"
| "REPOST_FACADE"
| "AUDIO"
| string;
export type OwnPost = {
id: string;
account_id: string;
/** Threads media id */
media_id?: string;
text: string;
media_type: ThreadsMediaType;
media_url?: string | null;
thumbnail_url?: string | null;
permalink?: string;
shortcode?: string;
topic_tag?: string;
/** 成效Threads insights / fields */
like_count: number;
reply_count: number;
/** 轉發 repost */
repost_count: number;
/** 引用 quote */
quote_count: number;
/** 瀏覽 views */
view_count: number;
/** 分享 shares有則顯示 */
share_count: number;
is_quote_post?: boolean;
is_reply?: boolean;
/** insights 拉取狀態 */
insights_status?: "ok" | "partial" | "unavailable" | string;
insights_message?: string;
formula_summary?: string;
/** 成效洞察一句 */
insight?: string;
/** 爆紅/結構分析全文 */
formula_detail?: string;
replies: OwnPostReply[];
published_at: number;
};
export type MentionStatus = "pending" | "replied" | "skipped";
export type MentionItem = {
id: string;
account_id: string;
from_username: string;
text: string;
context_snippet: string;
status: MentionStatus;
draft_text?: string;
created_at: number;
};
export type ViralSample = {
id: string;
author: string;
text: string;
like_count: number;
reply_count: number;
topic?: string;
};
export type ViralAnalysis = {
hooks: string;
structure: string;
emotion: string;
copyable: string;
risks: string;
summary: string;
};
export type ResearchHit = {
id: string;
title: string;
/** 短摘 */
snippet: string;
url: string;
/** 網頁內容摘要(做功課用,可比 snippet 長) */
summary?: string;
/** 學習重點35 條,可掃讀) */
learn_points?: string[];
/** 回帖可直接借的鉤子 */
reply_hooks?: string[];
/** 來源站名 */
source_label?: string;
/** 與主題貼近度 */
tier?: ScoutResearchTier;
};
/**
*
* - core
* - adjacent
* - broad
*/
export type ScoutResearchTier = "core" | "adjacent" | "broad";
/** 知識節點與產品/痛點的關係(圖譜邊) */
export type ScoutKnowledgeRelation =
| "solves_pain"
| "nearby_scene"
| "myth"
| "contrast"
| "background";
/**
*
* + +
*/
export type ScoutResearchNote = {
id: string;
title: string;
/** 網頁內容摘要(次要;主讀 learn_points */
summary: string;
/** 學習重點35 條子彈 */
learn_points?: string[];
/** 回帖可直接借的鉤子13 句) */
reply_hooks?: string[];
url: string;
source_label?: string;
/** 從此頁抽出可海巡的詞 */
keywords?: string[];
/** 與主題貼近度分層 */
tier?: ScoutResearchTier;
/** 與產品的關係 */
relation?: ScoutKnowledgeRelation;
};
export type InspirationIdea = {
id: string;
title: string;
hook: string;
angle: string;
source: "formula" | "trend" | "persona" | "viral";
/** 來自哪個熱點(若有) */
trend_id?: string | null;
};
/** 靈感第二步:可選的開場角度(舊流程相容) */
export type InspireAngle = {
id: string;
hook: string;
};
/** 靈感元素庫:可重用產文材料 */
export type InspireElementKind = "persona" | "role" | "brand" | "snippet" | "trend";
export type InspireElement = {
id: string;
kind: InspireElementKind;
/** 列表顯示 */
title: string;
/** 塞進 prompt 的文字persona/brand 可 runtime 再展開) */
body: string;
/** persona_id / brand_id / trend_id */
ref_id?: string | null;
/** true = 進元素庫持久false = 僅本輪 */
reusable: boolean;
created_at: number;
updated_at: number;
};
export type InspireChatMessage = {
id: string;
role: "user" | "assistant" | "system";
text: string;
/** assistant 產稿時附乾淨正文 */
draft?: { title?: string; body: string };
created_at: number;
};
export type InspireSession = {
id: string;
messages: InspireChatMessage[];
pinned_element_ids: string[];
updated_at: number;
};
/** 靈感來源:外面「正在熱」的題材,不是自己腦內產文 */
export type TrendKind = "threads_tag" | "web_keyword" | "news";
export type TrendItem = {
id: string;
kind: TrendKind;
/** 顯示名:#敏感肌、颱風假、無香洗髮精… */
label: string;
summary: string;
/** mock 熱度 1100 */
heat: number;
keywords: string[];
/** 平台/媒體上的片段,幫助理解為什麼熱 */
samples: string[];
source_label: string;
/** unix nanoseconds UTC */
observed_at: number;
};
export type AiSettings = {
provider: string;
model: string;
research_provider: string;
research_model: string;
/** mock only: whether a key was saved (never store real secrets in UI state beyond local) */
api_key_configured: boolean;
research_api_key_configured: boolean;
};
export type PlacementSettings = {
web_search_provider: "brave" | "exa";
expand_strategy: "brave" | "llm" | "hybrid";
brave_api_key_configured: boolean;
exa_api_key_configured: boolean;
dev_mode_enabled: boolean;
};
export type Brand = {
id: string;
display_name: string;
brief: string;
/** 給誰:敏感受眾、遠端上班族… */
target_audience?: string;
/** 這輪目標:軟性經驗分享、導連結… */
goals?: string;
};
/**
*
* BrandsPage + pain_points
*/
export type BrandProduct = {
id: string;
brand_id: string;
/** 產品標籤/名稱 */
label: string;
/** 賣點與使用情境(寫草稿) */
product_context: string;
/** 對方貼文常出現的詞(掃/打分) */
match_tags: string[];
/** 對方在煩什麼(找人、產關鍵字) */
pain_points: string[];
/** 可分享連結(選填) */
placement_url?: string;
created_at: number;
updated_at: number;
};
export type ScoutTopic = {
id: string;
brand_id: string;
name: string;
keywords: string[];
/** 此主題預設主推的產品 */
preferred_product_id?: string | null;
};
export type ScoutOutreachStatus = "new" | "drafted" | "published" | "skipped";
/**
*
* - product
* - theme
* - activity
*/
export type ScoutMode = "product" | "theme" | "activity";
/** 使用者選的探查目的UI */
export type ScoutPurpose = "value" | "activity";
export type ScoutPost = {
id: string;
/** @deprecated 保留相容;新掃描以 brand_id 為主 */
topic_id?: string;
/** 主題模式可無品牌 */
brand_id?: string | null;
author: string;
text: string;
search_tag: string;
opportunity: string;
outreach_status: ScoutOutreachStatus;
draft_text?: string;
/** 0100 契合分mock 打分) */
score: number;
matched_product_id?: string | null;
matched_product_label?: string | null;
/** 為何命中 */
match_reason?: string;
scout_mode?: ScoutMode;
/** 這輪意圖摘要(可選) */
intent_snippet?: string;
/** 分組用:同一輪海巡相同 */
theme_key?: string;
/** 分組顯示名:主題/產品/活躍關鍵字 */
theme_label?: string;
};
/**
* +
*/
export type ScoutRunBrief = {
intent: string;
mode: ScoutMode;
brand_id?: string | null;
product_id?: string | null;
product_label?: string | null;
/** 展示產品能解的痛A或主題焦點詞B */
pains: string[];
/** 對方常講/興趣詞 */
tags: string[];
/** 周邊知識 */
periphery: string[];
/** 預設全勾;使用者可卸 */
scan_terms: string[];
/** A置入原則 */
placement_note?: string;
/** B回應姿態 */
response_stance?: string;
/**
*
* research
*/
research_notes?: ScoutResearchNote[];
/** 產品 context 全文(展示用) */
product_context?: string;
/** 對方常講的話match_tags 詳列) */
match_tags_detail?: string[];
/** 分組/持久化用(與命中 theme_key 對齊) */
theme_key?: string;
theme_label?: string;
};
/** 已完成的功課(可依主題找回,不消失) */
export type ScoutHomeworkRecord = {
theme_key: string;
theme_label: string;
purpose: ScoutPurpose;
brief: ScoutRunBrief;
/** unix nanoseconds UTC */
created_at: number;
};
/** @deprecated 舊掃描前 context新流程用 ScoutRunBrief */
export type ScoutScanContext = {
brand_id: string;
product_ids: string[];
pains: string[];
tags: string[];
/** 輕量延伸詞(相關問法) */
expand_terms: string[];
};

View File

@ -0,0 +1,87 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { formatMoney, formatPlanPrice } from "../lib/i18n/format";
import { translate } from "../lib/i18n/messages";
import { loadUiPrefs, saveUiPrefs } from "../lib/i18n/prefs";
import type { AppCurrency, AppLocale } from "../lib/i18n/types";
type I18nContextValue = {
locale: AppLocale;
currency: AppCurrency;
setLocale: (locale: AppLocale) => void;
setCurrency: (currency: AppCurrency) => void;
setPrefs: (prefs: { locale?: AppLocale; currency?: AppCurrency }) => void;
t: (key: string, params?: Record<string, string | number>) => string;
formatMoney: (amountTwd: number) => string;
formatPlanPrice: (amountTwd: number) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<AppLocale>(() => loadUiPrefs().locale);
const [currency, setCurrencyState] = useState<AppCurrency>(
() => loadUiPrefs().currency,
);
useEffect(() => {
document.documentElement.lang = locale === "en" ? "en" : "zh-Hant";
}, [locale]);
const setLocale = useCallback((next: AppLocale) => {
setLocaleState(next);
saveUiPrefs({ locale: next });
}, []);
const setCurrency = useCallback((next: AppCurrency) => {
setCurrencyState(next);
saveUiPrefs({ currency: next });
}, []);
const setPrefs = useCallback(
(prefs: { locale?: AppLocale; currency?: AppCurrency }) => {
const nextLocale = prefs.locale ?? locale;
const nextCurrency = prefs.currency ?? currency;
setLocaleState(nextLocale);
setCurrencyState(nextCurrency);
saveUiPrefs({ locale: nextLocale, currency: nextCurrency });
},
[locale, currency],
);
const t = useCallback(
(key: string, params?: Record<string, string | number>) =>
translate(locale, key, params),
[locale],
);
const value = useMemo<I18nContextValue>(
() => ({
locale,
currency,
setLocale,
setCurrency,
setPrefs,
t,
formatMoney: (amountTwd: number) => formatMoney(amountTwd, currency, locale),
formatPlanPrice: (amountTwd: number) =>
formatPlanPrice(amountTwd, currency, locale),
}),
[locale, currency, setLocale, setCurrency, setPrefs, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}

View File

@ -1,16 +0,0 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
-webkit-font-smoothing: antialiased;
}

View File

@ -0,0 +1,507 @@
import type { OwnPost } from "../domain/types";
import { newId } from "./id";
import { readJson, writeJson } from "./storage";
import { nowUnixNano } from "./time";
import { KEYS } from "../data/mock/keys";
export type MonthBucket = {
/** YYYY-MM */
key: string;
/** 顯示2026/4 */
label: string;
posts: number;
views: number;
likes: number;
replies: number;
reposts: number;
quotes: number;
shares: number;
/** 01 */
engagementRate: number;
/** 真實貼文聚合 or 補齊 mock 歷史 */
source: "posts" | "estimate";
};
export type AccountInsightsReport = {
accountId: string;
months: MonthBucket[];
current: MonthBucket;
previous: MonthBucket | null;
/** 相對上月變化 %null = 無法比) */
delta: {
views: number | null;
likes: number | null;
replies: number | null;
posts: number | null;
engagementRate: number | null;
};
topPosts: OwnPost[];
/** 分析結論 */
analysis: string[];
/** 可執行建議 */
recommendations: string[];
totalPosts: number;
/** 互動率平均(有 views 的貼) */
avgEngagementRate: number;
};
/** 單月分析封存:可回看每個月的結論與建議 */
export type AccountInsightsSnapshot = {
id: string;
account_id: string;
/** YYYY-MM */
month_key: string;
month_label: string;
/** 產生/覆寫分析的時間 unix ns */
analyzed_at: number;
metrics: {
posts: number;
views: number;
likes: number;
replies: number;
engagementRate: number;
source: MonthBucket["source"];
};
delta: {
views: number | null;
likes: number | null;
replies: number | null;
posts: number | null;
engagementRate: number | null;
};
analysis: string[];
recommendations: string[];
/** 當月 Top 貼摘要(封存文字,不依貼文刪除而消失) */
top_highlights: string[];
};
export function monthKeyFromDate(d: Date = new Date()): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function monthLabel(key: string): string {
const [y, m] = key.split("-");
return `${y}/${Number(m)}`;
}
function loadAllSnapshots(): AccountInsightsSnapshot[] {
return readJson(KEYS.accountInsightHistory, [] as AccountInsightsSnapshot[]);
}
function saveAllSnapshots(list: AccountInsightsSnapshot[]): void {
writeJson(KEYS.accountInsightHistory, list.slice(0, 240));
}
export function listInsightSnapshots(accountId: string): AccountInsightsSnapshot[] {
return loadAllSnapshots()
.filter((s) => s.account_id === accountId)
.slice()
.sort((a, b) => (a.month_key < b.month_key ? 1 : a.month_key > b.month_key ? -1 : 0));
}
export function getInsightSnapshot(
accountId: string,
monthKey: string,
): AccountInsightsSnapshot | null {
return (
loadAllSnapshots().find((s) => s.account_id === accountId && s.month_key === monthKey) || null
);
}
/** 寫入/覆寫某帳某月分析 */
export function saveInsightSnapshot(snap: AccountInsightsSnapshot): AccountInsightsSnapshot {
const list = loadAllSnapshots().filter(
(s) => !(s.account_id === snap.account_id && s.month_key === snap.month_key),
);
list.unshift(snap);
saveAllSnapshots(list);
return snap;
}
function snapshotFromParts(opts: {
accountId: string;
month: MonthBucket;
previous: MonthBucket | null;
analysis: string[];
recommendations: string[];
topHighlights: string[];
forceNewId?: boolean;
existingId?: string;
}): AccountInsightsSnapshot {
const delta = {
views: opts.previous ? pctChange(opts.month.views, opts.previous.views) : null,
likes: opts.previous ? pctChange(opts.month.likes, opts.previous.likes) : null,
replies: opts.previous ? pctChange(opts.month.replies, opts.previous.replies) : null,
posts: opts.previous ? pctChange(opts.month.posts, opts.previous.posts) : null,
engagementRate: opts.previous
? pctChange(opts.month.engagementRate * 100, opts.previous.engagementRate * 100)
: null,
};
return {
id: opts.existingId || newId("ain"),
account_id: opts.accountId,
month_key: opts.month.key,
month_label: opts.month.label,
analyzed_at: nowUnixNano(),
metrics: {
posts: opts.month.posts,
views: opts.month.views,
likes: opts.month.likes,
replies: opts.month.replies,
engagementRate: opts.month.engagementRate,
source: opts.month.source,
},
delta,
analysis: opts.analysis,
recommendations: opts.recommendations,
top_highlights: opts.topHighlights,
};
}
/**
* report N force
*/
export function ensureInsightHistory(
report: AccountInsightsReport,
opts?: { forceCurrent?: boolean },
): AccountInsightsSnapshot[] {
const months = report.months;
const topHighlights = report.topPosts.slice(0, 3).map((p) => {
const head = (p.insight || p.formula_summary || p.text).slice(0, 80);
return `${p.topic_tag ? `${p.topic_tag}` : ""}${head}${head.length >= 80 ? "…" : ""}`;
});
for (let i = 0; i < months.length; i++) {
const month = months[i]!;
const previous = i > 0 ? months[i - 1]! : null;
const existing = getInsightSnapshot(report.accountId, month.key);
const isCurrent = i === months.length - 1;
if (existing && !(isCurrent && opts?.forceCurrent)) continue;
const delta = {
views: previous ? pctChange(month.views, previous.views) : null,
likes: previous ? pctChange(month.likes, previous.likes) : null,
replies: previous ? pctChange(month.replies, previous.replies) : null,
posts: previous ? pctChange(month.posts, previous.posts) : null,
engagementRate: previous
? pctChange(month.engagementRate * 100, previous.engagementRate * 100)
: null,
};
// 本月:優先用 report 全文force 或歷史月:依該月指標重算敘事
const narrative =
isCurrent && !opts?.forceCurrent && report.analysis.length > 0
? { analysis: report.analysis, recommendations: report.recommendations }
: buildNarrative({
current: month,
previous,
delta,
topPosts: isCurrent ? report.topPosts : [],
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate,
totalPosts: isCurrent ? report.totalPosts : month.posts,
monthLabel: month.label,
});
saveInsightSnapshot(
snapshotFromParts({
accountId: report.accountId,
month,
previous,
analysis: narrative.analysis,
recommendations: narrative.recommendations,
topHighlights: isCurrent
? topHighlights
: existing?.top_highlights?.length
? existing.top_highlights
: [
`${month.label} 互動率 ${Math.round(month.engagementRate * 1000) / 10}%`,
`瀏覽 ${month.views.toLocaleString("zh-TW")} · 回覆 ${month.replies}`,
],
existingId: existing?.id,
}),
);
}
return listInsightSnapshots(report.accountId);
}
function engagementOf(p: OwnPost): number {
const views = p.view_count || 0;
if (views <= 0) return 0;
const eng =
(p.like_count || 0) +
(p.reply_count || 0) +
(p.repost_count || 0) +
(p.quote_count || 0) +
(p.share_count || 0);
return eng / views;
}
function emptyBucket(key: string, source: MonthBucket["source"] = "estimate"): MonthBucket {
return {
key,
label: monthLabel(key),
posts: 0,
views: 0,
likes: 0,
replies: 0,
reposts: 0,
quotes: 0,
shares: 0,
engagementRate: 0,
source,
};
}
function finalizeBucket(b: MonthBucket): MonthBucket {
const eng =
b.likes + b.replies + b.reposts + b.quotes + b.shares;
return {
...b,
engagementRate: b.views > 0 ? eng / b.views : 0,
};
}
function lastNMonthKeys(n: number, from = new Date()): string[] {
const keys: string[] = [];
const d = new Date(from.getFullYear(), from.getMonth(), 1);
for (let i = n - 1; i >= 0; i--) {
const x = new Date(d.getFullYear(), d.getMonth() - i, 1);
keys.push(monthKeyFromDate(x));
}
return keys;
}
function pctChange(curr: number, prev: number): number | null {
if (prev <= 0 && curr <= 0) return 0;
if (prev <= 0) return curr > 0 ? 100 : null;
return Math.round(((curr - prev) / prev) * 1000) / 10;
}
function hashSeed(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
/**
* N mock 便
* live snapshot
*/
export function buildAccountInsights(
accountId: string,
posts: OwnPost[],
monthCount = 6,
): AccountInsightsReport {
const mine = posts.filter((p) => p.account_id === accountId);
const keys = lastNMonthKeys(monthCount);
const byMonth = new Map<string, MonthBucket>();
for (const k of keys) byMonth.set(k, emptyBucket(k, "posts"));
for (const p of mine) {
const d = new Date(Math.floor(p.published_at / 1_000_000));
const key = monthKeyFromDate(d);
if (!byMonth.has(key)) continue;
const b = byMonth.get(key)!;
b.posts += 1;
b.views += p.view_count || 0;
b.likes += p.like_count || 0;
b.replies += p.reply_count || 0;
b.reposts += p.repost_count || 0;
b.quotes += p.quote_count || 0;
b.shares += p.share_count || 0;
b.source = "posts";
}
// 有真實數據的月份均值,用來補齊空月
const real = keys
.map((k) => byMonth.get(k)!)
.filter((b) => b.posts > 0)
.map(finalizeBucket);
const avgViews = real.length
? real.reduce((s, b) => s + b.views, 0) / real.length
: 800;
const avgLikes = real.length
? real.reduce((s, b) => s + b.likes, 0) / real.length
: 30;
const avgReplies = real.length
? real.reduce((s, b) => s + b.replies, 0) / real.length
: 3;
const avgPosts = real.length
? Math.max(1, Math.round(real.reduce((s, b) => s + b.posts, 0) / real.length))
: 2;
for (let i = 0; i < keys.length; i++) {
const k = keys[i]!;
const b = byMonth.get(k)!;
if (b.posts > 0) {
byMonth.set(k, finalizeBucket(b));
continue;
}
// 越舊略低、加帳號雜訊(穩定偽隨機)
const age = keys.length - 1 - i;
const noise = 0.72 + (hashSeed(`${accountId}|${k}`) % 40) / 100;
const factor = noise * (1 - age * 0.06);
const est = emptyBucket(k, "estimate");
est.posts = Math.max(1, Math.round(avgPosts * factor));
est.views = Math.round(avgViews * factor);
est.likes = Math.round(avgLikes * factor);
est.replies = Math.max(0, Math.round(avgReplies * factor));
est.reposts = Math.round(est.likes * 0.12);
est.quotes = Math.round(est.likes * 0.08);
est.shares = Math.round(est.likes * 0.05);
byMonth.set(k, finalizeBucket(est));
}
const months = keys.map((k) => byMonth.get(k)!);
const current = months[months.length - 1]!;
const previous = months.length >= 2 ? months[months.length - 2]! : null;
const delta = {
views: previous ? pctChange(current.views, previous.views) : null,
likes: previous ? pctChange(current.likes, previous.likes) : null,
replies: previous ? pctChange(current.replies, previous.replies) : null,
posts: previous ? pctChange(current.posts, previous.posts) : null,
engagementRate: previous
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
: null,
};
const topPosts = mine
.slice()
.sort((a, b) => {
const score = (p: OwnPost) =>
(p.view_count || 0) +
(p.like_count || 0) * 8 +
(p.reply_count || 0) * 20 +
(p.repost_count || 0) * 15 +
(p.quote_count || 0) * 12;
return score(b) - score(a);
})
.slice(0, 5);
const withViews = mine.filter((p) => (p.view_count || 0) > 0);
const avgEngagementRate = withViews.length
? withViews.reduce((s, p) => s + engagementOf(p), 0) / withViews.length
: current.engagementRate;
const { analysis, recommendations } = buildNarrative({
current,
previous,
delta,
topPosts,
avgEngagementRate,
totalPosts: mine.length,
monthLabel: current.label,
});
return {
accountId,
months,
current,
previous,
delta,
topPosts,
analysis,
recommendations,
totalPosts: mine.length,
avgEngagementRate,
};
}
function buildNarrative(opts: {
current: MonthBucket;
previous: MonthBucket | null;
delta: AccountInsightsReport["delta"];
topPosts: OwnPost[];
avgEngagementRate: number;
totalPosts: number;
monthLabel?: string;
}): { analysis: string[]; recommendations: string[] } {
const { current, previous, delta, topPosts, avgEngagementRate, totalPosts } = opts;
const when = opts.monthLabel || current.label || "本月";
const analysis: string[] = [];
const recommendations: string[] = [];
if (totalPosts === 0 && current.posts === 0) {
return {
analysis: [`${when}:尚無足夠貼文數據,無法比較月成效。`],
recommendations: ["先同步我的貼文,或該月至少發 12 則再分析。"],
};
}
analysis.push(
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(資料${current.source === "posts" ? "來自同步貼文" : "為歷史估測"})。`,
);
if (delta.views != null && previous) {
if (delta.views > 8) {
analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")})。`,
);
} else if (delta.views < -8) {
analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)},曝光偏弱,宜檢查發文節奏與開場鉤子。`,
);
} else {
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
}
}
if (delta.replies != null) {
if (delta.replies > 10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升,適合延續可接話題材。`);
} else if (delta.replies < -10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)},可多試「求經驗/明確條件」開問。`);
}
}
const engPct = Math.round(avgEngagementRate * 1000) / 10;
analysis.push(
`互動率約 ${engPct}%(讚+回+轉+引用+分享瀏覽。Threads 上 ${engPct >= 4 ? "屬不錯" : engPct >= 2 ? "中等,還有拉高空間" : "偏低,優先優化鉤子與問句"}`,
);
const top = topPosts[0];
if (top) {
const tag = top.topic_tag ? `${top.topic_tag}` : "高互動";
analysis.push(
`表現最佳貼偏 ${tag}${(top.insight || top.formula_summary || top.text).slice(0, 72)}${(top.insight || top.formula_summary || top.text).length > 72 ? "…" : ""}`,
);
}
const questionPosts = topPosts.filter((p) => /[?]/.test(p.text) || /求|有人|嗎/.test(p.text));
if (questionPosts.length >= 1 || (current.replies > 0 && current.posts > 0)) {
recommendations.push("維持條件明確的提問貼(插座/無香/預算),回覆與轉發通常較穩。");
} else {
recommendations.push("下一期至少 1 則帶具體條件的提問,避免純宣告。");
}
if ((delta.views ?? 0) < 0 || engPct < 2.5) {
recommendations.push("搭配探查清待回:活躍回覆能補帳號存在感,間接撐後續曝光。");
} else {
recommendations.push("把高互動貼的結構(鉤子+問句)複製到下 2 則新串,測能否再現。");
}
if (current.posts < 3) {
recommendations.push("該月發文偏少,可固定每週 23 則,曲線與月比才比較得準。");
} else {
recommendations.push("下月回看本頁歷史:對照瀏覽與回覆是否同向,避免只追讚數。");
}
return {
analysis: analysis.slice(0, 5),
recommendations: recommendations.slice(0, 4),
};
}
export function fmtDelta(n: number | null | undefined): string {
if (n == null || Number.isNaN(n)) return "—";
if (n > 0) return `+${n}%`;
if (n < 0) return `${n}%`;
return "0%";
}
export function fmtEngRate(rate: number): string {
return `${Math.round(rate * 1000) / 10}%`;
}

View File

@ -0,0 +1,72 @@
import { newId } from "./id";
/** 編輯器內附圖(本地選檔或 mock 產圖) */
export type AttachedImage = {
id: string;
url: string;
name?: string;
/** 產圖 prompt選檔可無 */
prompt?: string;
};
export const MAX_ATTACHED_IMAGES = 10;
/** 單檔上限(讀成 data URL 前) */
const MAX_FILE_BYTES = 8 * 1024 * 1024;
export function withAttachedImageNote(text: string, count: number): string {
const body = text.trim();
if (!count || count <= 0) return body;
if (/(附圖\s*\d+\s*張)/.test(body)) return body;
return `${body}\n\n附圖 ${count} 張)`;
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error(`讀取失敗:${file.name}`));
reader.readAsDataURL(file);
});
}
/**
* data URL
* mock repo
*/
export async function filesToAttachedImages(
files: FileList | File[],
opts?: { max?: number; existingCount?: number },
): Promise<AttachedImage[]> {
const max = opts?.max ?? MAX_ATTACHED_IMAGES;
const existing = opts?.existingCount ?? 0;
const room = Math.max(0, max - existing);
const list = Array.from(files).filter((f) => f.type.startsWith("image/"));
if (list.length === 0) throw new Error("請選擇圖片檔");
const take = list.slice(0, room);
if (take.length === 0) throw new Error(`最多附 ${max} 張圖`);
const out: AttachedImage[] = [];
for (const file of take) {
if (file.size > MAX_FILE_BYTES) {
throw new Error(`${file.name} 超過 8MB`);
}
const url = await readFileAsDataUrl(file);
if (!url.startsWith("data:image")) throw new Error(`無法讀取:${file.name}`);
out.push({
id: newId("img"),
url,
name: file.name,
});
}
return out;
}
/** 大 data URL 不寫進 localStorage改成可顯示的 mock 縮圖 seed */
export function toPersistableImageUrl(img: AttachedImage): string {
if (img.url.startsWith("data:") && img.url.length > 1800) {
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=c0aede,ffd5dc,b6e3f4`;
}
return img.url;
}

View File

@ -0,0 +1,45 @@
import { CURRENCIES, type AppCurrency, type AppLocale } from "./types";
/** 方案等金額以 TWD 為基準單位(整數) */
export function convertFromTwd(amountTwd: number, currency: AppCurrency): number {
const meta = CURRENCIES.find((c) => c.id === currency) || CURRENCIES[0]!;
return amountTwd * meta.fromTwd;
}
export function formatMoney(
amountTwd: number,
currency: AppCurrency,
locale: AppLocale,
): string {
const meta = CURRENCIES.find((c) => c.id === currency) || CURRENCIES[0]!;
const value = convertFromTwd(amountTwd, currency);
const localeTag = locale === "en" ? "en-US" : "zh-TW";
// JPY 無小數
const fraction = currency === "JPY" ? 0 : currency === "TWD" ? 0 : 2;
try {
return new Intl.NumberFormat(localeTag, {
style: "currency",
currency,
currencyDisplay: "symbol",
minimumFractionDigits: fraction,
maximumFractionDigits: fraction,
}).format(value);
} catch {
const rounded =
fraction === 0 ? Math.round(value) : Math.round(value * 100) / 100;
return `${meta.symbol}${rounded.toLocaleString(localeTag)}`;
}
}
/**
*
* UI `NT$590` + `/月`
*/
export function formatPlanPrice(
amountTwd: number,
currency: AppCurrency,
locale: AppLocale,
): string {
return formatMoney(amountTwd, currency, locale);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
import { KEYS } from "../../data/mock/keys";
import { readJson, writeJson } from "../storage";
import {
isAppCurrency,
isAppLocale,
type AppCurrency,
type AppLocale,
} from "./types";
/** 與 lib/theme 同步;此處內聯避免循環 import */
export type ThemePreference = "light" | "dark" | "system";
export type UiPrefs = {
locale: AppLocale;
currency: AppCurrency;
theme: ThemePreference;
};
const DEFAULT: UiPrefs = {
locale: "zh-TW",
currency: "TWD",
theme: "system",
};
function isThemePref(v: unknown): v is ThemePreference {
return v === "light" || v === "dark" || v === "system";
}
export function loadUiPrefs(): UiPrefs {
const raw = readJson<Partial<UiPrefs> | null>(KEYS.uiPrefs, null);
const locale = raw?.locale && isAppLocale(raw.locale) ? raw.locale : detectBrowserLocale();
const currency =
raw?.currency && isAppCurrency(raw.currency) ? raw.currency : DEFAULT.currency;
const theme = raw?.theme && isThemePref(raw.theme) ? raw.theme : DEFAULT.theme;
return { locale, currency, theme };
}
/** 合併寫入,避免只改語言時把 theme 洗掉 */
export function saveUiPrefs(prefs: Partial<UiPrefs>): void {
const cur = loadUiPrefs();
writeJson(KEYS.uiPrefs, {
locale: prefs.locale ?? cur.locale,
currency: prefs.currency ?? cur.currency,
theme: prefs.theme ?? cur.theme,
});
}
function detectBrowserLocale(): AppLocale {
try {
const lang = (navigator.language || "").toLowerCase();
if (lang.startsWith("zh")) return "zh-TW";
if (lang.startsWith("en")) return "en";
} catch {
/* ignore */
}
return DEFAULT.locale;
}

View File

@ -0,0 +1,38 @@
export type AppLocale = "zh-TW" | "en";
export type AppCurrency = "TWD" | "USD" | "JPY" | "EUR" | "HKD";
export type LocaleMeta = {
id: AppLocale;
label: string;
nativeLabel: string;
};
export type CurrencyMeta = {
id: AppCurrency;
label: string;
symbol: string;
/** 相對 TWD 的 mock 匯率1 TWD = rate * foreign? 改用amount_twd * rate = foreign */
fromTwd: number;
};
export const LOCALES: LocaleMeta[] = [
{ id: "zh-TW", label: "Traditional Chinese", nativeLabel: "繁體中文" },
{ id: "en", label: "English", nativeLabel: "English" },
];
export const CURRENCIES: CurrencyMeta[] = [
{ id: "TWD", label: "New Taiwan Dollar", symbol: "NT$", fromTwd: 1 },
{ id: "USD", label: "US Dollar", symbol: "US$", fromTwd: 1 / 32 },
{ id: "HKD", label: "Hong Kong Dollar", symbol: "HK$", fromTwd: 1 / 4.1 },
{ id: "JPY", label: "Japanese Yen", symbol: "¥", fromTwd: 4.7 },
{ id: "EUR", label: "Euro", symbol: "€", fromTwd: 1 / 35 },
];
export function isAppLocale(v: string): v is AppLocale {
return v === "zh-TW" || v === "en";
}
export function isAppCurrency(v: string): v is AppCurrency {
return CURRENCIES.some((c) => c.id === v);
}

6
apps/web/src/lib/id.ts Normal file
View File

@ -0,0 +1,6 @@
export function newId(prefix = "id"): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
}
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

View File

@ -0,0 +1,64 @@
/** 會員頭像:本機選檔 → 壓縮成可存 localStorage 的 data URL */
const MAX_INPUT_BYTES = 5 * 1024 * 1024;
const MAX_EDGE = 256;
const JPEG_QUALITY = 0.86;
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("無法讀取圖片"));
};
img.src = url;
});
}
/**
* 256px JPEG data URL
* mock
*/
export async function fileToMemberAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
throw new Error("請選擇圖片檔JPGPNGWebP");
}
if (file.size > MAX_INPUT_BYTES) {
throw new Error("圖片請在 5MB 以內");
}
const img = await loadImageFromFile(file);
const srcW = img.naturalWidth || img.width;
const srcH = img.naturalHeight || img.height;
if (!srcW || !srcH) throw new Error("無法讀取圖片尺寸");
const side = Math.min(srcW, srcH);
const sx = Math.floor((srcW - side) / 2);
const sy = Math.floor((srcH - side) / 2);
const edge = Math.min(MAX_EDGE, side);
const canvas = document.createElement("canvas");
canvas.width = edge;
canvas.height = edge;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("瀏覽器不支援圖片處理");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, sx, sy, side, side, 0, 0, edge, edge);
const dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
if (!dataUrl.startsWith("data:image/")) throw new Error("壓縮失敗");
if (dataUrl.length > 400_000) {
// 再壓一輪
const smaller = canvas.toDataURL("image/jpeg", 0.7);
if (smaller.length > 400_000) throw new Error("頭像仍過大,請換較簡單的圖");
return smaller;
}
return dataUrl;
}

View File

@ -0,0 +1,41 @@
import type { Member, Role } from "../domain/types";
type TFn = (key: string) => string;
/** 角色標籤(可接 i18n */
export function roleLabel(role: Role | string, t?: TFn): string {
if (role === "admin") return t ? t("role.admin") : "管理員";
if (role === "member") return t ? t("role.member") : "一般會員";
return String(role);
}
/**
* admin
*
*/
export function memberRoleSummary(
member: Pick<Member, "roles"> | null | undefined,
t?: TFn,
): {
primary: "admin" | "member";
primaryLabel: string;
labels: string[];
isAdmin: boolean;
} {
const roles = member?.roles?.length ? member.roles : (["member"] as Role[]);
const isAdmin = roles.includes("admin");
const primary = isAdmin ? "admin" : "member";
const labels = [...new Set(roles.map((r) => roleLabel(r, t)))];
return {
primary,
primaryLabel: isAdmin
? t
? t("role.admin")
: "管理員"
: t
? t("role.member")
: "一般會員",
labels,
isAdmin,
};
}

204
apps/web/src/lib/mockAi.ts Normal file
View File

@ -0,0 +1,204 @@
import type { Persona } from "../domain/types";
import { buildPersonaPromptBlock, isPersonaReady, toneOf } from "./personaPrompt";
/** Tiny delay so mock AI buttons feel async */
export function mockDelay(ms = 450): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
export function voiceLabel(persona?: Persona | null): string {
return persona?.name || "預設語氣";
}
function clip(s: string, n: number): string {
const t = s.trim();
return t.length > n ? `${t.slice(0, n)}` : t;
}
function fingerprintBits(persona?: Persona | null) {
const d = persona?.style?.draft;
return {
tone: toneOf(persona),
hooks: d?.hooks || "先丟痛點再問",
fingerprint: d?.languageFingerprint || "口語短句",
rhythm: d?.rhythm || "23 短段",
examples: d?.examples || "",
avoid: d?.avoid || persona?.guard?.avoid?.join("、") || "硬廣",
cta: d?.ctaStyle || "輕輕問一句",
audience: d?.audience || "會求經驗的人",
};
}
/** 產出時附帶使用的人設 block除錯預覽用 */
export function explainPersonaUsage(persona?: Persona | null, mode: "post" | "reply" | "outreach" | "inspire" = "post") {
return {
ready: isPersonaReady(persona),
name: persona?.name || "(未選)",
block: buildPersonaPromptBlock(persona, mode),
};
}
export function mockGenerateTopic(seed: string, persona?: Persona | null): string {
const b = fingerprintBits(persona);
const base = seed.trim() || "日常小發現";
return [
`(視角:${persona?.name || "預設"} · ${b.tone}`,
`${b.hooks}:最近卡在「${base}」。`,
`想聽 ${b.audience} 的真實經驗——不是規格文。`,
`用字偏向:${b.fingerprint}`,
].join("\n");
}
export function mockGenerateRoot(topic: string, persona?: Persona | null): string {
const b = fingerprintBits(persona);
const t = topic.trim() || "一個大家會想回的小題目";
const body = [
clip(t, 120),
"",
`我自己目前卡在「有沒有實際用過、會不會踩雷」。`,
b.examples ? `(像我會說的:${clip(b.examples, 48)}` : "",
b.cta,
]
.filter(Boolean)
.join("\n");
return `${b.tone} · 避開:${clip(b.avoid, 24)}\n${body}`;
}
export function mockGenerateReply(opts: {
context: string;
speakerName: string;
/** 有才套用語氣;沒有就中性口吻 */
persona?: Persona | null;
/** 有才輕帶品牌視角;沒有就不提 */
brandName?: string;
brandBrief?: string;
isLead?: boolean;
}): string {
const { context, speakerName, persona, brandName, brandBrief, isLead } = opts;
const hasPersona = Boolean(persona);
const b = fingerprintBits(persona);
const clipCtx = clip(context, 48) || "前面那則";
const head = hasPersona ? `${speakerName} · ${b.tone}` : `${speakerName}`;
const brandLine =
brandName && brandBrief
? `補充一點 ${brandName} 相關經驗:${clip(brandBrief, 48)}`
: brandName
? `補充一點和 ${brandName} 有關的實際用法。`
: "";
if (isLead) {
return [
head,
`懂你說的「${clipCtx}」。`,
brandLine || "想再問一句:你實際怎麼選的?",
hasPersona ? `(避開 ${clip(b.avoid, 24)}` : "",
]
.filter(Boolean)
.join("\n");
}
return [
head,
brandLine || `我這邊經驗是:${clipCtx} 真的有差。`,
brandLine ? `對「${clipCtx}」這點,務實做法通常是先對情境再對規格。` : "如果在意使用情境,我可以再補細節。",
hasPersona ? `用字:${b.fingerprint}` : "",
]
.filter(Boolean)
.join("\n");
}
export function mockGenerateOwnPostReply(opts: {
postText: string;
replyText?: string;
persona?: Persona | null;
}): string {
const b = fingerprintBits(opts.persona);
const target = clip(opts.replyText || opts.postText, 40);
return [
`(作者本人 · ${b.tone}`,
`懂你說的「${target}」。`,
`我自己是先抓痛點再對規格,通常看使用情境而不是只看規格表。`,
`你比較在意哪一點?`,
opts.persona?.guard?.banAiTone ? "(已關 AI 腔/客服腔)" : "",
]
.filter(Boolean)
.join("\n");
}
export function mockGenerateScoutDraft(opts: {
postText: string;
brandName: string;
brandBrief?: string;
targetAudience?: string;
productLabel?: string;
productContext?: string;
painHint?: string;
placementUrl?: string;
persona?: Persona | null;
/** theme = 接話product = 可輕帶解法activity = 短回養帳號 */
mode?: "product" | "theme" | "activity";
}): string {
const b = fingerprintBits(opts.persona);
const clipPost = clip(opts.postText, 36);
const mode = opts.mode || (opts.productLabel ? "product" : "theme");
if (mode === "activity") {
return [
`同感「${clipPost}」這段 🙌`,
`我也常這樣,後來比較會先停一下再決定。`,
`你現在比較偏哪一種狀態?`,
].join("\n");
}
if (mode === "theme") {
return [
`看到你聊「${clipPost}」——`,
`我之前也卡過類似的點,後來比較有感的是先釐清自己在意什麼。`,
`你現在最卡的是哪一段?可以再多講一點(真想聽,不是套公式)。`,
opts.persona?.guard?.banAiTone ? "" : "",
]
.filter(Boolean)
.join("\n");
}
const lines = [
`看到你提到「${clipPost}」很有感。`,
];
if (opts.painHint) {
lines.push(`這類「${clip(opts.painHint, 28)}」的情況,身邊也常聽到。`);
}
if (opts.productContext) {
lines.push(clip(opts.productContext, 120));
} else if (opts.productLabel) {
lines.push(`我自己後來會先從「${opts.productLabel}」這類情境想解法,不一定一步到位。`);
} else {
lines.push(`整理經驗時也常遇到類似情況——可以分享一個比較務實的切入點(非業配)。`);
}
if (opts.placementUrl) {
lines.push(opts.placementUrl);
}
lines.push(`你比較在意哪一點?`);
if (b.tone) {
// light fingerprint without fake meta banner
}
return lines.join("\n");
}
export function mockGenerateInspiration(
topic: string,
persona?: Persona | null,
): {
title: string;
hook: string;
angle: string;
} {
const b = fingerprintBits(persona);
const t = topic.trim() || "生活小題";
return {
title: `${t} · ${persona?.name || "日常視角"}`,
hook: `${b.hooks}(主題:${t};對 ${b.audience}`,
angle:
persona?.style?.dimensions?.d4Topics?.summary ||
persona?.brief ||
"先痛點、再經驗、最後輕帶觀點,避免一句廣告。",
};
}

View File

@ -0,0 +1,20 @@
import { mockDelay } from "./mockAi";
import { newId } from "./id";
export type GeneratedImage = {
id: string;
url: string;
prompt: string;
};
/** mock 產圖:用 dicebear 當可顯示縮圖 */
export async function mockGenerateImage(prompt: string): Promise<GeneratedImage> {
await mockDelay(700);
const p = prompt.trim() || "threads post visual";
const seed = encodeURIComponent(p.slice(0, 48) || "harbor");
return {
id: newId("img"),
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,ffd5dc`,
prompt: p,
};
}

View File

@ -0,0 +1,68 @@
import type { Brand, InspireAngle, Persona, TrendItem } from "../domain/types";
import { newId } from "./id";
import { mockDelay } from "./mockAi";
import { isPersonaReady, toneOf } from "./personaPrompt";
function clip(s: string, n: number): string {
const t = s.trim();
return t.length > n ? `${t.slice(0, n)}` : t;
}
/**
* 3
*
*/
export async function mockGenerateInspireAngles(opts: {
trend: TrendItem;
persona?: Persona | null;
brand?: Brand | null;
}): Promise<InspireAngle[]> {
await mockDelay(650);
const { trend } = opts;
const label = trend.label.replace(/^#/, "");
const sample = trend.samples[0] || trend.summary;
const persona = opts.persona && isPersonaReady(opts.persona) ? opts.persona : null;
const brand = opts.brand || null;
const tone = persona ? toneOf(persona) : "";
const brandBit = brand?.brief
? clip(brand.brief, 36)
: brand?.display_name
? brand.display_name
: "";
const a1 = persona
? `${tone})看到大家都在聊 ${trend.label}——${clip(sample, 40)} 你實際怎麼處理?`
: `${trend.label} 最近很吵:${clip(sample, 42)} 有人也是嗎?`;
const a2 = brandBit
? `${label} 時大家常忽略一點:${brandBit}。我自己的經驗是…`
: `先別急著結論 ${label}。我比較想聽「真正踩過雷」的人怎麼說。`;
const a3 =
trend.keywords[0] != null
? `一句話:${trend.keywords[0]} 到底卡在哪?留言區求不業配的真實答案。`
: `如果只能給 ${label} 一個實用建議,你會說什麼?`;
return [
{ id: newId("ang"), hook: a1 },
{ id: newId("ang"), hook: a2 },
{ id: newId("ang"), hook: a3 },
];
}
/** 搜尋關鍵字當臨時熱點,走同一套角度流 */
export function trendFromQuery(query: string): TrendItem {
const q = query.trim() || "熱門";
const now = Date.now() * 1_000_000;
return {
id: newId("trend"),
kind: "threads_tag",
label: q.startsWith("#") ? q : `#${q.replace(/\s+/g, "")}`,
summary: `與「${q}」相關的 Threads 討論`,
heat: 70,
keywords: [q],
samples: [`最近一直刷到「${q}」,求經驗`],
source_label: "搜尋",
observed_at: now,
};
}

View File

@ -0,0 +1,203 @@
import type {
Brand,
InspireChatMessage,
InspireElement,
Persona,
} from "../domain/types";
import { newId } from "./id";
import { mockDelay } from "./mockAi";
import { isPersonaReady } from "./personaPrompt";
import { nowUnixNano } from "./time";
export type ResolvedInspireContext = {
roles: string[];
snippets: string[];
trends: string[];
persona?: Persona | null;
brand?: Brand | null;
labels: string[];
};
export function resolveInspireContext(
elements: InspireElement[],
opts?: {
personas?: Persona[];
brands?: Brand[];
},
): ResolvedInspireContext {
const personas = opts?.personas || [];
const brands = opts?.brands || [];
const roles: string[] = [];
const snippets: string[] = [];
const trends: string[] = [];
const labels: string[] = [];
let persona: Persona | null = null;
let brand: Brand | null = null;
for (const el of elements) {
labels.push(el.title);
if (el.kind === "role") {
roles.push(el.body || el.title);
} else if (el.kind === "snippet") {
snippets.push(el.body || el.title);
} else if (el.kind === "trend") {
trends.push(el.body || el.title);
} else if (el.kind === "persona" && el.ref_id) {
const p = personas.find((x) => x.id === el.ref_id);
if (p && isPersonaReady(p)) persona = p;
else if (el.body) snippets.push(el.body);
} else if (el.kind === "brand" && el.ref_id) {
const b = brands.find((x) => x.id === el.ref_id);
if (b) brand = b;
else if (el.body) snippets.push(el.body);
} else if (el.body) {
snippets.push(el.body);
}
}
return { roles, snippets, trends, persona, brand, labels };
}
function toneLead(ctx: ResolvedInspireContext): string {
if (ctx.roles.some((r) => /朋友|私訊/.test(r))) return "懂你…";
if (ctx.roles.some((r) => /鉤子|短句/.test(r))) return "先講結論:";
if (ctx.roles.some((r) => /真實|反業配/.test(r))) return "講實話——";
if (ctx.persona?.style?.draft?.hooks) {
return ctx.persona.style.draft.hooks.slice(0, 24);
}
if (ctx.persona?.name) return `以「${ctx.persona.name}」的口吻:`;
return "";
}
function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
const topic =
userText.trim() ||
ctx.trends[0] ||
ctx.brand?.display_name ||
"日常小事";
const lead = toneLead(ctx);
const lines: string[] = [];
if (lead) lines.push(lead);
if (ctx.trends.length) {
lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)}`);
}
const personaBit =
ctx.persona?.brief ||
ctx.persona?.style?.draft?.tone ||
ctx.persona?.voice ||
"";
const brandBit = ctx.brand
? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `${ctx.brand.brief.slice(0, 40)}` : ""}`
: "";
if (/改短|短一點|精簡/.test(userText)) {
lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`);
} else if (/更口語|隨便|碎念/.test(userText)) {
lines.push(`欸所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`);
} else {
lines.push(
`${topic.slice(0, 80)}${personaBit ? `${personaBit.slice(0, 48)}` : ""}${brandBit}`,
);
lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。");
}
const wantAsk =
ctx.snippets.some((s) => /問句|留言/.test(s)) ||
ctx.roles.some((r) => /鉤子|問句/.test(r));
if (wantAsk) {
lines.push("你們最近有類似經驗嗎?");
}
if (ctx.snippets.some((s) => /誇大|療效|禁止/.test(s))) {
// 語氣收斂:不另加誇大句
}
// 短貼感
let body = lines.filter(Boolean).join("\n");
if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) {
body = body.slice(0, 180) + "…";
}
return body.trim();
}
function assistantNote(ctx: ResolvedInspireContext, mode: "chat" | "generate"): string {
if (mode === "chat") {
if (ctx.labels.length) {
return `好。這輪會帶上:${ctx.labels.slice(0, 4).join("、")}${ctx.labels.length > 4 ? "…" : ""}。你可以直接說想寫什麼,或按「產文」。`;
}
return "可以。想寫什麼主題?也可以先從右側套用角色/人設/片段,或點下方夯什麼。";
}
if (ctx.labels.length) {
return `已依 ${ctx.labels.slice(0, 3).join("、")} 寫一則草稿(可再改):`;
}
return "這是一則草稿(尚未套用特別元素,可在右側 pin 後再產):";
}
export async function mockInspireChat(opts: {
userMessage: string;
mode: "chat" | "generate";
elements: InspireElement[];
personas?: Persona[];
brands?: Brand[];
}): Promise<InspireChatMessage[]> {
await mockDelay(opts.mode === "generate" ? 650 : 380);
const now = nowUnixNano();
const ctx = resolveInspireContext(opts.elements, {
personas: opts.personas,
brands: opts.brands,
});
const userText = opts.userMessage.trim();
const out: InspireChatMessage[] = [];
if (userText) {
out.push({
id: newId("im"),
role: "user",
text: userText,
created_at: now,
});
}
if (opts.mode === "generate") {
const body = buildDraftBody(userText || "寫一則可發的 Threads", ctx);
out.push({
id: newId("im"),
role: "assistant",
text: assistantNote(ctx, "generate"),
draft: {
title: (ctx.trends[0] || userText || "靈感草稿").slice(0, 32),
body,
},
created_at: now + 1,
});
} else {
out.push({
id: newId("im"),
role: "assistant",
text: assistantNote(ctx, "chat"),
created_at: now + 1,
});
}
return out;
}
export function emptyInspireSession(): import("../domain/types").InspireSession {
const now = nowUnixNano();
return {
id: newId("isess"),
messages: [
{
id: newId("im"),
role: "assistant",
text: "說你想寫什麼。右側可套用角色、人設、品牌、片段;下方是最近 Threads 夯什麼。",
created_at: now,
},
],
pinned_element_ids: [],
updated_at: now,
};
}

View File

@ -0,0 +1,97 @@
import { mockDelay } from "./mockAi";
/** 從商品連結「抓回來」後要填進表單的草稿(尚未存檔) */
export type ProductUrlDraft = {
label: string;
product_context: string;
pain_points: string[];
match_tags: string[];
placement_url: string;
/** mock 說明live 會是後端爬頁 */
source_note: string;
};
function slugToWords(slug: string): string[] {
return slug
.split(/[-_+/]+/)
.map((s) => decodeURIComponent(s).trim())
.filter((s) => s.length > 1 && !/^\d+$/.test(s) && !/^(www|com|tw|html|php|p|product|products|item|shop)$/i.test(s));
}
function titleCaseWords(words: string[]): string {
return words
.map((w) => {
if (/[\u4e00-\u9fff]/.test(w)) return w;
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
})
.join(" ");
}
/** 依 URL 路徑主機推估產品語意mock真抓頁屬 Phase C 後端) */
export async function mockImportProductFromUrl(rawUrl: string): Promise<ProductUrlDraft> {
await mockDelay(900);
const trimmed = rawUrl.trim();
if (!trimmed) throw new Error("請貼上商品或官網連結");
let url: URL;
try {
url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
} catch {
throw new Error("連結格式不正確");
}
const host = url.hostname.replace(/^www\./, "");
const pathParts = url.pathname.split("/").filter(Boolean);
const lastSlug = pathParts[pathParts.length - 1] || host.split(".")[0] || "product";
const words = slugToWords(lastSlug.replace(/\.(html?|php)$/i, ""));
const fromQuery =
url.searchParams.get("name") ||
url.searchParams.get("title") ||
url.searchParams.get("q") ||
"";
const labelBase =
(fromQuery && fromQuery.trim()) ||
(words.length ? titleCaseWords(words.slice(0, 6)) : "") ||
host.split(".")[0] ||
"未命名商品";
const blob = `${labelBase} ${words.join(" ")} ${host}`.toLowerCase();
// 粗分類用路徑關鍵字推痛點tagsmock 啟發式)
let pain_points: string[] = [];
let match_tags: string[] = [...words.slice(0, 5)];
let angle = "依商品頁摘要整理的賣點與使用情境(請再人工改準)。";
if (/敏感|無香|香精|抗敏|unscent|sensitive|hypo/i.test(blob)) {
pain_points = ["香精/氣味太重受不了", "皮膚或頭皮容易刺癢", "找不到真的溫和選項"];
match_tags = [...new Set([...match_tags, "無香", "敏感肌", "溫和", "香精"])];
angle = "主打低刺激/無香體驗;適合先講使用感受,再輕帶規格與通路。";
} else if (/咖啡|cafe|coffee|插座|座位|第三空間/i.test(blob)) {
pain_points = ["平日下午沒地方久坐", "店裡沒插座或趕客", "人多坐不久"];
match_tags = [...new Set([...match_tags, "咖啡廳", "插座", "筆電", "不限時"])];
angle = "強調座位與工作友善,分享實際待店經驗優於硬推商品。";
} else if (/寶寶|嬰|幼兒|baby|kids/i.test(blob)) {
pain_points = ["怕洗劑太刺激", "怕洗不乾淨", "不知道怎麼選成分"];
match_tags = [...new Set([...match_tags, "寶寶", "溫和", "嬰幼兒"])];
angle = "親子情境:務實講洗淨與溫和的取捨,避免恐嚇式行銷。";
} else if (/洗|沐|護|detergent|shampoo|soap|skincare|保養/i.test(blob)) {
pain_points = ["不知道適不適合自己膚況", "行銷話術看不懂", "用完反而不適"];
match_tags = [...new Set([...match_tags, "選品", "成分", "使用心得"])];
angle = "先對齊使用情境與頻率,再對規格;語氣務實不硬廣。";
} else {
pain_points = [`在找「${labelBase}」相關解法或真實心得`, "被行銷文搞混、想聽實際用過的人講"];
if (!match_tags.length) match_tags = [labelBase, "推薦", "心得"];
angle = `從「${labelBase}」商品頁推測的使用情境;請依真實賣點改寫。`;
}
const product_context = [`${labelBase}`, angle].join("\n");
return {
label: labelBase.slice(0, 48),
product_context,
pain_points,
match_tags: match_tags.map((t) => t.slice(0, 24)).filter(Boolean).slice(0, 12),
placement_url: url.toString(),
source_note: "已填入,可再改",
};
}

View File

@ -0,0 +1,323 @@
import type {
ResearchHit,
ScoutKnowledgeRelation,
ScoutResearchNote,
ScoutResearchTier,
} from "../domain/types";
import { mockDelay } from "./mockAi";
import { newId } from "./id";
const SOURCES = [
{ host: "www.commonhealth.com.tw", label: "康健雜誌" },
{ host: "www.edh.tw", label: "早安健康" },
{ host: "heho.com.tw", label: "Heho健康" },
{ host: "www.dcard.tw", label: "Dcard" },
{ host: "www.ptt.cc", label: "PTT" },
{ host: "medium.com", label: "Medium" },
];
export const RESEARCH_TIER_META: Record<
ScoutResearchTier,
{ label: string; hint: string; order: number }
> = {
core: { label: "最貼主題", hint: "直接對準痛點/關鍵語,優先讀", order: 0 },
adjacent: { label: "相關周邊", hint: "鄰近情境,擴搜尋面", order: 1 },
broad: { label: "最廣泛", hint: "背景與對照,選讀即可", order: 2 },
};
export const KNOWLEDGE_RELATION_META: Record<
ScoutKnowledgeRelation,
{ label: string }
> = {
solves_pain: { label: "對準痛點" },
nearby_scene: { label: "鄰近場景" },
myth: { label: "迷思澄清" },
contrast: { label: "對照選購" },
background: { label: "背景脈絡" },
};
function pageUrl(host: string, slug: string): string {
return `https://${host}/article/${encodeURIComponent(slug)}`;
}
/** 漂亮顯示用:來源站名 + 精簡 host */
export function formatResearchLink(url: string, sourceLabel?: string): {
label: string;
host: string;
} {
let host = "";
try {
host = new URL(url).hostname.replace(/^www\./, "");
} catch {
host = url.replace(/^https?:\/\//, "").split("/")[0] || "";
}
return {
label: sourceLabel?.trim() || host || "來源",
host,
};
}
/**
* learn_points summary
*/
export function noteLearnPoints(n: Pick<ScoutResearchNote, "learn_points" | "summary">): string[] {
if (n.learn_points && n.learn_points.length > 0) {
return n.learn_points.slice(0, 5);
}
const raw = (n.summary || "").trim();
if (!raw) return [];
const byNum = raw
.split(/(?=\d)|(?=\d\.)|(?=)/)
.map((s) => s.replace(/^\d+[.]\s*/, "").replace(/^\s*/, "").trim())
.filter((s) => s.length >= 8 && s.length <= 80);
if (byNum.length >= 2) return byNum.slice(0, 4);
const bySentence = raw
.split(/[。!?\n]+/)
.map((s) => s.trim())
.filter((s) => s.length >= 8);
return bySentence.slice(0, 3);
}
export function noteReplyHooks(n: Pick<ScoutResearchNote, "reply_hooks">): string[] {
return (n.reply_hooks || []).filter(Boolean).slice(0, 3);
}
/**
* mock + + + + URL
* core adjacent
*/
export async function mockWebResearch(query: string): Promise<ResearchHit[]> {
await mockDelay(650);
const q = query.trim() || "主題";
const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic";
const pages: Array<Omit<ResearchHit, "id">> = [
{
title: `${q}:常見迷思與實際差異`,
snippet: `多數人討論「${q}」時會忽略使用情境,只看行銷字。`,
learn_points: [
`「溫和/天然」要對應使用頻率與膚況,不是萬能標籤`,
`別只看成分表第一行,香精、防腐與殘留感也會刺`,
`先寫自己的痛點句,再對規格,比問「哪個最好」準`,
],
reply_hooks: [
`先確認是刺鼻、刺癢還是洗不乾淨,三種解法不太一樣`,
`無香也不等於一定不刺激,可以一起對成分表看`,
],
summary: [
`本篇整理「${q}」在公開討論中的三個常見誤區:`,
`把「溫和/天然」當萬能標籤;只比成分表第一行;聽完業配再決策。`,
`建議先寫下痛點句(刺鼻、刺癢、洗不乾淨),再回頭對規格。`,
].join(""),
url: pageUrl(SOURCES[0]!.host, `${slug}-myths`),
source_label: SOURCES[0]!.label,
tier: "core",
},
{
title: `專家觀點:如何評估 ${q}`,
snippet: `可從安全性、長期成本、真實回饋三維度評估。`,
learn_points: [
`優先問:刺激來源能不能排除、有沒有可驗證使用情境`,
`敏感或長時間接觸:先收集失敗案例,不要只看成功廣告`,
`可操作三問:什麼會變糟?多久改善?有無生活替代?`,
],
reply_hooks: [
`你試過之後是「當下就刺」還是「用幾天後才不舒服」?`,
`若目標是敏感肌,失敗案例通常比業配文更有參考價值`,
],
summary: [
`科普向文章:評估「${q}」時優先看刺激來源與使用情境。`,
`問句可直接當 Threads 接話鉤子。`,
].join(""),
url: pageUrl(SOURCES[1]!.host, `${slug}-howto`),
source_label: SOURCES[1]!.label,
tier: "core",
},
{
title: `論壇實測串:${q} 用過的人怎麼說`,
snippet: `公開討論裡「實際用過」的留言互動通常高於純規格文。`,
learn_points: [
`高頻抱怨:一用就刺鼻/刺癢、洗完有殘留、假無香`,
`正向經驗多來自小範圍試用、對照香精欄、接受磨合期`,
`海巡關鍵語:真的無香嗎、有人也刺痛嗎、求非業配`,
],
reply_hooks: [
`也有人一用就刺鼻,你是碰到味道還是接觸後刺癢?`,
`論壇裡「求非業配」串通常比較敢講失敗經驗`,
],
summary: [
`彙整論壇約 40 則心得:抱怨集中在刺鼻、殘留、假無香。`,
`回覆時先共感具體症狀,再輕提解法。`,
].join(""),
url: pageUrl(SOURCES[3]!.host, `f-mood-${slug}-reviews`),
source_label: SOURCES[3]!.label,
tier: "adjacent",
},
{
title: `${q} 對照表與選購清單(摘要)`,
snippet: `把規格拆成痛點對照,比堆疊功能點更容易說服人。`,
learn_points: [
`拆成:刺激源、使用步驟、價格帶、適合誰/不適合誰`,
`適合「已明確知道自己的雷」的人,不適合亂槍打鳥`,
`對方痛點講清楚前,先不要急著丟產品連結`,
],
reply_hooks: [
`你比較在意「完全無味」還是「接觸後不刺」?兩個規格不一樣`,
`若你已經知道自己的雷,對照表會比推薦清單好用`,
],
summary: [
`清單文把「${q}」拆成刺激源、步驟、價格、適合對象。`,
`置入啟發:先確認痛點再決定要不要帶產品。`,
].join(""),
url: pageUrl(SOURCES[2]!.host, `${slug}-checklist`),
source_label: SOURCES[2]!.label,
tier: "adjacent",
},
];
return pages.map((p) => ({ ...p, id: newId("hit") }));
}
export function formatResearchInsert(hits: ResearchHit[]): string {
if (!hits.length) return "";
return (
"\n\n——\n補充資料\n" +
hits
.map((h, i) => {
const points = h.learn_points?.length
? h.learn_points.map((p) => `· ${p}`).join("\n")
: h.summary || h.snippet;
const link = formatResearchLink(h.url, h.source_label);
return `${i + 1}. ${h.title}\n${points}\n${link.label} · ${link.host}`;
})
.join("\n\n")
);
}
function relationForTier(tier: ScoutResearchTier, title: string): ScoutKnowledgeRelation {
if (title.includes("迷思")) return "myth";
if (title.includes("對照") || title.includes("選購")) return "contrast";
if (tier === "core") return "solves_pain";
if (tier === "adjacent") return "nearby_scene";
return "background";
}
/** ResearchHit → 海巡功課用的知識節點 */
export function researchHitsToScoutNotes(hits: ResearchHit[]): ScoutResearchNote[] {
return hits.map((h) => {
const tier = h.tier || "core";
const summary = (h.summary || h.snippet || "").trim();
const learn_points =
h.learn_points && h.learn_points.length > 0
? h.learn_points
: noteLearnPoints({ summary, learn_points: undefined });
return {
id: h.id,
title: h.title,
summary,
learn_points,
reply_hooks: h.reply_hooks?.length ? h.reply_hooks : undefined,
url: h.url,
source_label: h.source_label,
keywords: extractKeywordsFromNote(h.title, h.summary || h.snippet),
tier,
relation: relationForTier(tier, h.title),
};
});
}
function extractKeywordsFromNote(title: string, body: string): string[] {
const blob = `${title} ${body}`;
const candidates = [
"無香",
"敏感肌",
"刺鼻",
"刺癢",
"香精",
"成分表",
"溫和",
"實測",
"非業配",
"插座",
"第三空間",
"久坐",
];
const found = candidates.filter((k) => blob.includes(k));
const head = title.split(/[:]/)[0]?.trim();
if (head && head.length >= 2 && head.length <= 16 && !found.includes(head)) {
found.unshift(head.slice(0, 12));
}
return [...new Set(found)].slice(0, 5);
}
/**
*
* adjacent broad
*/
export async function mockExpandKnowledgePages(
terms: string[],
contextLabel?: string,
): Promise<ScoutResearchNote[]> {
await mockDelay(350);
const take = terms.filter(Boolean).slice(0, 6);
return take.map((term, i) => {
const src = SOURCES[(i + 2) % SOURCES.length]!;
const ctx = contextLabel ? `(對齊 ${contextLabel}` : "";
const tier: ScoutResearchTier = i < 2 ? "adjacent" : "broad";
if (tier === "broad") {
return {
id: newId("ek"),
title: `背景:${term} 的更大討論脈絡${ctx}`,
summary: `較廣角閱讀:「${term}」在生活/消費文化裡如何被框定。`,
learn_points: [
`${term}」常被包進更大的生活/消費敘事,不只是單點規格`,
`用途是聽懂對方從哪個場景開講,不是立刻給選品答案`,
`語氣很散時:先接背景一句,再收斂到具體痛點`,
],
reply_hooks: [
`聽起來你是從「${term}」這條線在煩,我先對一下場景`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "background",
};
}
return {
id: newId("ek"),
title: `周邊:${term} 為什麼常被一起談${ctx}`,
summary: `針對關鍵語「${term}」的延伸摘要。`,
learn_points: [
`社群常把「${term}」和相鄰痛點綁在一起講`,
`對方未必搜產品名,而是用症狀/場景說話`,
`可帶走:用對方的詞接話 → 先釐清情境 → 再談規格`,
],
reply_hooks: [
`很多人提到「${term}」時,其實在講隔壁的痛,你是哪一種?`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "nearby_scene",
};
});
}
export function groupNotesByTier(
notes: ScoutResearchNote[],
): { tier: ScoutResearchTier; notes: ScoutResearchNote[] }[] {
const buckets: Record<ScoutResearchTier, ScoutResearchNote[]> = {
core: [],
adjacent: [],
broad: [],
};
for (const n of notes) {
const t = n.tier || "adjacent";
buckets[t].push(n);
}
return (["core", "adjacent", "broad"] as ScoutResearchTier[])
.filter((t) => buckets[t].length > 0)
.map((tier) => ({ tier, notes: buckets[tier] }));
}

View File

@ -0,0 +1,242 @@
import type { BrandProduct, ScoutRunBrief, ScoutScanContext } from "../domain/types";
/** 簡單相關詞表:有命中才擴,避免假圖譜 */
const RELATED: Record<string, string[]> = {
: ["香精過敏", "刺鼻", "低敏洗劑"],
: ["換季刺癢", "成分表", "溫和"],
: ["無香", "刺鼻", "過敏"],
: ["無香", "香精"],
: ["洗淨力", "殘留", "寶寶衣物"],
: ["嬰幼兒", "溫和", "洗不乾淨"],
: ["不限時", "筆電", "久坐"],
: ["第三空間", "安靜", "插座"],
: ["插座", "工作座位"],
: ["換季", "無香", "刺癢"],
: ["敏感肌", "換季", "成分"],
: ["咖啡廳", "插座", "久坐"],
: ["去哪", "人少", "不限時"],
};
const STOP = new Set([
"的",
"了",
"在",
"是",
"我",
"有",
"和",
"就",
"不",
"人",
"都",
"一",
"這",
"次",
"想",
"找",
"海",
"巡",
"主題",
"關於",
"可以",
"什麼",
"為",
"或",
"與",
"到",
"會",
"被",
"讓",
]);
function expandFromSeeds(seeds: string[]): string[] {
const out: string[] = [];
const seen = new Set(seeds.map((s) => s.toLowerCase()));
for (const s of seeds) {
const key = Object.keys(RELATED).find((k) => s.includes(k) || k.includes(s));
if (!key) continue;
for (const r of RELATED[key] || []) {
if (seen.has(r.toLowerCase())) continue;
seen.add(r.toLowerCase());
out.push(r);
if (out.length >= 6) return out;
}
}
return out;
}
/** 從意圖粗抽關鍵詞mock非 NLP */
export function extractIntentTerms(intent: string): string[] {
const raw = intent
.replace(/[,。!?、;:\s\n\r#@「」『』()()[\]{}]/g, " ")
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 2 && !STOP.has(s));
// 也抓 RELATED 鍵是否出現在全文
const hits: string[] = [];
for (const k of Object.keys(RELATED)) {
if (intent.includes(k) && !hits.includes(k)) hits.push(k);
}
const merged = [...hits, ...raw];
const seen = new Set<string>();
const out: string[] = [];
for (const t of merged) {
const key = t.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(t);
if (out.length >= 8) break;
}
return out;
}
export function buildScoutScanContext(
brandId: string,
products: BrandProduct[],
): ScoutScanContext {
const pains = [...new Set(products.flatMap((p) => p.pain_points).filter(Boolean))];
const tags = [...new Set(products.flatMap((p) => p.match_tags).filter(Boolean))];
const seeds = [...pains, ...tags, ...products.map((p) => p.label)];
const expand_terms = expandFromSeeds(seeds);
return {
brand_id: brandId,
product_ids: products.map((p) => p.id),
pains: pains.slice(0, 8),
tags: tags.slice(0, 12),
expand_terms,
};
}
/**
* + brief
* purpose=activity product product theme
*/
export function prepareScoutBrief(opts: {
intent: string;
brandId?: string | null;
product?: BrandProduct | null;
purpose?: "value" | "activity";
}): ScoutRunBrief {
const intent = opts.intent.trim();
if (!intent) throw new Error("請先寫這次想找什麼/關鍵字");
const intentTerms = extractIntentTerms(intent);
const product = opts.product || null;
const purpose = opts.purpose || "value";
// 活躍度:只吃關鍵字,輕量、不強制功課感
if (purpose === "activity") {
const focus = intentTerms.length ? intentTerms : [intent.slice(0, 20)];
const periphery = expandFromSeeds(focus).slice(0, 4);
const scan_terms = [...new Set([...focus, ...periphery])].slice(0, 10);
const theme_label = `活躍 · ${intent.slice(0, 28)}${intent.length > 28 ? "…" : ""}`;
const theme_key = ["activity", "", intent.slice(0, 48)].join("|");
return {
intent,
mode: "activity",
brand_id: opts.brandId || null,
product_id: null,
product_label: null,
pains: focus.slice(0, 4),
tags: focus.slice(0, 8),
periphery,
scan_terms,
theme_key,
theme_label,
response_stance:
"短回、自然、有溫度;目的是活躍與互動率,不硬銷、不長文說教。可接一句同感或小問題。",
};
}
if (product) {
const pains = [...(product.pain_points || [])].filter(Boolean);
const tags = [...(product.match_tags || [])].filter(Boolean);
// 意圖與產品語境對齊:意圖詞若與痛/標籤重疊優先
const aligned = intentTerms.filter(
(t) =>
pains.some((p) => p.includes(t) || t.includes(p)) ||
tags.some((g) => g.includes(t) || t.includes(g)) ||
(product.label && (product.label.includes(t) || t.includes(product.label))),
);
const periphery = expandFromSeeds([...pains, ...tags, ...intentTerms, product.label]);
const scan_terms = [
...new Set([
...aligned,
...pains.slice(0, 4),
...tags.slice(0, 4),
...intentTerms.slice(0, 3),
...periphery.slice(0, 3),
]),
].slice(0, 12);
const theme_label = product.label;
const theme_key = ["product", product.id, intent.slice(0, 48)].join("|");
return {
intent,
mode: "product",
brand_id: opts.brandId || product.brand_id,
product_id: product.id,
product_label: product.label,
pains: pains.slice(0, 8),
tags: tags.slice(0, 10),
periphery,
scan_terms,
theme_key,
theme_label,
placement_note:
product.product_context?.trim() ||
`共感對方痛點後,輕帶「${product.label}」使用情境(勿硬銷)。`,
};
}
// 主題模式
const focus = intentTerms.length ? intentTerms : [intent.slice(0, 16)];
const periphery = expandFromSeeds(focus);
const scan_terms = [...new Set([...focus, ...periphery])].slice(0, 12);
const theme_label = intent.slice(0, 36) + (intent.length > 36 ? "…" : "");
const theme_key = ["theme", "", intent.slice(0, 48)].join("|");
return {
intent,
mode: "theme",
brand_id: opts.brandId || null,
product_id: null,
product_label: null,
pains: focus.slice(0, 4), // 主題焦點展示
tags: focus.slice(0, 8),
periphery,
scan_terms,
theme_key,
theme_label,
response_stance: "接話、分享經驗、可留問句;不硬銷、不提產品連結。",
};
}
/** mock 命中正文模板 */
export function mockHitTextsForTerm(
term: string,
mode: "product" | "theme" | "activity",
productLabel?: string | null,
): string[] {
if (mode === "product") {
return [
`有人也被「${term}」搞到很煩嗎?求真正用過的經驗,不要業配腔。`,
`最近一直卡在${term},試了幾個都不對…有人有務實建議嗎?`,
productLabel
? `想問有沒有比較溫和的解法(想到${term}),不一定要貴的。`
: `關於「${term}」想聽真實心得。`,
];
}
if (mode === "activity") {
return [
`隨便聊聊:「${term}」你們最近有感嗎?`,
`路過刷到「${term}」,想聽一句真心話就好。`,
`今天就想跟「${term}」相關的人講兩句,有人也在嗎?`,
];
}
return [
`最近在想「${term}」這件事,有人也有興趣聊聊嗎?`,
`有人在跟「${term}」相關的坑嗎?想聽故事。`,
`週末如果聊「${term}」,你們會先問什麼?`,
];
}

View File

@ -0,0 +1,239 @@
import type { Persona, PersonaDraftFields, StyleDimKey, StyleDimension } from "../domain/types";
import { mockDelay } from "./mockAi";
import {
emptyDraftFields,
emptyGuard,
normalizePersona,
serializeDraftText,
} from "./personaPrompt";
import { nowUnixNano } from "./time";
export function splitSamples(raw: string): string[] {
return raw
.split(/\n\s*---\s*\n|\n{3,}/)
.map((s) => s.trim())
.filter((s) => s.length >= 10)
.slice(0, 12);
}
function pickEvidence(samples: string[], max = 2): string[] {
return samples
.map((s) => (s.length > 36 ? `${s.slice(0, 36)}` : s))
.slice(0, max);
}
function inferTone(blob: string): string {
if (/哈哈|笑死|哭|靠|真的假的/.test(blob)) return "輕鬆吐槽、情緒外露";
if (/建議|成分|數據|對比|規格/.test(blob)) return "務實、有依據、少廢話";
if (/懂|抱抱|辛苦|没关系|沒關係|一起/.test(blob)) return "共感、溫柔、像朋友";
return "口語親近、不端著";
}
function inferHooks(blob: string): string {
if (/|\?/.test(blob)) return "用真心疑問開場,邀請別人補經驗";
if (/有人|大家|求/.test(blob)) return "先丟痛點再求推坑/經驗";
return "先講自己卡關的情境,再拋問題";
}
export type AnalyzeSource =
| { kind: "manual"; label?: string }
| { kind: "benchmark"; username: string };
/**
* Mock Threads Playwright / API
*/
export async function mockScrapePublicPosts(username: string): Promise<string[]> {
const handle = username.replace(/^@/, "").trim().toLowerCase();
if (!handle || handle.length < 2) {
throw new Error("請輸入有效的 Threads username不含網址");
}
if (/[\s/]/.test(handle)) {
throw new Error("username 請只填帳號,例如 ultralab_tw");
}
// 模擬網路延遲(爬公開頁)
await mockDelay(900);
// 依 username 長出可辨識差異的假樣本,讓分析結果不像隨機
const isFun = /fun|meme|laugh|吐|梗/.test(handle);
const isPro = /lab|pro|tech|care|skin|official/.test(handle);
if (isFun) {
return [
`不是我不想研究,是 @${handle} 看規格表看到想睡…有人也這樣嗎?`,
`講真的上次踩雷之後我都先問「實際用起來」再下單,規格文先放旁邊。`,
`笑死剛看到有人寫「無痛入手」,結果後來留言區全是後悔文。`,
`你們週末都在幹嘛,我還在跟自己的購物車吵架。`,
];
}
if (isPro) {
return [
`若你在意耐用,我會先看使用情境再對規格;同樣標示差很多。`,
`建議把需求拆成:頻率、膚況/環境、預算三欄,再對產品會比較準。`,
`成分表先找會刺鼻或致敏的那幾欄;有疑慮再問實際使用者。`,
`對比兩款時不要只看行銷字,看「你會每天碰到的那一段流程」。`,
];
}
return [
`有時候真的會卡在「不知道問誰」——後來我改成先寫自己的使用情境。`,
`懂那種越研究越焦慮的感覺。我通常先問有沒有實際用過、會不會踩雷。`,
`大家如果有推的,最好附一句為什麼,比空推一個名字有用。`,
`我自己目前是先抓痛點再對規格,不是一開始就掃整張規格表。`,
`你們呢?有類似經驗也可以講,我想整理給之後的自己。`,
];
}
function analyzeFromSamples(
persona: Persona,
samples: string[],
source: AnalyzeSource,
): Persona {
if (samples.length < 2) {
throw new Error("樣本不足(至少 2 則公開貼文/參考段)");
}
const blob = samples.join("\n");
const evidence = pickEvidence(samples);
const tone = inferTone(blob);
const hooks = inferHooks(blob);
const base = normalizePersona(persona);
const label =
source.kind === "benchmark"
? `@${source.username.replace(/^@/, "")}`
: source.label?.trim() || "手動貼文";
const draft: PersonaDraftFields = {
...emptyDraftFields(),
identity: base.style.draft.identity || base.name || "生活觀察者",
tone,
audience: base.style.draft.audience || "同溫層、會在 Threads 求經驗的人",
hooks,
languageFingerprint: /有時候|後來|其實|講真的/.test(blob)
? "常用「有時候/後來/其實」當轉折"
: "短句、口語、少成語",
rhythm: blob.includes("\n") ? "24 個短段落,段間空行" : "一段講完再補一句問句",
punctuation: /…|\.\.\./.test(blob) ? "愛用省略號與問號" : "逗號與問號為主,少驚嘆",
contentPatterns: "情境 → 感受或觀察 → 輕問一句",
knowledgeTranslation: "先講使用情境,再講重點,不丟術語牆",
ctaStyle: "自然邀留言(「你們呢」「有人也…嗎」),不命令",
examples: samples[0].slice(0, 80),
avoid: base.style.draft.avoid || "硬銷、條列教學、客服腔、假裝中立實則業配",
};
const dim = (summary: string, ev = evidence): StyleDimension => ({ summary, evidence: ev });
const dimensions: Partial<Record<StyleDimKey, StyleDimension>> = {
d1Tone: dim(tone),
d2Structure: dim("開場情境 → 中段補充 → 結尾提問或輕 CTA"),
d3Interaction: dim("接住對方情緒後再給觀點;回覆用提問延續對話"),
d4Topics: dim(base.brief || `生活經驗(樣本:${label}`, evidence.slice(0, 1)),
d5Rhythm: dim(draft.rhythm),
d6Visual: dim(draft.punctuation + ";少 emoji 或 01 個"),
d7Conversion: dim(draft.ctaStyle),
d8Risk: dim(draft.avoid, []),
};
const draftText = serializeDraftText(draft);
const avoidList = draft.avoid
.split(/[、,]/)
.map((s) => s.trim())
.filter(Boolean);
return {
...base,
brief: base.brief || `${tone};對「${draft.audience}」說話`,
status: "ready",
voice: draft.tone,
notes: base.brief,
style: {
dimensions,
draft,
draftText,
source: source.kind === "benchmark" ? "benchmark" : "manual",
benchmarkUsername: source.kind === "benchmark" ? source.username.replace(/^@/, "") : undefined,
sourceLabel: source.kind === "manual" ? label : undefined,
sampleCount: samples.length,
samplePreviews: samples.slice(0, 5).map((s) => (s.length > 100 ? `${s.slice(0, 100)}` : s)),
analyzedAt: nowUnixNano(),
},
guard: {
...emptyGuard(),
...base.guard,
avoid: avoidList.length ? avoidList : emptyGuard().avoid,
banAiTone: true,
maxChars: base.guard?.maxChars || 280,
},
};
}
/**
*
*/
export async function mockAnalyzePersonaFromText(
persona: Persona,
rawText: string,
sourceLabel?: string,
): Promise<Persona> {
await mockDelay(500);
const samples = splitSamples(rawText);
if (samples.length < 2) {
throw new Error("請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字");
}
return analyzeFromSamples(persona, samples, { kind: "manual", label: sourceLabel });
}
/**
* mock
*/
export async function mockAnalyzePersonaFromAccount(
persona: Persona,
username: string,
): Promise<{ persona: Persona; posts: string[] }> {
const handle = username.replace(/^@/, "").trim();
const posts = await mockScrapePublicPosts(handle);
// 分析階段
await mockDelay(600);
const next = analyzeFromSamples(persona, posts, { kind: "benchmark", username: handle });
return { persona: next, posts };
}
export function buildSeedReadyStyle(opts: {
identity: string;
tone: string;
audience: string;
examples: string;
avoid?: string;
}): Persona["style"] {
const draft: PersonaDraftFields = {
identity: opts.identity,
tone: opts.tone,
audience: opts.audience,
hooks: "先丟真實卡關,再問一句",
languageFingerprint: "口語、短句、偶爾「其實/有時候」",
rhythm: "23 短段,段間空行",
punctuation: "問號收尾,少驚嘆",
contentPatterns: "痛點 → 自己經驗 → 輕問",
knowledgeTranslation: "用生活例子講,不丟規格表",
ctaStyle: "「有人也這樣嗎」",
examples: opts.examples,
avoid: opts.avoid || "硬廣、說教、AI 腔",
};
const dim = (summary: string): StyleDimension => ({ summary, evidence: [opts.examples.slice(0, 40)] });
return {
dimensions: {
d1Tone: dim(opts.tone),
d2Structure: dim("情境開場 → 經驗 → 提問"),
d3Interaction: dim("先接話再補觀點"),
d4Topics: dim("生活選品與真實經驗"),
d5Rhythm: dim(draft.rhythm),
d6Visual: dim("換行分段,少 emoji"),
d7Conversion: dim(draft.ctaStyle),
d8Risk: dim(draft.avoid),
},
draft,
draftText: serializeDraftText(draft),
source: "seed",
sampleCount: 3,
analyzedAt: nowUnixNano(),
};
}

View File

@ -0,0 +1,118 @@
import { mockDelay } from "./mockAi";
import { nowUnixNano } from "./time";
import type { ExternalThreadTarget } from "../domain/types";
/** 正規化 Threads permalink比對方案用 */
export function normalizeThreadUrl(raw: string): string {
const trimmed = (raw || "").trim();
if (!trimmed) return "";
try {
const withProto = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const u = new URL(withProto);
const host = u.hostname.replace(/^www\./i, "").toLowerCase();
// threads.com / threads.net 等
let path = u.pathname.replace(/\/+$/, "") || "";
return `https://${host}${path}`;
} catch {
return trimmed.split("?")[0].split("#")[0].replace(/\/+$/, "");
}
}
/**
* shortcode /
*
* - https://www.threads.net/@user/post/CODE
* - https://www.threads.com/@user/post/CODE
* - https://www.threads.net/t/CODE (較少見)
*/
export function parseThreadsPermalink(raw: string): {
url: string;
shortcode?: string;
author_username?: string;
} | null {
const input = (raw || "").trim();
if (!input) return null;
let href = input;
if (!/^https?:\/\//i.test(href)) href = `https://${href}`;
let u: URL;
try {
u = new URL(href);
} catch {
return null;
}
const host = u.hostname.replace(/^www\./i, "").toLowerCase();
if (!host.includes("threads.")) {
// 仍允許貼完整 URL 當目標,但提示非標準
if (!/threads/i.test(input)) return null;
}
const path = u.pathname;
const postMatch = path.match(/\/@([^/]+)\/post\/([^/?#]+)/i);
if (postMatch) {
const author = postMatch[1]!;
const code = postMatch[2]!;
const url = normalizeThreadUrl(`https://www.threads.net/@${author}/post/${code}`);
return { url, shortcode: code, author_username: author };
}
const tMatch = path.match(/\/t\/([^/?#]+)/i);
if (tMatch) {
const code = tMatch[1]!;
const url = normalizeThreadUrl(`https://www.threads.net/t/${code}`);
return { url, shortcode: code };
}
// 任意 threads 連結:至少當目標 URL
if (host.includes("threads.")) {
return { url: normalizeThreadUrl(href) };
}
return null;
}
const MOCK_SNIPPETS = [
"最近有人推這套流程嗎?我試了兩週還在卡關…",
"真心問:週末還有哪間不擠、有插座的店?",
"敏感肌用這款會刺痛嗎?求真實心得不要業配腔。",
"第一次自己發串,回覆要怎麼接才不像機器人?",
"有人用過這招嗎?留言區好像很吵。",
];
function mockPreviewForCode(code: string, author?: string): string {
let h = 0;
const s = `${code}|${author || ""}`;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
const base = MOCK_SNIPPETS[h % MOCK_SNIPPETS.length]!;
return author ? `@${author}${base}` : base;
}
/**
* mock Threads
* live resolve media id +
*/
export async function mockResolveThreadLink(rawUrl: string): Promise<ExternalThreadTarget> {
await mockDelay(450);
const parsed = parseThreadsPermalink(rawUrl);
if (!parsed) {
throw new Error("請貼有效的 Threads 連結(例如 https://www.threads.net/@user/post/…)");
}
const shortcode = parsed.shortcode || "unknown";
return {
url: parsed.url,
raw_url: rawUrl.trim(),
shortcode,
author_username: parsed.author_username,
text_preview: mockPreviewForCode(shortcode, parsed.author_username),
// mock 假 numeric id方便之後對齊 API
media_id: `mock_${shortcode.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12) || "post"}`,
resolved_at: nowUnixNano(),
};
}
export function externalTargetKey(target: Pick<ExternalThreadTarget, "url"> | string): string {
const url = typeof target === "string" ? target : target.url;
return normalizeThreadUrl(url);
}

View File

@ -0,0 +1,205 @@
import type { Persona, TrendItem, TrendKind } from "../domain/types";
import { newId } from "./id";
import { mockDelay, mockGenerateInspiration } from "./mockAi";
import { nowUnixNano } from "./time";
const EXTRA_POOL: Omit<TrendItem, "id" | "observed_at" | "heat">[] = [
{
kind: "threads_tag",
label: "#辦公室零食",
summary: "午休話題回溫;「同事搶食」與「健康零食」兩極討論。",
keywords: ["零食", "辦公室", "午休"],
samples: ["抽屜裡永遠少一包,有人也是嗎?"],
source_label: "Threads 熱門標籤",
},
{
kind: "threads_tag",
label: "#早起失敗",
summary: "作息自我吐槽串;適合輕鬆人設接「前一晚到底在幹嘛」。",
keywords: ["早起", "賴床", "作息"],
samples: ["鬧鐘響了七次還是起不來,求物理方法。"],
source_label: "Threads 熱門標籤",
},
{
kind: "web_keyword",
label: "低敏洗面乳",
summary: "成分表關鍵字搜索上升;真實「用完刺癢」留言比評測更有熱度。",
keywords: ["洗面乳", "低敏", "成分"],
samples: ["標榜低敏還是刺,有人也遇過嗎?"],
source_label: "網搜熱門關鍵字",
},
{
kind: "web_keyword",
label: "筆電咖啡廳 台北",
summary: "在地搜尋熱;「不限時 + 插座密度」是決策關鍵字。",
keywords: ["咖啡廳", "筆電", "台北", "不限時"],
samples: ["文山區能坐下午的店還有嗎?"],
source_label: "網搜熱門關鍵字",
},
{
kind: "news",
label: "週末賽事/轉播話題",
summary: "運動時事帶流量;非粉也可用「不懂但陪看」角度切入生活感。",
keywords: ["賽事", "週末", "陪看"],
samples: ["不懂規則但被氣氛感染,你們會看嗎?"],
source_label: "最近熱門時事",
},
{
kind: "news",
label: "暑期/開學倒數",
summary: "季節節點;家長與學生族群討論度上升,可做輕量生活開問。",
keywords: ["暑假", "開學", "作息"],
samples: ["作息還沒調回來,只剩三天怎麼救?"],
source_label: "最近熱門時事",
},
];
function jitterHeat(heat: number): number {
const delta = Math.floor(Math.random() * 17) - 8;
return Math.max(35, Math.min(99, heat + delta));
}
function pickSamples(samples: string[]): string[] {
if (samples.length <= 1) return samples;
const shuffled = [...samples].sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.min(2, shuffled.length));
}
/** 刷新:抖動熱度、更新時間;有機會換入池中新標 */
export async function mockRefreshTrends(
current: TrendItem[],
kind: TrendKind | "all" = "all",
): Promise<TrendItem[]> {
await mockDelay(700);
const now = nowUnixNano();
let next = current.map((t) => ({
...t,
heat: jitterHeat(t.heat),
samples: pickSamples(t.samples.length ? t.samples : ["(刷新後暫無新片段)"]),
observed_at: now - Math.floor(Math.random() * 4) * 3_600_000_000_000,
}));
if (Math.random() > 0.35) {
const used = new Set(next.map((t) => t.label));
const candidates = EXTRA_POOL.filter((p) => !used.has(p.label));
const pool =
kind === "all" ? candidates : candidates.filter((p) => p.kind === kind);
if (pool.length) {
const pick = pool[Math.floor(Math.random() * pool.length)]!;
const injected: TrendItem = {
...pick,
id: newId("trend"),
heat: 55 + Math.floor(Math.random() * 40),
observed_at: now,
};
// drop lowest heat of same kind filter scope
const sortable = kind === "all" ? next : next.filter((t) => t.kind === kind);
const drop = [...sortable].sort((a, b) => a.heat - b.heat)[0];
if (drop) next = next.filter((t) => t.id !== drop.id);
next = [injected, ...next];
}
}
return next.sort((a, b) => b.heat - a.heat);
}
/** 上網搜:回傳與 query 相關的「熱關鍵字 / 時事」假資料 */
export async function mockSearchTrends(query: string): Promise<TrendItem[]> {
await mockDelay(800);
const q = query.trim() || "熱門";
const now = nowUnixNano();
const results: TrendItem[] = [
{
id: newId("trend"),
kind: "web_keyword",
label: `${q} 是什麼`,
summary: `網搜「${q}」相關:解釋向與清單文並存;真實經驗文互動通常高於純定義。`,
heat: 70 + Math.floor(Math.random() * 20),
keywords: [q, `${q} 推薦`, `${q} 心得`],
samples: [`最近一直刷到「${q}」,有人能用一句話解釋嗎?`],
source_label: "網搜熱門關鍵字",
observed_at: now,
},
{
id: newId("trend"),
kind: "web_keyword",
label: `${q} 踩雷`,
summary: `負向關鍵字組合熱度偏高;適合「避雷條件」開問,比直接推品安全。`,
heat: 60 + Math.floor(Math.random() * 25),
keywords: [q, "踩雷", "注意"],
samples: [`${q} 前一定要看什麼?求過來人。`],
source_label: "網搜熱門關鍵字",
observed_at: now - 2_000_000_000_000,
},
{
id: newId("trend"),
kind: "news",
label: `近日與「${q}」相關討論`,
summary: `時事/社群交叉討論:可從生活感受切入,再輕帶你的專業視角。`,
heat: 55 + Math.floor(Math.random() * 30),
keywords: [q, "近況", "討論"],
samples: [`大家都在聊 ${q},我卻還停在…`],
source_label: "最近熱門時事",
observed_at: now - 5_000_000_000_000,
},
{
id: newId("trend"),
kind: "threads_tag",
label: `#${q.replace(/\s+/g, "")}`,
summary: `Threads 上與「${q}」相近標籤正在累積回覆;開場宜具體情境。`,
heat: 65 + Math.floor(Math.random() * 28),
keywords: [q],
samples: [`標了 #${q.replace(/\s+/g, "")} 求真實經驗,別業配。`],
source_label: "Threads 熱門標籤",
observed_at: now - 1_000_000_000_000,
},
];
return results.sort((a, b) => b.heat - a.heat);
}
export function sparkCopyFromTrend(
trend: TrendItem,
persona?: Persona | null,
): { title: string; hook: string; angle: string } {
const base = mockGenerateInspiration(trend.label.replace(/^#/, ""), persona);
const sample = trend.samples[0] || trend.summary;
return {
title: `${trend.label} · 切入`,
hook:
trend.kind === "threads_tag"
? `順著 ${trend.label} 的熱度:用一句貼近「${sample.slice(0, 28)}…」的開問,比空泛跟風標籤好回。`
: trend.kind === "news"
? `時事「${trend.label}」→ 落到日常:${base.hook}`
: `搜尋熱度在「${trend.label}」:${base.hook}`,
angle: [
trend.summary,
`關鍵字:${trend.keywords.slice(0, 4).join("、")}`,
base.angle,
].join(" "),
};
}
export function trendKindLabel(kind: TrendKind): string {
switch (kind) {
case "threads_tag":
return "Threads";
case "web_keyword":
return "網搜";
case "news":
return "時事";
}
}
/** 帶進寫一則的正文:乾淨、無 meta 尾巴 */
export function seedTextFromTrend(trend: TrendItem, sampleIndex = 0): string {
const sample = trend.samples[sampleIndex] || trend.samples[0] || "";
if (sample) return sample;
return `${trend.label}\n\n${trend.summary}`;
}
/** 列表次行:短摘要 */
export function trendListMeta(trend: TrendItem): string {
const short =
trend.summary.length > 42 ? `${trend.summary.slice(0, 42)}` : trend.summary;
return `${trendKindLabel(trend.kind)} · ${trend.heat} · ${short}`;
}

View File

@ -0,0 +1,47 @@
import type { Persona, ViralAnalysis } from "../domain/types";
import { mockDelay } from "./mockAi";
import { toneOf } from "./personaPrompt";
export async function mockAnalyzeViral(text: string): Promise<ViralAnalysis> {
await mockDelay(600);
const t = text.trim() || "(空文)";
const hasQ = /|\?/.test(t);
const hasPain = /卡|煩|雷|痛|敏感|不會|怎麼/.test(t);
return {
hooks: hasQ
? "用真心疑問收尾,降低回覆門檻"
: hasPain
? "先丟具體痛點,讀者有代入感"
: "開場用生活情境,不像廣告",
structure: "情境/痛點 → 自己經驗一句 → 邀請補充(或輕 CTA",
emotion: hasPain ? "共感 + 一點焦慮釋放" : "好奇/認同",
copyable: "可複製:短段落、一個明確條件、結尾問句;勿整段抄原文",
risks: "勿保證效果、勿硬廣、勿嘲諷留言者",
summary: `這則之所以有互動潛力:${hasPain ? "痛點具體" : "情境清楚"}${hasQ ? " + 好回的問題" : ""}。改寫時保留結構,換成你的經驗與人設語氣。`,
};
}
export async function mockMimicPost(sourceText: string, persona?: Persona | null): Promise<string> {
await mockDelay(550);
const tone = toneOf(persona);
const clip = sourceText.trim().slice(0, 80) || "某個生活卡關";
const name = persona?.name || "預設";
return [
`(仿寫 · ${name} · ${tone}`,
`最近也卡在類似情況:${clip}${sourceText.length > 80 ? "…" : ""}`,
``,
`我自己目前是先抓「使用情境」再對規格,比較不會越研究越焦慮。`,
`有人也這樣嗎?歡迎補一句你的實際經驗。`,
].join("\n");
}
export function formatViralAnalysis(a: ViralAnalysis): string {
return [
`【為什麼可能爆】${a.summary}`,
`【鉤子】${a.hooks}`,
`【結構】${a.structure}`,
`【情緒】${a.emotion}`,
`【可複製】${a.copyable}`,
`【風險】${a.risks}`,
].join("\n");
}

58
apps/web/src/lib/nav.ts Normal file
View File

@ -0,0 +1,58 @@
export type NavKey = "today" | "crew" | "studio" | "scout" | "outbox" | "jobs" | "brands";
export type NavItem = {
key: NavKey;
path: string;
/** i18n key例如 nav.today */
labelKey: string;
/** 後備中文(無 i18n 時) */
label: string;
en: string;
};
/** 側欄完整導覽 */
export const primaryNav: NavItem[] = [
{ key: "today", path: "/app/today", labelKey: "nav.today", label: "今日", en: "Today" },
{ key: "crew", path: "/app/crew", labelKey: "nav.crew", label: "帳號", en: "Crew" },
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "海巡", en: "Patrol" },
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
{ key: "brands", path: "/app/brands", labelKey: "nav.brands", label: "品牌", en: "Brands" },
];
/** 手機底欄固定 4 格(主流程) */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "scout", "outbox"];
/** 手機底欄「更多」內項目 */
export const mobileDockMoreKeys: NavKey[] = ["crew", "jobs", "brands"];
export function navItemsByKeys(keys: NavKey[]): NavItem[] {
const map = new Map(primaryNav.map((n) => [n.key, n]));
return keys.map((k) => map.get(k)).filter((x): x is NavItem => Boolean(x));
}
export function isNavActive(pathname: string, item: NavItem): boolean {
if (item.path === "/app/today") {
return pathname === "/app" || pathname === "/app/today";
}
return pathname === item.path || pathname.startsWith(`${item.path}/`);
}
/** 目前路徑是否落在「更多」裡的某頁 */
export function isMoreNavActive(pathname: string): boolean {
return navItemsByKeys(mobileDockMoreKeys).some((item) => isNavActive(pathname, item));
}
/** 通知點擊後的落地路徑 */
export function pathForNotification(n: {
kind: string;
ref_type: string;
ref_id?: string;
}): string {
if (n.ref_type === "job" && n.ref_id) return `/app/jobs/${n.ref_id}`;
if (n.ref_type === "outbox" && n.ref_id) return `/app/outbox/${n.ref_id}`;
if (n.kind === "outbox" || n.ref_type === "outbox") return "/app/outbox";
// 任務/系統/無 ref任務中心
return "/app/jobs";
}

View File

@ -0,0 +1,30 @@
/** 列表分頁page 從 1 起) */
export function pageCount(total: number, pageSize: number): number {
const size = Math.max(1, pageSize || 10);
return Math.max(1, Math.ceil(Math.max(0, total) / size) || 1);
}
export function clampPage(page: number, totalPages: number): number {
return Math.max(1, Math.min(Math.max(1, totalPages), page || 1));
}
export function pageSlice<T>(list: T[], page: number, pageSize: number): T[] {
const size = Math.max(1, pageSize || 10);
const pages = pageCount(list.length, size);
const p = clampPage(page, pages);
const start = (p - 1) * size;
return list.slice(start, start + size);
}
export function pageRangeLabel(page: number, pageSize: number, total: number): string {
if (total <= 0) return "0";
const size = Math.max(1, pageSize || 10);
const pages = pageCount(total, size);
const p = clampPage(page, pages);
const from = (p - 1) * size + 1;
const to = Math.min(p * size, total);
return `${from}${to} / ${total}`;
}
export const DEFAULT_PAGE_SIZES = [5, 10, 20, 50] as const;

View File

@ -0,0 +1,276 @@
import type {
Persona,
PersonaDraftFields,
PersonaGuard,
PersonaStyle,
StyleDimKey,
StyleDimension,
} from "../domain/types";
import { nowUnixNano } from "./time";
export const DIM_ORDER: StyleDimKey[] = [
"d1Tone",
"d2Structure",
"d3Interaction",
"d4Topics",
"d5Rhythm",
"d6Visual",
"d7Conversion",
"d8Risk",
];
export const DIM_LABELS: Record<StyleDimKey, string> = {
d1Tone: "D1 語氣人格",
d2Structure: "D2 結構模板",
d3Interaction: "D3 互動方式",
d4Topics: "D4 主題分布",
d5Rhythm: "D5 發文節奏",
d6Visual: "D6 視覺語法",
d7Conversion: "D7 轉換方式",
d8Risk: "D8 風險紅線",
};
export type PersonaPromptMode = "post" | "reply" | "outreach" | "inspire";
export function emptyDraftFields(): PersonaDraftFields {
return {
identity: "",
tone: "",
audience: "",
hooks: "",
languageFingerprint: "",
rhythm: "",
punctuation: "",
contentPatterns: "",
knowledgeTranslation: "",
ctaStyle: "",
examples: "",
avoid: "",
};
}
export function emptyGuard(): PersonaGuard {
return {
avoid: ["硬廣", "客服腔", "條列說教"],
maxChars: 280,
banAiTone: true,
};
}
export function emptyStyle(): PersonaStyle {
return {
dimensions: {},
draft: emptyDraftFields(),
draftText: "",
source: "manual",
sampleCount: 0,
};
}
export function createEmptyPersona(partial?: Partial<Persona> & { id: string; name: string }): Persona {
return {
id: partial?.id || "",
name: partial?.name || "",
brief: partial?.brief || "",
status: partial?.status || "empty",
style: partial?.style || emptyStyle(),
guard: partial?.guard || emptyGuard(),
voice: partial?.voice,
notes: partial?.notes,
};
}
/** 把舊 name/voice/notes 人設升級成三層結構 */
export function normalizePersona(raw: Persona | Record<string, unknown>): Persona {
const r = raw as Partial<Persona> & { voice?: string; notes?: string };
const draft = {
...emptyDraftFields(),
...(r.style?.draft || {}),
};
if (!draft.tone && r.voice) draft.tone = r.voice;
if (!draft.identity && r.name) draft.identity = r.name;
if (!draft.avoid && r.notes) draft.avoid = r.notes;
const style: PersonaStyle = {
dimensions: r.style?.dimensions || {},
draft,
draftText: r.style?.draftText || (r.style ? serializeDraftText(draft) : ""),
source: r.style?.source || "manual",
sampleCount: r.style?.sampleCount || 0,
analyzedAt: r.style?.analyzedAt,
};
if (!style.draftText && (draft.tone || draft.identity)) {
style.draftText = serializeDraftText(draft);
}
const hasFingerprint = Boolean(style.draftText.trim()) || Object.keys(style.dimensions).length > 0;
const status = r.status || (hasFingerprint ? "ready" : "empty");
return {
id: String(r.id || ""),
name: String(r.name || "未命名人設"),
brief: String(r.brief || r.notes || ""),
status,
style,
guard: {
...emptyGuard(),
...(r.guard || {}),
avoid: r.guard?.avoid?.length
? r.guard.avoid
: emptyGuard().avoid,
},
voice: r.voice || draft.tone,
notes: r.notes || r.brief,
};
}
export function serializeDraftText(d: PersonaDraftFields): string {
const lines: string[] = [];
const push = (label: string, value: string) => {
const v = value.trim();
if (v) lines.push(`${label}\n${v}`);
};
push("我是誰", d.identity);
push("語氣", d.tone);
push("對誰說", d.audience);
push("開場鉤子", d.hooks);
push("語言指紋", d.languageFingerprint);
push("節奏", d.rhythm);
push("標點", d.punctuation);
push("內容套路", d.contentPatterns);
push("知識轉譯", d.knowledgeTranslation);
push("CTA", d.ctaStyle);
push("像他會說的話", d.examples);
push("絕不怎麼說", d.avoid);
return lines.join("\n\n");
}
export function toneOf(persona?: Persona | null): string {
if (!persona) return "自然口語";
return persona.style?.draft?.tone || persona.voice || "自然口語";
}
export function isPersonaReady(persona?: Persona | null): boolean {
return persona?.status === "ready" && Boolean(persona.style?.draftText?.trim() || persona.style?.draft?.tone);
}
export function personaOptionLabel(persona: Persona): string {
const tone = toneOf(persona);
const badge = persona.status === "ready" ? "ready" : persona.status === "analyzing" ? "分析中" : "待分析";
return `${persona.name} · ${tone}${badge}`;
}
/**
* block
* mock / live
*/
export function buildPersonaPromptBlock(persona: Persona | null | undefined, mode: PersonaPromptMode): string {
if (!persona) {
return "【人設】尚未選擇。請用台灣繁體、口語、像真人在 Threads 打字。";
}
const p = normalizePersona(persona);
const parts: string[] = [];
// Layer A — who
if (mode === "post" || mode === "inspire" || mode === "outreach") {
const who = [p.name && `名稱:${p.name}`, p.brief && `定位:${p.brief}`, p.style.draft.identity && `角色:${p.style.draft.identity}`]
.filter(Boolean)
.join("\n");
if (who) parts.push(`【人設定位】\n${who}`);
} else {
// reply: light identity
const light = p.style.draft.identity || p.name;
if (light) parts.push(`【人設】你是「${light}」,用自己的語氣回覆。`);
}
// Layer B — how
const draftText = p.style.draftText.trim() || serializeDraftText(p.style.draft);
if (mode === "post") {
if (draftText) parts.push(`【語言指紋與寫作規則】\n${draftText}`);
const d2 = p.style.dimensions.d2Structure?.summary;
const d4 = p.style.dimensions.d4Topics?.summary;
if (d2 || d4) {
parts.push(
`【結構與主題重點】\n${[d2 && `結構:${d2}`, d4 && `主題:${d4}`].filter(Boolean).join("\n")}`,
);
}
} else if (mode === "reply") {
if (draftText) parts.push(`【語言指紋】\n${draftText}`);
const d3 = p.style.dimensions.d3Interaction?.summary;
if (d3) parts.push(`【互動方式】\n${d3}`);
} else if (mode === "outreach") {
const slim = [
p.style.draft.tone && `語氣:${p.style.draft.tone}`,
p.style.draft.avoid && `避免:${p.style.draft.avoid}`,
p.style.draft.languageFingerprint && `用字:${p.style.draft.languageFingerprint}`,
p.style.draft.ctaStyle && `收尾:${p.style.draft.ctaStyle}`,
]
.filter(Boolean)
.join("\n");
if (slim) parts.push(`【外展語氣】\n${slim}`);
else if (draftText) parts.push(`【語言指紋】\n${draftText}`);
} else {
// inspire
const d4 = p.style.dimensions.d4Topics?.summary;
const hooks = p.style.draft.hooks;
const audience = p.style.draft.audience;
parts.push(
`【靈感方向】\n${[
audience && `對誰:${audience}`,
hooks && `鉤子:${hooks}`,
d4 && `主題:${d4}`,
p.style.draft.tone && `語氣:${p.style.draft.tone}`,
]
.filter(Boolean)
.join("\n") || draftText || p.brief}`,
);
}
// Layer C — guard
const avoid = [
...p.guard.avoid,
...(p.style.draft.avoid ? p.style.draft.avoid.split(/[、,]/).map((s) => s.trim()).filter(Boolean) : []),
];
const uniqAvoid = [...new Set(avoid)];
const guardLines = [
uniqAvoid.length ? `禁止:${uniqAvoid.join("、")}` : "",
p.guard.banAiTone ? "禁止 AI 腔/客服腔/「總而言之」「希望這對您有幫助」" : "",
p.guard.maxChars ? `長度約不超過 ${p.guard.maxChars}` : "",
mode === "reply" ? "你是貼文作者本人在回,不要裝粉絲或官方帳號。" : "",
mode === "outreach" ? "不硬廣;像過來人分享經驗,可輕輕帶觀點。" : "",
].filter(Boolean);
if (guardLines.length) parts.push(`【護欄】\n${guardLines.join("\n")}`);
// Light D1 + D8 always when available for post/reply
if (mode === "post" || mode === "reply") {
const d1 = p.style.dimensions.d1Tone?.summary;
const d8 = p.style.dimensions.d8Risk?.summary;
if (d1 || d8) {
parts.push(
`【語氣與禁忌參考】\n${[d1 && `語氣:${d1}`, d8 && `紅線:${d8}`].filter(Boolean).join("\n")}`,
);
}
}
return parts.join("\n\n") || `【人設】${p.name}${toneOf(p)}`;
}
export function markReady(persona: Persona): Persona {
const draftText = persona.style.draftText.trim() || serializeDraftText(persona.style.draft);
return {
...persona,
status: draftText ? "ready" : "empty",
style: {
...persona.style,
draftText,
analyzedAt: persona.style.analyzedAt || nowUnixNano(),
},
voice: persona.style.draft.tone || persona.voice,
};
}
export function dimSummary(persona: Persona, key: StyleDimKey): StyleDimension | undefined {
return persona.style.dimensions[key];
}

View File

@ -0,0 +1,63 @@
import { KEYS } from "../data/mock/keys";
import { newId } from "./id";
import { readJson, writeJson } from "./storage";
import { nowUnixNano } from "./time";
import { PLANS, setMemberPrefs, type PlanId } from "./usageMeter";
/** mock 方案購買(接金流前本機假資料) */
export type PlanPurchase = {
id: string;
uid: string;
plan_id: PlanId;
amount_twd: number;
/** paid 才視為真正買成 */
status: "paid" | "failed" | "pending";
/** 假卡末四碼等 */
mock_ref?: string;
created_at: number;
};
function loadPurchases(): PlanPurchase[] {
return readJson(KEYS.planPurchases, [] as PlanPurchase[]);
}
function savePurchases(list: PlanPurchase[]): void {
writeJson(KEYS.planPurchases, list.slice(0, 200));
}
export function listPlanPurchases(uid: string, limit = 20): PlanPurchase[] {
return loadPurchases()
.filter((p) => p.uid === uid)
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit);
}
/**
* mock
* webhook setMemberPrefs
*/
export function mockCompletePlanPurchase(opts: {
uid: string;
plan_id: PlanId;
mock_ref?: string;
}): PlanPurchase {
const plan = PLANS[opts.plan_id];
if (!plan) throw new Error("未知方案");
const rec: PlanPurchase = {
id: newId("pay"),
uid: opts.uid,
plan_id: opts.plan_id,
amount_twd: plan.price_twd,
status: "paid",
mock_ref: opts.mock_ref || "mock",
created_at: nowUnixNano(),
};
const list = loadPurchases();
list.unshift(rec);
savePurchases(list);
// 只有 paid 才改方案
setMemberPrefs(opts.uid, { plan_id: opts.plan_id });
return rec;
}

View File

@ -0,0 +1,54 @@
import { PLANS, type PlanId } from "./usageMeter";
export type PlanRightsCopy = {
headline: string;
/** 比價卡上 45 條短權益 */
bullets: string[];
/** 結帳頁:你會得到 */
rights: string[];
/** 結帳頁:額度 */
quota: string[];
notes: string[];
};
type TFn = (key: string, params?: Record<string, string | number>) => string;
/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */
export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy {
const p = PLANS[id];
const caps = {
copy: p.soft_caps.ai_copy,
research: p.soft_caps.ai_research,
search: p.soft_caps.web_search,
image: p.soft_caps.ai_image,
};
return {
headline: t(`plan.${id}.headline`),
bullets: [
t(`plan.${id}.bullet1`, { n: p.monthly_credits }),
t(`plan.${id}.bullet2`),
t(`plan.${id}.bullet3`),
t(`plan.${id}.bullet4`),
],
rights: [
t(`plan.${id}.right1`),
t(`plan.${id}.right2`),
t(`plan.${id}.right3`),
],
quota: [
t(`plan.${id}.quota1`, { n: p.monthly_credits, price: p.price_label }),
t(`plan.${id}.quota2`, caps),
],
notes: [t(`plan.${id}.note1`), t(`plan.${id}.note2`)],
};
}
export function planCtaLabel(current: PlanId, target: PlanId, t: TFn): string {
if (current === target) return t("plan.cta.current");
const order: PlanId[] = ["free", "starter", "pro"];
const a = order.indexOf(current);
const b = order.indexOf(target);
if (b > a) return t("plan.cta.upgrade");
if (b < a) return t("plan.cta.downgrade");
return t("plan.cta.switch");
}

View File

@ -0,0 +1,66 @@
import type { BrandProduct } from "../domain/types";
/** 掃描關鍵字池:產品痛點 + tags可多產品合併 */
export function buildScanKeywordPool(
products: BrandProduct | BrandProduct[] | null | undefined,
): string[] {
const list = !products ? [] : Array.isArray(products) ? products : [products];
const pool = list.flatMap((product) => [
...(product.match_tags || []),
...(product.pain_points || []),
product.label || "",
])
.map((k) => k.trim())
.filter(Boolean);
return [...new Set(pool)];
}
export function scoreProductForPost(
product: BrandProduct,
searchTag: string,
postText: string,
): number {
const tags = [...product.match_tags, ...product.pain_points, product.label].filter(Boolean);
const hay = `${searchTag} ${postText}`.toLowerCase();
let hits = 0;
for (const tag of tags) {
const t = tag.toLowerCase();
if (t && hay.includes(t)) hits += 1;
}
if (!hits) return 0;
// 0100
return Math.min(99, 40 + hits * 18);
}
export function resolveProductForPost(opts: {
products: BrandProduct[];
preferredProductId?: string | null;
searchTag: string;
postText: string;
}): { product: BrandProduct | null; score: number } {
const { products, preferredProductId, searchTag, postText } = opts;
if (!products.length) return { product: null, score: 0 };
let best: BrandProduct | null = null;
let bestScore = 0;
for (const p of products) {
const s = scoreProductForPost(p, searchTag, postText);
if (s > bestScore) {
bestScore = s;
best = p;
}
}
if (best && bestScore >= 40) return { product: best, score: bestScore };
if (preferredProductId) {
const pref = products.find((p) => p.id === preferredProductId);
if (pref) return { product: pref, score: Math.max(bestScore, 35) };
}
return { product: products[0] || null, score: bestScore || 30 };
}
export function opportunityLine(product: BrandProduct | null, searchTag: string): string {
if (!product) return `關鍵字「${searchTag}」命中;尚未對上產品線`;
const pain = product.pain_points[0] || product.match_tags[0] || product.label;
return `對上「${product.label}」· 痛點/語境:${pain}`;
}

View File

@ -0,0 +1,51 @@
import { readJson, writeJson } from "./storage";
import { KEYS } from "../data/mock/keys";
export type ScoutTodayState = {
/** YYYY-MM-DD local */
date: string;
done: number;
goalActivity: number;
goalValue: number;
};
function todayKey(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function loadScoutToday(): ScoutTodayState {
const raw = readJson<ScoutTodayState | null>(KEYS.scoutToday, null);
const date = todayKey();
if (!raw || raw.date !== date) {
return {
date,
done: 0,
goalActivity: 15,
goalValue: 8,
};
}
return {
date,
done: Math.max(0, raw.done || 0),
goalActivity: Math.max(1, raw.goalActivity || 15),
goalValue: Math.max(1, raw.goalValue || 8),
};
}
export function saveScoutToday(state: ScoutTodayState): void {
writeJson(KEYS.scoutToday, {
...state,
date: todayKey(),
});
}
export function bumpScoutTodayDone(n = 1): ScoutTodayState {
const cur = loadScoutToday();
const next = { ...cur, date: todayKey(), done: cur.done + n };
saveScoutToday(next);
return next;
}

View File

@ -0,0 +1,37 @@
export function safeGetItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function safeSetItem(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// ignore quota / private mode
}
}
export function safeRemoveItem(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// ignore
}
}
export function readJson<T>(key: string, fallback: T): T {
const raw = safeGetItem(key);
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJson(key: string, value: unknown): void {
safeSetItem(key, JSON.stringify(value));
}

View File

@ -0,0 +1,477 @@
import type { Member, MemberStatus, Role } from "../domain/types";
import { KEYS } from "../data/mock/keys";
import { newId } from "./id";
import { readJson, writeJson } from "./storage";
import { nowUnixNano } from "./time";
/** 租戶內會員帳號(含密碼,僅 mock 本機) */
export type TenantUserRecord = {
uid: string;
email: string;
password: string;
display_name: string;
roles: Role[];
/** 帳號狀態active 正常 · suspended 停權 */
status: MemberStatus;
bio?: string;
timezone?: string;
notify_email?: boolean;
/** 會員頭像mock data URL / https */
avatar_url?: string | null;
email_verified: boolean;
email_verified_at?: number | null;
created_at: number;
updated_at: number;
};
/** 管理員列表用(無密碼) */
export type MemberAdminView = {
uid: string;
email: string;
display_name: string;
roles: Role[];
status: MemberStatus;
bio?: string;
timezone?: string;
notify_email?: boolean;
avatar_url?: string | null;
email_verified: boolean;
email_verified_at?: number | null;
created_at: number;
updated_at: number;
};
export type AdminCreateMemberInput = {
display_name: string;
email: string;
/** 未填則產生臨時密碼 */
password?: string;
roles?: Role[];
/** 預設 true管理者代建通常直接可用 */
email_verified?: boolean;
bio?: string;
};
function normalizeStatus(s: unknown): MemberStatus {
return s === "suspended" ? "suspended" : "active";
}
const DEMO_EMAIL = "demo@harbor.local";
const DEMO_PASSWORD = "demo";
function seedUsers(): TenantUserRecord[] {
const now = nowUnixNano();
const day = 86_400_000_000_000;
const base: TenantUserRecord[] = [
{
uid: "user_demo",
email: DEMO_EMAIL,
password: DEMO_PASSWORD,
display_name: "Demo 島民",
roles: ["member", "admin"],
status: "active",
bio: "系統管理員",
timezone: "Asia/Taipei",
notify_email: true,
email_verified: true,
email_verified_at: now - day,
created_at: now - 30 * day,
updated_at: now,
},
{
uid: "user_alice",
email: "alice@harbor.local",
password: "alice",
display_name: "Alice 編輯",
roles: ["member"],
status: "active",
bio: "一般會員 · 負責內容",
timezone: "Asia/Taipei",
notify_email: true,
email_verified: true,
email_verified_at: now - 7 * day,
created_at: now - 20 * day,
updated_at: now,
},
{
uid: "user_bob",
email: "bob@harbor.local",
password: "bob",
display_name: "Bob 待驗證",
roles: ["member"],
status: "active",
bio: "新加入,尚未驗證信箱",
timezone: "Asia/Taipei",
notify_email: true,
email_verified: false,
email_verified_at: null,
created_at: now - 2 * day,
updated_at: now,
},
{
uid: "user_suspended_demo",
email: "suspended@harbor.local",
password: "suspended",
display_name: "已停權帳號",
roles: ["member"],
status: "suspended",
bio: "停權中",
timezone: "Asia/Taipei",
notify_email: false,
email_verified: true,
email_verified_at: now - 10 * day,
created_at: now - 15 * day,
updated_at: now - day,
},
];
// 額外 mock 會員方便測分頁
const extras: TenantUserRecord[] = Array.from({ length: 12 }, (_, i) => {
const n = i + 1;
return {
uid: `user_extra_${n}`,
email: `user${n}@harbor.local`,
password: `user${n}`,
display_name: `島民 ${n}`,
roles: ["member"] as Role[],
status: (n === 7 ? "suspended" : "active") as MemberStatus,
bio: `分頁測試帳 #${n}`,
timezone: "Asia/Taipei",
notify_email: n % 2 === 0,
email_verified: n % 3 !== 0,
email_verified_at: n % 3 !== 0 ? now - n * day : null,
created_at: now - (n + 3) * day,
updated_at: now - n * 3_600_000_000_000,
};
});
return [...base, ...extras];
}
function migrateUserRecord(raw: TenantUserRecord & { status?: MemberStatus }): TenantUserRecord {
return {
...raw,
status: normalizeStatus(raw.status),
email_verified: raw.email_verified === true,
};
}
export function loadTenantUsers(): TenantUserRecord[] {
let list = readJson(KEYS.tenantUsers, [] as TenantUserRecord[]);
if (!list.length) {
list = seedUsers();
saveTenantUsers(list);
return list;
}
// 舊資料補 status
let dirty = false;
list = list.map((u) => {
if (u.status === "active" || u.status === "suspended") return u;
dirty = true;
return migrateUserRecord(u);
});
// 確保 demo 永遠在
if (!list.some((u) => u.email.toLowerCase() === DEMO_EMAIL)) {
list = [...seedUsers().filter((u) => u.email === DEMO_EMAIL), ...list];
dirty = true;
}
if (dirty) saveTenantUsers(list);
return list;
}
export function saveTenantUsers(list: TenantUserRecord[]): void {
writeJson(KEYS.tenantUsers, list);
}
export function findUserByEmail(email: string): TenantUserRecord | null {
const e = email.trim().toLowerCase();
return loadTenantUsers().find((u) => u.email.toLowerCase() === e) || null;
}
export function findUserByUid(uid: string): TenantUserRecord | null {
return loadTenantUsers().find((u) => u.uid === uid) || null;
}
export function upsertTenantUser(user: TenantUserRecord): TenantUserRecord {
const list = loadTenantUsers();
const i = list.findIndex((u) => u.uid === user.uid);
const next = { ...user, updated_at: nowUnixNano() };
if (i >= 0) list[i] = next;
else list.push(next);
saveTenantUsers(list);
return next;
}
export function toMember(u: TenantUserRecord): Member {
return {
tenant_id: "default",
uid: u.uid,
email: u.email,
display_name: u.display_name,
roles: u.roles,
status: normalizeStatus(u.status),
bio: u.bio || "",
timezone: u.timezone || "Asia/Taipei",
notify_email: u.notify_email ?? true,
avatar_url: u.avatar_url?.trim() || null,
email_verified: u.email_verified === true,
email_verified_at: u.email_verified_at ?? null,
};
}
export function toAdminView(u: TenantUserRecord): MemberAdminView {
return {
uid: u.uid,
email: u.email,
display_name: u.display_name,
roles: u.roles,
status: normalizeStatus(u.status),
bio: u.bio,
timezone: u.timezone,
notify_email: u.notify_email,
avatar_url: u.avatar_url?.trim() || null,
email_verified: u.email_verified === true,
email_verified_at: u.email_verified_at ?? null,
created_at: u.created_at,
updated_at: u.updated_at,
};
}
export function isMemberSuspended(u: Pick<TenantUserRecord, "status"> | Pick<Member, "status"> | null | undefined): boolean {
return normalizeStatus(u?.status) === "suspended";
}
export function listAdminViews(): MemberAdminView[] {
return loadTenantUsers()
.map(toAdminView)
.sort((a, b) => b.updated_at - a.updated_at);
}
export function requireAdminSession(sessionMember: Member | null): Member {
if (!sessionMember) throw new Error("尚未登入");
if (!sessionMember.roles.includes("admin")) throw new Error("需要管理員權限");
return sessionMember;
}
export function adminSetEmailVerified(
actor: Member,
uid: string,
verified: boolean,
): MemberAdminView {
requireAdminSession(actor);
const u = findUserByUid(uid);
if (!u) throw new Error("找不到使用者");
const now = nowUnixNano();
const next = upsertTenantUser({
...u,
email_verified: verified,
email_verified_at: verified ? now : null,
});
return toAdminView(next);
}
export function adminResetPassword(
actor: Member,
uid: string,
newPassword?: string,
): { user: MemberAdminView; temporary_password: string } {
requireAdminSession(actor);
const u = findUserByUid(uid);
if (!u) throw new Error("找不到使用者");
const temporary_password =
(newPassword || "").trim() || `tmp_${newId("pw").replace(/^pw_/, "").slice(0, 8)}`;
if (temporary_password.length < 4) throw new Error("密碼至少 4 碼");
const next = upsertTenantUser({ ...u, password: temporary_password });
return { user: toAdminView(next), temporary_password };
}
/** 管理員指派角色member / admin */
export function adminSetRoles(
actor: Member,
uid: string,
roles: Role[],
): MemberAdminView {
requireAdminSession(actor);
const u = findUserByUid(uid);
if (!u) throw new Error("找不到島民");
const normalized = normalizeRoles(roles);
if (!normalized.length) throw new Error("至少保留一個角色(一般會員)");
// 不可拔掉系統最後一位「在用」管理員
const wasAdmin = u.roles.includes("admin");
const willBeAdmin = normalized.includes("admin");
if (wasAdmin && !willBeAdmin) {
ensureAnotherActiveAdmin(uid);
}
const next = upsertTenantUser({ ...u, roles: normalized });
return toAdminView(next);
}
/** 管理者新增島民(可回傳臨時密碼) */
export function adminCreateMember(
actor: Member,
input: AdminCreateMemberInput,
): { user: MemberAdminView; temporary_password: string } {
requireAdminSession(actor);
const display_name = (input.display_name || "").trim();
if (!display_name) throw new Error("請輸入島民名稱");
if (display_name.length > 40) throw new Error("名稱請在 40 字內");
const email = (input.email || "").trim().toLowerCase();
if (!email || !email.includes("@")) throw new Error("Email 格式不正確");
if (findUserByEmail(email)) throw new Error("此 Email 已被使用");
const temporary_password =
(input.password || "").trim() || `tmp_${newId("pw").replace(/^pw_/, "").slice(0, 8)}`;
if (temporary_password.length < 4) throw new Error("密碼至少 4 碼");
const now = nowUnixNano();
const verified = input.email_verified !== false;
const rec: TenantUserRecord = {
uid: newId("user"),
email,
password: temporary_password,
display_name,
roles: normalizeRoles(input.roles || ["member"]),
status: "active",
bio: (input.bio || "").trim().slice(0, 200),
timezone: "Asia/Taipei",
notify_email: true,
email_verified: verified,
email_verified_at: verified ? now : null,
created_at: now,
updated_at: now,
};
const next = upsertTenantUser(rec);
return { user: toAdminView(next), temporary_password };
}
/**
*
* -
* -
*/
export function adminSetSuspended(
actor: Member,
uid: string,
suspended: boolean,
): MemberAdminView {
requireAdminSession(actor);
const u = findUserByUid(uid);
if (!u) throw new Error("找不到島民");
if (actor.uid === uid && suspended) {
throw new Error("不能停權自己的帳號");
}
const nextStatus: MemberStatus = suspended ? "suspended" : "active";
if (normalizeStatus(u.status) === nextStatus) {
return toAdminView(u);
}
if (suspended && u.roles.includes("admin")) {
ensureAnotherActiveAdmin(uid);
}
const next = upsertTenantUser({ ...u, status: nextStatus });
return toAdminView(next);
}
/** 除了 targetUid 以外,是否還有 active 管理員 */
function ensureAnotherActiveAdmin(excludeUid: string): void {
const other = loadTenantUsers().filter(
(x) =>
x.uid !== excludeUid &&
x.roles.includes("admin") &&
normalizeStatus(x.status) === "active",
);
if (other.length === 0) {
throw new Error("系統至少需要一位在用的管理員,無法停權或降級最後一位");
}
}
function normalizeRoles(roles: Role[]): Role[] {
const set = new Set<Role>();
for (const r of roles) {
if (r === "admin" || r === "member") set.add(r);
}
// 有 admin 時仍保留 member 標籤,方便顯示「也是會員」
if (set.has("admin") && !set.has("member")) set.add("member");
if (!set.size) set.add("member");
return [...set];
}
export type AdminUserListPage = {
list: MemberAdminView[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
/** 分頁列表page 從 1 起query 可搜名稱 / Email / uid */
export function listAdminViewsPage(
page = 1,
pageSize = 10,
query = "",
): AdminUserListPage {
const q = query.trim().toLowerCase();
let all = listAdminViews();
if (q) {
all = all.filter((u) => {
const name = u.display_name.toLowerCase();
const email = u.email.toLowerCase();
const uid = u.uid.toLowerCase();
// 完整 uid 或前綴、名稱、email
return (
name.includes(q) ||
email.includes(q) ||
uid.includes(q) ||
uid.replace(/^user_/, "").includes(q)
);
});
}
const total = all.length;
const size = Math.max(1, Math.min(50, pageSize || 10));
const totalPages = Math.max(1, Math.ceil(total / size));
const p = Math.max(1, Math.min(totalPages, page || 1));
const start = (p - 1) * size;
return {
list: all.slice(start, start + size),
page: p,
pageSize: size,
total,
totalPages,
};
}
/** 同步舊 single-user credentials 進租戶表(一次性相容) */
export function migrateLegacyCredentials(): void {
const list = loadTenantUsers();
const legacy = readJson<{ email: string; password: string } | null>(
KEYS.memberCredentials,
null,
);
const profile = readJson<Partial<Member> | null>(KEYS.memberProfile, null);
if (!legacy?.email) return;
const email = legacy.email.toLowerCase();
const existing = list.find((u) => u.email.toLowerCase() === email);
if (existing) {
// 沿用密碼/驗證狀態
upsertTenantUser({
...existing,
password: legacy.password || existing.password,
display_name: profile?.display_name || existing.display_name,
bio: profile?.bio ?? existing.bio,
timezone: profile?.timezone || existing.timezone,
notify_email: profile?.notify_email ?? existing.notify_email,
email_verified:
profile?.email_verified !== undefined
? profile.email_verified === true
: existing.email_verified,
email_verified_at:
profile?.email_verified_at !== undefined
? profile.email_verified_at
: existing.email_verified_at,
});
}
}

38
apps/web/src/lib/theme.ts Normal file
View File

@ -0,0 +1,38 @@
import { loadUiPrefs, saveUiPrefs, type ThemePreference } from "./i18n/prefs";
export type { ThemePreference };
export type ResolvedTheme = "light" | "dark";
export function isThemePreference(v: unknown): v is ThemePreference {
return v === "light" || v === "dark" || v === "system";
}
export function loadThemePreference(): ThemePreference {
return loadUiPrefs().theme;
}
/** 只更新 uiPrefs 裡的 theme保留 localecurrency */
export function saveThemePreference(theme: ThemePreference): void {
saveUiPrefs({ theme });
}
export function getSystemPrefersDark(): boolean {
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch {
return false;
}
}
export function resolveTheme(pref: ThemePreference): ResolvedTheme {
if (pref === "dark") return "dark";
if (pref === "light") return "light";
return getSystemPrefersDark() ? "dark" : "light";
}
/** 寫入 <html data-theme> 與 color-scheme */
export function applyResolvedTheme(resolved: ResolvedTheme): void {
const root = document.documentElement;
root.setAttribute("data-theme", resolved);
root.style.colorScheme = resolved;
}

108
apps/web/src/lib/time.ts Normal file
View File

@ -0,0 +1,108 @@
import { loadUiPrefs } from "./i18n/prefs";
import { translate } from "./i18n/messages";
import type { AppLocale } from "./i18n/types";
/** Unix nanoseconds (UTC), matching product contract. */
export function nowUnixNano(): number {
return Date.now() * 1_000_000;
}
function currentLocale(): AppLocale {
return loadUiPrefs().locale;
}
function t(key: string, params?: Record<string, string | number>): string {
return translate(currentLocale(), key, params);
}
export function formatLocalDateTime(nano: number | null | undefined): string {
if (!nano) return "—";
const ms = Math.floor(nano / 1_000_000);
const locale = currentLocale() === "en" ? "en-US" : "zh-TW";
return new Date(ms).toLocaleString(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export type SessionExpiryKind = "unknown" | "ok" | "soon" | "expired";
/** soon = 48h 內到期 */
export function sessionExpiryKind(
expiresAt: number | null | undefined,
now = nowUnixNano(),
): SessionExpiryKind {
if (expiresAt == null || expiresAt <= 0) return "unknown";
if (expiresAt <= now) return "expired";
const soonMs = 48 * 60 * 60 * 1000;
if (expiresAt - now <= soonMs * 1_000_000) return "soon";
return "ok";
}
export function formatRelativeFromNow(
nano: number | null | undefined,
now = nowUnixNano(),
): string {
if (nano == null || nano <= 0) return "—";
const diffMs = Math.floor((nano - now) / 1_000_000);
const abs = Math.abs(diffMs);
const min = Math.round(abs / 60_000);
const hour = Math.round(abs / 3_600_000);
const day = Math.round(abs / 86_400_000);
let span: string;
if (min < 60) span = t("time.min", { n: Math.max(1, min) });
else if (hour < 48) span = t("time.hour", { n: hour });
else span = t("time.day", { n: day });
if (diffMs < 0) return t("time.expired", { span });
return t("time.remaining", { span });
}
/** 過去時間通知列表用剛剛N 分前) */
export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string {
if (nano == null || nano <= 0) return "—";
const diffMs = Math.floor((now - nano) / 1_000_000);
if (diffMs < 45_000) return t("time.justNow");
const min = Math.floor(diffMs / 60_000);
if (min < 60) return t("time.minAgo", { n: min });
const hour = Math.floor(diffMs / 3_600_000);
if (hour < 48) return t("time.hourAgo", { n: hour });
const day = Math.floor(diffMs / 86_400_000);
if (day < 14) return t("time.dayAgo", { n: day });
return formatLocalDateTime(nano);
}
export function formatSessionExpiry(expiresAt: number | null | undefined): {
kind: SessionExpiryKind;
absolute: string;
relative: string;
label: string;
} {
const kind = sessionExpiryKind(expiresAt);
const absolute = formatLocalDateTime(expiresAt);
const relative = formatRelativeFromNow(expiresAt);
const label =
kind === "unknown"
? t("time.sessionUnknown")
: kind === "expired"
? t("time.sessionExpired", { absolute })
: kind === "soon"
? t("time.sessionSoon", { relative, absolute })
: t("time.sessionOk", { relative, absolute });
return { kind, absolute, relative, label };
}
export function fromDatetimeLocalValue(value: string): number {
if (!value) return nowUnixNano();
const ms = new Date(value).getTime();
if (Number.isNaN(ms)) return nowUnixNano();
return ms * 1_000_000;
}
export function toDatetimeLocalValue(nano: number | null | undefined): string {
if (!nano) return "";
const d = new Date(Math.floor(nano / 1_000_000));
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

View File

@ -0,0 +1,829 @@
import { KEYS } from "../data/mock/keys";
import { getSession } from "../data/mock/store";
import { newId } from "./id";
import { readJson, writeJson } from "./storage";
import { nowUnixNano } from "./time";
/** 計費維度(對齊未來付費方案) */
export type UsageMeter = "ai_copy" | "ai_research" | "web_search" | "ai_image";
export type PlanId = "free" | "starter" | "pro";
export type UsageEvent = {
id: string;
/** 使用者 uid舊資料可能缺會歸到 unknown */
uid: string;
meter: UsageMeter;
/** 消耗點數 */
credits: number;
/** 使用者可讀說明 */
label: string;
/** 功能來源,除錯/對帳 */
source: string;
/** unix nanoseconds UTC */
created_at: number;
};
export type PlanDef = {
id: PlanId;
name: string;
price_label: string;
price_twd: number;
blurb: string;
blurb_key: string;
monthly_credits: number;
soft_caps: Record<UsageMeter, number>;
};
/** 每人方案/是否不擋額度(用量一律計算) */
export type UsageMemberPrefs = {
plan_id: PlanId;
/** 達方案上限仍可繼續用AISearch點數照樣記帳 */
unlimited: boolean;
};
export const METER_META: Record<
UsageMeter,
{ label: string; hint: string; order: number; group: "ai" | "search" }
> = {
ai_copy: {
label: "AI 文案",
hint: "回覆草稿、靈感聊天、仿寫、結構分析",
order: 0,
group: "ai",
},
ai_research: {
label: "AI 研究",
hint: "海巡周邊知識、上網功課",
order: 1,
group: "ai",
},
web_search: {
label: "搜尋",
hint: "熱點刷新、關鍵字搜、研究檢索",
order: 2,
group: "search",
},
ai_image: {
label: "AI 生圖",
hint: "文生圖/配圖",
order: 3,
group: "ai",
},
};
export const PLANS: Record<PlanId, PlanDef> = {
free: {
id: "free",
name: "Free",
price_label: "NT$0月",
price_twd: 0,
blurb: "試用與個人輕量經營",
blurb_key: "usage.plan.free.blurb",
monthly_credits: 80,
soft_caps: {
ai_copy: 40,
ai_research: 8,
web_search: 30,
ai_image: 3,
},
},
starter: {
id: "starter",
name: "Starter",
price_label: "NT$590月",
price_twd: 590,
blurb: "小團隊日常發文與海巡",
blurb_key: "usage.plan.starter.blurb",
monthly_credits: 500,
soft_caps: {
ai_copy: 250,
ai_research: 60,
web_search: 200,
ai_image: 20,
},
},
pro: {
id: "pro",
name: "Pro",
price_label: "NT$1,990月",
price_twd: 1990,
blurb: "多帳、重度 AI 與研究",
blurb_key: "usage.plan.pro.blurb",
monthly_credits: 2000,
soft_caps: {
ai_copy: 1000,
ai_research: 250,
web_search: 800,
ai_image: 80,
},
},
};
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
ai_copy: 1,
ai_research: 3,
web_search: 1,
ai_image: 5,
};
export type UsageMonthSummary = {
month_key: string;
uid: string;
plan: PlanDef;
unlimited: boolean;
total_credits: number;
remaining_credits: number;
/** 無限時為 0有上限才算用掉比例 */
pct: number;
/** AI 次數合計(文案 + 研究 + 生圖) */
ai_calls: number;
/** 搜尋次數 */
search_calls: number;
by_meter: Record<
UsageMeter,
{ credits: number; count: number; soft_cap: number; pct: number }
>;
events: UsageEvent[];
};
export type TenantUsageMemberRow = {
uid: string;
email: string;
display_name: string;
unlimited: boolean;
plan_id: PlanId;
/** 區間內方案配給(購買/額度) */
purchased_credits: number;
/** 區間內消耗 */
total_credits: number;
ai_calls: number;
search_calls: number;
remaining_credits: number;
pct: number;
};
export type TenantUsageSummary = {
month_key: string;
/** @deprecated 等同 consumed_credits保留相容 */
total_credits: number;
purchased_credits: number;
consumed_credits: number;
remaining_credits: number;
ai_calls: number;
search_calls: number;
by_meter: Record<UsageMeter, { credits: number; count: number }>;
members: TenantUsageMemberRow[];
};
/** 圖表時間粒度 */
export type UsageGranularity = "day" | "month" | "year";
export type UsageTimeBucket = {
/** day: YYYY-MM-DD · month: YYYY-MM · year: YYYY */
key: string;
label: string;
purchased_credits: number;
consumed_credits: number;
ai_calls: number;
search_calls: number;
};
export type TenantUsageAnalytics = {
granularity: UsageGranularity;
/** 區間起迄YYYY-MM-DD */
from: string;
to: string;
range_label: string;
purchased_credits: number;
consumed_credits: number;
remaining_credits: number;
pct: number;
ai_calls: number;
search_calls: number;
by_meter: Record<UsageMeter, { credits: number; count: number }>;
series: UsageTimeBucket[];
members: TenantUsageMemberRow[];
};
export type TenantAnalyticsQuery = {
granularity?: UsageGranularity;
/** YYYY-MM-DD */
from?: string;
/** YYYY-MM-DD */
to?: string;
};
function monthKeyFromMs(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function currentMonthKey(now = Date.now()): string {
return monthKeyFromMs(now);
}
function loadEvents(): UsageEvent[] {
const raw = readJson<(UsageEvent & { uid?: string })[]>(KEYS.usageEvents, []);
return raw.map((e) => ({
...e,
uid: e.uid || "unknown",
}));
}
function saveEvents(list: UsageEvent[]): void {
writeJson(KEYS.usageEvents, list.slice(0, 2000));
}
type PrefsMap = Record<string, UsageMemberPrefs>;
function loadPrefsMap(): PrefsMap {
return readJson(KEYS.usageMemberPrefs, {} as PrefsMap);
}
function savePrefsMap(map: PrefsMap): void {
writeJson(KEYS.usageMemberPrefs, map);
}
function normalizePlanId(id: unknown): PlanId {
if (id === "starter" || id === "pro" || id === "free") return id;
return "free";
}
export function getMemberPrefs(uid: string): UsageMemberPrefs {
const map = loadPrefsMap();
const row = map[uid];
if (row) {
return {
plan_id: normalizePlanId(row.plan_id),
unlimited: row.unlimited === true,
};
}
// 相容:舊全域方案
const legacy = readJson<string>(KEYS.usagePlanId, "free");
return { plan_id: normalizePlanId(legacy), unlimited: false };
}
export function setMemberPrefs(
uid: string,
patch: Partial<UsageMemberPrefs>,
): UsageMemberPrefs {
const map = loadPrefsMap();
const cur = getMemberPrefs(uid);
const next: UsageMemberPrefs = {
plan_id: patch.plan_id != null ? normalizePlanId(patch.plan_id) : cur.plan_id,
unlimited: patch.unlimited != null ? patch.unlimited === true : cur.unlimited,
};
map[uid] = next;
savePrefsMap(map);
return next;
}
/** @deprecated 改用 getMemberPrefs(uid) */
export function getPlanId(uid?: string): PlanId {
if (uid) return getMemberPrefs(uid).plan_id;
const s = getSession()?.member?.uid;
return getMemberPrefs(s || "unknown").plan_id;
}
/** @deprecated 改用 setMemberPrefs */
export function setPlanId(id: PlanId, uid?: string): void {
const s = uid || getSession()?.member?.uid || "unknown";
setMemberPrefs(s, { plan_id: id });
}
function sessionUid(): string {
return getSession()?.member?.uid || "unknown";
}
/**
*
* -
* - unlimited
*/
export function recordUsage(opts: {
meter: UsageMeter;
label: string;
source: string;
credits?: number;
uid?: string;
}): UsageEvent {
const uid = opts.uid || sessionUid();
const credits =
opts.credits != null && opts.credits > 0
? opts.credits
: DEFAULT_CREDIT_COST[opts.meter];
const ev: UsageEvent = {
id: newId("use"),
uid,
meter: opts.meter,
credits,
label: opts.label,
source: opts.source,
created_at: nowUnixNano(),
};
const list = loadEvents();
list.unshift(ev);
saveEvents(list);
return ev;
}
export function listUsageEvents(opts?: {
limit?: number;
uid?: string;
monthKey?: string;
}): UsageEvent[] {
const limit = opts?.limit ?? 100;
const monthKey = opts?.monthKey;
let list = loadEvents();
if (opts?.uid) list = list.filter((e) => e.uid === opts.uid);
if (monthKey) {
list = list.filter((e) => {
const ms = Math.floor(e.created_at / 1_000_000);
return monthKeyFromMs(ms) === monthKey;
});
}
return list.slice(0, limit);
}
function emptyByMeter(plan: PlanDef): UsageMonthSummary["by_meter"] {
const by_meter = {} as UsageMonthSummary["by_meter"];
for (const m of Object.keys(METER_META) as UsageMeter[]) {
by_meter[m] = {
credits: 0,
count: 0,
soft_cap: plan.soft_caps[m],
pct: 0,
};
}
return by_meter;
}
function aggregateEvents(
events: UsageEvent[],
plan: PlanDef,
_unlimited: boolean,
): Pick<
UsageMonthSummary,
"total_credits" | "remaining_credits" | "pct" | "by_meter" | "ai_calls" | "search_calls"
> {
// 用量一律完整計算(與是否「不擋額度」無關)
const by_meter = emptyByMeter(plan);
let total = 0;
for (const e of events) {
total += e.credits;
const row = by_meter[e.meter];
if (row) {
row.credits += e.credits;
row.count += 1;
}
}
for (const m of Object.keys(by_meter) as UsageMeter[]) {
const row = by_meter[m];
// 可超過 soft_cappct 最高顯示 100+ 也 cap 在 999 供 UI 參考
row.pct =
row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0;
}
const remaining = Math.max(0, plan.monthly_credits - total);
const pct =
plan.monthly_credits > 0
? Math.min(999, Math.round((total / plan.monthly_credits) * 100))
: 0;
let ai_calls = 0;
let search_calls = 0;
for (const m of Object.keys(METER_META) as UsageMeter[]) {
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
else search_calls += by_meter[m].count;
}
return {
total_credits: total,
remaining_credits: remaining,
pct,
by_meter,
ai_calls,
search_calls,
};
}
export function summarizeUsage(
uid?: string,
monthKey = currentMonthKey(),
): UsageMonthSummary {
const target = uid || sessionUid();
const prefs = getMemberPrefs(target);
const plan = PLANS[prefs.plan_id];
const events = loadEvents().filter((e) => {
if (e.uid !== target) return false;
const ms = Math.floor(e.created_at / 1_000_000);
return monthKeyFromMs(ms) === monthKey;
});
const agg = aggregateEvents(events, plan, prefs.unlimited);
return {
month_key: monthKey,
uid: target,
plan,
unlimited: prefs.unlimited,
...agg,
events: events.slice(0, 80),
};
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function toDateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function parseDateKey(key: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec((key || "").trim());
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const day = Number(m[3]);
if (!y || mo < 1 || mo > 12 || day < 1 || day > 31) return null;
const d = new Date(y, mo - 1, day);
if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) return null;
return d;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function daysInMonth(y: number, monthIndex0: number): number {
return new Date(y, monthIndex0 + 1, 0).getDate();
}
function eventDayKey(e: UsageEvent): string {
return toDateKey(new Date(Math.floor(e.created_at / 1_000_000)));
}
function eventMonthKey(e: UsageEvent): string {
return monthKeyFromMs(Math.floor(e.created_at / 1_000_000));
}
function eventYearKey(e: UsageEvent): string {
return String(new Date(Math.floor(e.created_at / 1_000_000)).getFullYear());
}
function memberMonthlyQuota(uid: string): number {
return PLANS[getMemberPrefs(uid).plan_id].monthly_credits;
}
/** 區間內「購買/配給」點數:依每人方案月額度 × 涵蓋月數(日視圖按當月天數比例) */
function purchasedInRange(
memberUids: string[],
from: Date,
to: Date,
granularity: UsageGranularity,
): number {
let total = 0;
for (const uid of memberUids) {
const monthly = memberMonthlyQuota(uid);
if (granularity === "year") {
// 逐年:每年 12 個月 × 月額度(區間裁切到涵蓋月)
const start = new Date(from.getFullYear(), from.getMonth(), 1);
const end = new Date(to.getFullYear(), to.getMonth(), 1);
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
const m0 = y === start.getFullYear() ? start.getMonth() : 0;
const m1 = y === end.getFullYear() ? end.getMonth() : 11;
total += monthly * (m1 - m0 + 1);
}
} else if (granularity === "month") {
const start = new Date(from.getFullYear(), from.getMonth(), 1);
const end = new Date(to.getFullYear(), to.getMonth(), 1);
let cur = start;
while (cur <= end) {
total += monthly;
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
}
} else {
// day每日配給 = 月額度 / 當月天數
let cur = startOfDay(from);
const end = startOfDay(to);
while (cur <= end) {
const dim = daysInMonth(cur.getFullYear(), cur.getMonth());
total += monthly / dim;
cur = addDays(cur, 1);
}
}
}
return Math.round(total);
}
function defaultRange(granularity: UsageGranularity): { from: Date; to: Date } {
const to = startOfDay(new Date());
if (granularity === "day") {
return { from: addDays(to, -29), to };
}
if (granularity === "year") {
return {
from: new Date(to.getFullYear() - 2, 0, 1),
to,
};
}
// month近 12 個月
return {
from: new Date(to.getFullYear(), to.getMonth() - 11, 1),
to,
};
}
function bucketKeys(
from: Date,
to: Date,
granularity: UsageGranularity,
): { key: string; label: string; start: Date; end: Date }[] {
const out: { key: string; label: string; start: Date; end: Date }[] = [];
if (granularity === "day") {
let cur = startOfDay(from);
const end = startOfDay(to);
while (cur <= end) {
const key = toDateKey(cur);
out.push({
key,
label: `${cur.getMonth() + 1}/${cur.getDate()}`,
start: cur,
end: cur,
});
cur = addDays(cur, 1);
}
return out;
}
if (granularity === "month") {
let cur = new Date(from.getFullYear(), from.getMonth(), 1);
const end = new Date(to.getFullYear(), to.getMonth(), 1);
while (cur <= end) {
const y = cur.getFullYear();
const m = cur.getMonth();
const key = `${y}-${pad2(m + 1)}`;
const last = new Date(y, m + 1, 0);
out.push({
key,
label: `${y}/${pad2(m + 1)}`,
start: cur,
end: last,
});
cur = new Date(y, m + 1, 1);
}
return out;
}
// year
for (let y = from.getFullYear(); y <= to.getFullYear(); y++) {
out.push({
key: String(y),
label: `${y}`,
start: new Date(y, 0, 1),
end: new Date(y, 11, 31),
});
}
return out;
}
function eventInBucket(e: UsageEvent, granularity: UsageGranularity, key: string): boolean {
if (granularity === "day") return eventDayKey(e) === key;
if (granularity === "month") return eventMonthKey(e) === key;
return eventYearKey(e) === key;
}
function purchasedForBucket(
memberUids: string[],
bucket: { start: Date; end: Date; key: string },
granularity: UsageGranularity,
): number {
return purchasedInRange(memberUids, bucket.start, bucket.end, granularity);
}
/**
* mock便
* events < 12
*/
export function ensureDemoUsageHistory(
members: { uid: string }[],
): void {
const list = loadEvents();
if (list.length >= 12) return;
if (!members.length) return;
const meters = Object.keys(METER_META) as UsageMeter[];
const labels: Record<UsageMeter, string> = {
ai_copy: "示意:文案",
ai_research: "示意:研究",
web_search: "示意:搜尋",
ai_image: "示意:生圖",
};
const now = Date.now();
const seeded: UsageEvent[] = [];
for (let dayAgo = 160; dayAgo >= 0; dayAgo -= 2 + (dayAgo % 3)) {
const uid = members[dayAgo % members.length]!.uid;
const meter = meters[dayAgo % meters.length]!;
const ms = now - dayAgo * 86_400_000 - (dayAgo % 7) * 3_600_000;
seeded.push({
id: newId("use"),
uid,
meter,
credits: DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)),
label: labels[meter],
source: "demo.seed",
created_at: ms * 1_000_000,
});
}
saveEvents([...seeded, ...list].slice(0, 2000));
}
/** 租戶區間分析:購買 vs 消耗、時間序列、每人 */
export function summarizeTenantAnalytics(
members: { uid: string; email: string; display_name: string }[],
query: TenantAnalyticsQuery = {},
): TenantUsageAnalytics {
const granularity: UsageGranularity =
query.granularity === "day" || query.granularity === "year" ? query.granularity : "month";
const defaults = defaultRange(granularity);
let from = parseDateKey(query.from || "") || defaults.from;
let to = parseDateKey(query.to || "") || defaults.to;
if (from > to) {
const tmp = from;
from = to;
to = tmp;
}
// 限縮避免爆量桶
const maxSpanDays =
granularity === "day" ? 93 : granularity === "month" ? 366 * 3 : 366 * 8;
if ((startOfDay(to).getTime() - startOfDay(from).getTime()) / 86_400_000 > maxSpanDays) {
from = addDays(to, -maxSpanDays);
}
const fromKey = toDateKey(from);
const toKey = toDateKey(to);
const uids = members.map((m) => m.uid);
const rangeStartMs = startOfDay(from).getTime();
const rangeEndMs = startOfDay(to).getTime() + 86_400_000 - 1;
const rangeEvents = loadEvents().filter((e) => {
const ms = Math.floor(e.created_at / 1_000_000);
return ms >= rangeStartMs && ms <= rangeEndMs;
});
const by_meter = {} as TenantUsageAnalytics["by_meter"];
for (const m of Object.keys(METER_META) as UsageMeter[]) {
by_meter[m] = { credits: 0, count: 0 };
}
let consumed_credits = 0;
for (const e of rangeEvents) {
consumed_credits += e.credits;
const row = by_meter[e.meter];
if (row) {
row.credits += e.credits;
row.count += 1;
}
}
let ai_calls = 0;
let search_calls = 0;
for (const m of Object.keys(METER_META) as UsageMeter[]) {
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
else search_calls += by_meter[m].count;
}
const purchased_credits = purchasedInRange(uids, from, to, granularity);
const remaining_credits = Math.max(0, Math.round(purchased_credits - consumed_credits));
const pct =
purchased_credits > 0
? Math.min(999, Math.round((consumed_credits / purchased_credits) * 100))
: 0;
const buckets = bucketKeys(from, to, granularity);
const series: UsageTimeBucket[] = buckets.map((b) => {
const evs = rangeEvents.filter((e) => eventInBucket(e, granularity, b.key));
let c = 0;
let ai = 0;
let search = 0;
for (const e of evs) {
c += e.credits;
if (METER_META[e.meter]?.group === "ai") ai += 1;
else search += 1;
}
return {
key: b.key,
label: b.label,
purchased_credits: purchasedForBucket(uids, b, granularity),
consumed_credits: c,
ai_calls: ai,
search_calls: search,
};
});
const memberRows: TenantUsageMemberRow[] = members.map((m) => {
const prefs = getMemberPrefs(m.uid);
const plan = PLANS[prefs.plan_id];
const evs = rangeEvents.filter((e) => e.uid === m.uid);
let total = 0;
let ai = 0;
let search = 0;
for (const e of evs) {
total += e.credits;
if (METER_META[e.meter]?.group === "ai") ai += 1;
else search += 1;
}
const purchased = purchasedInRange([m.uid], from, to, granularity);
const remaining = Math.max(0, purchased - total);
const memberPct =
purchased > 0 ? Math.min(999, Math.round((total / purchased) * 100)) : 0;
return {
uid: m.uid,
email: m.email,
display_name: m.display_name,
unlimited: prefs.unlimited,
plan_id: plan.id,
purchased_credits: purchased,
total_credits: total,
ai_calls: ai,
search_calls: search,
remaining_credits: remaining,
pct: memberPct,
};
});
memberRows.sort((a, b) => b.total_credits - a.total_credits);
const unit =
granularity === "day" ? "日" : granularity === "month" ? "月" : "年";
const range_label = `${fromKey}${toKey} · 以${unit}檢視`;
return {
granularity,
from: fromKey,
to: toKey,
range_label,
purchased_credits,
consumed_credits,
remaining_credits,
pct,
ai_calls,
search_calls,
by_meter,
series,
members: memberRows,
};
}
/** 租戶本月全體:總體 + 每人(相容舊呼叫) */
export function summarizeTenantUsage(
members: { uid: string; email: string; display_name: string }[],
monthKey = currentMonthKey(),
): TenantUsageSummary {
const [y, mo] = monthKey.split("-").map(Number);
const from = `${y}-${pad2(mo)}-01`;
const last = daysInMonth(y, mo - 1);
const to = `${y}-${pad2(mo)}-${pad2(last)}`;
const a = summarizeTenantAnalytics(members, {
granularity: "month",
from,
to,
});
return {
month_key: monthKey,
total_credits: a.consumed_credits,
purchased_credits: a.purchased_credits,
consumed_credits: a.consumed_credits,
remaining_credits: a.remaining_credits,
ai_calls: a.ai_calls,
search_calls: a.search_calls,
by_meter: a.by_meter,
members: a.members,
};
}
export function usageWarning(summary: UsageMonthSummary): string | null {
// 不擋額度:只提示已超出,不說「用完」
if (summary.unlimited) {
if (summary.total_credits > summary.plan.monthly_credits) {
return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`;
}
return null;
}
if (summary.pct >= 100) {
return "本月點數已用完。可升級方案或等待下月重置。";
}
if (summary.pct >= 80) {
return `本月已用 ${summary.pct}% 點數。`;
}
for (const m of Object.keys(METER_META) as UsageMeter[]) {
const row = summary.by_meter[m];
if (row.pct >= 90) {
return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`;
}
}
return null;
}

View File

@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/global.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
);

View File

@ -0,0 +1,802 @@
import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Input, Pager, Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import { KEYS } from "../data/mock/keys";
import type { Role } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
import { readJson, writeJson } from "../lib/storage";
import type { MemberAdminView } from "../lib/tenantUsers";
import { formatLocalDateTime, nowUnixNano } from "../lib/time";
import { PLANS, type PlanId, type UsageMemberPrefs } from "../lib/usageMeter";
const PAGE_SIZE_OPTIONS = [5, 10, 20];
/** 最近揭示的臨時密碼:可重整,需手動關閉 */
type RevealedTempPassword = {
uid: string;
display_name: string;
email: string;
password: string;
created_at: number;
reason?: "create" | "reset";
};
function loadRevealedTemp(): RevealedTempPassword | null {
return readJson<RevealedTempPassword | null>(KEYS.adminTempPassword, null);
}
function saveRevealedTemp(rec: RevealedTempPassword | null): void {
writeJson(KEYS.adminTempPassword, rec);
}
/**
* uid+
*/
export function AdminUsersPage() {
const repos = useRepos();
const { member, reload } = useAuth();
const { t } = useI18n();
const { tick, refresh } = useData();
const [users, setUsers] = useState<MemberAdminView[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [query, setQuery] = useState("");
const [queryDraft, setQueryDraft] = useState("");
const [selected, setSelected] = useState<MemberAdminView | null>(null);
const [roleDraft, setRoleDraft] = useState<Role[]>(["member"]);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [revealed, setRevealed] = useState<RevealedTempPassword | null>(() => loadRevealedTemp());
const [copyHint, setCopyHint] = useState("");
const [customPw, setCustomPw] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createEmail, setCreateEmail] = useState("");
const [createPassword, setCreatePassword] = useState("");
const [createVerified, setCreateVerified] = useState(true);
const [createAsAdmin, setCreateAsAdmin] = useState(false);
/** 選中島民的方案/不擋額度 */
const [usagePrefs, setUsagePrefs] = useState<UsageMemberPrefs | null>(null);
const isAdmin = memberRoleSummary(member, t).isAdmin;
useEffect(() => {
if (!isAdmin) return;
void (async () => {
try {
const res = await repos.adminUsers.listUsersPage({
page,
pageSize,
query,
});
setUsers(res.list);
setTotal(res.total);
setPage(res.page);
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.loadFail"));
}
})();
}, [repos.adminUsers, tick, isAdmin, page, pageSize, query]);
const selectedUid = selected?.uid ?? "";
const selectedRolesKey = selected?.roles?.slice().sort().join(",") ?? "";
useEffect(() => {
if (!selectedUid || !selected) return;
setRoleDraft(selected.roles.includes("admin") ? ["member", "admin"] : ["member"]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedRolesKey tracks role changes
}, [selectedUid, selectedRolesKey]);
useEffect(() => {
if (!selectedUid || !isAdmin) {
setUsagePrefs(null);
return;
}
void repos.usage.getMemberPrefs(selectedUid).then(setUsagePrefs).catch(() => setUsagePrefs(null));
}, [selectedUid, isAdmin, repos.usage, tick]);
if (!member) return <Navigate to="/login" replace />;
if (!isAdmin) {
return (
<>
<PageHeader title={t("admin.users.title")} />
<EmptyState
title={t("admin.users.needAdmin")}
description=""
action={
<Link to="/app/today">
<Button type="button" variant="ghost">
{t("nav.today")}
</Button>
</Link>
}
/>
</>
);
}
async function reloadList() {
const res = await repos.adminUsers.listUsersPage({ page, pageSize, query });
setUsers(res.list);
setTotal(res.total);
setPage(res.page);
if (selected) {
const u = await repos.adminUsers.getUser(selected.uid);
setSelected(u);
}
refresh();
}
async function createMember() {
setBusy("create");
setMessage("");
setError("");
setCopyHint("");
try {
const res = await repos.adminUsers.createMember({
display_name: createName,
email: createEmail,
password: createPassword.trim() || undefined,
roles: createAsAdmin ? ["member", "admin"] : ["member"],
email_verified: createVerified,
});
const rec: RevealedTempPassword = {
uid: res.user.uid,
display_name: res.user.display_name,
email: res.user.email,
password: res.temporary_password,
created_at: nowUnixNano(),
reason: "create",
};
saveRevealedTemp(rec);
setRevealed(rec);
setCreateName("");
setCreateEmail("");
setCreatePassword("");
setCreateVerified(true);
setCreateAsAdmin(false);
setShowCreate(false);
setSelected(res.user);
setQuery("");
setQueryDraft("");
setPage(1);
setMessage(t("admin.users.created", { name: res.user.display_name }));
await reloadList();
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.createFail"));
} finally {
setBusy("");
}
}
async function saveUsageUnlimited(unlimited: boolean) {
if (!selected) return;
setBusy(`usage:${selected.uid}`);
setMessage("");
setError("");
try {
const next = await repos.usage.setMemberPrefs(selected.uid, { unlimited });
setUsagePrefs(next);
setMessage(
unlimited
? t("admin.users.unlimitedOn", { name: selected.display_name })
: t("admin.users.unlimitedOff", { name: selected.display_name }),
);
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function saveUsagePlan(plan_id: PlanId) {
if (!selected) return;
setBusy(`usage:${selected.uid}`);
setMessage("");
setError("");
try {
const next = await repos.usage.setMemberPrefs(selected.uid, { plan_id });
setUsagePrefs(next);
setMessage(t("admin.users.planSet", { name: selected.display_name, plan: PLANS[plan_id].name }));
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function toggleSuspended(u: MemberAdminView) {
const willSuspend = u.status !== "suspended";
if (
!window.confirm(
willSuspend
? t("admin.users.confirmSuspend", { name: u.display_name })
: t("admin.users.confirmUnsuspend", { name: u.display_name }),
)
) {
return;
}
setBusy(`suspend:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setSuspended(u.uid, willSuspend);
setMessage(
willSuspend
? t("admin.users.didSuspend", { name: next.display_name })
: t("admin.users.didUnsuspend", { name: next.display_name }),
);
setSelected(next);
await reloadList();
} catch (e) {
setError(
e instanceof Error
? e.message
: willSuspend
? t("admin.users.suspendFail")
: t("admin.users.unsuspendFail"),
);
} finally {
setBusy("");
}
}
async function toggleVerified(u: MemberAdminView) {
setBusy(`verify:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setEmailVerified(u.uid, !u.email_verified);
setMessage(
next.email_verified
? t("admin.users.markedVerified", { name: next.display_name })
: t("admin.users.markedUnverified", { name: next.display_name }),
);
if (member?.uid === u.uid) await reload();
await reloadList();
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function saveRoles(u: MemberAdminView) {
setBusy(`roles:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setRoles(u.uid, roleDraft);
setMessage(
t("admin.users.rolesUpdated", {
name: next.display_name,
roles: next.roles.map((r) => roleLabel(r, t)).join(t("common.listSep")),
}),
);
if (member?.uid === u.uid) await reload();
await reloadList();
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.rolesFail"));
} finally {
setBusy("");
}
}
async function doReset(u: MemberAdminView, useCustom: boolean) {
if (
!window.confirm(t("admin.users.confirmReset", { name: u.display_name }))
) {
return;
}
setBusy(`reset:${u.uid}`);
setMessage("");
setError("");
setCopyHint("");
try {
const res = await repos.adminUsers.resetPassword(
u.uid,
useCustom ? customPw || undefined : undefined,
);
const rec: RevealedTempPassword = {
uid: res.user.uid,
display_name: res.user.display_name,
email: res.user.email,
password: res.temporary_password,
created_at: nowUnixNano(),
reason: "reset",
};
saveRevealedTemp(rec);
setRevealed(rec);
setCustomPw("");
setMessage(t("admin.users.resetDone", { name: res.user.display_name }));
await reloadList();
} catch (e) {
setError(e instanceof Error ? e.message : t("admin.users.resetFail"));
} finally {
setBusy("");
}
}
async function copyTempPassword() {
if (!revealed?.password) return;
try {
await navigator.clipboard.writeText(revealed.password);
setCopyHint(t("admin.users.copied"));
window.setTimeout(() => setCopyHint(""), 2500);
} catch {
try {
const ta = document.createElement("textarea");
ta.value = revealed.password;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopyHint(t("admin.users.copied"));
window.setTimeout(() => setCopyHint(""), 2500);
} catch {
setCopyHint(t("admin.users.copyFail"));
}
}
}
function dismissTempPassword() {
if (
!window.confirm(t("admin.users.confirmDismissTemp"))
) {
return;
}
saveRevealedTemp(null);
setRevealed(null);
setCopyHint("");
}
function applySearch() {
setPage(1);
setQuery(queryDraft.trim());
}
const isSuspended = selected?.status === "suspended";
return (
<>
<PageHeader title={t("admin.users.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
{revealed ? (
<div className="hb-admin-temp-pw" role="status">
<div className="hb-admin-temp-pw__head">
<p className="hb-field__label" style={{ margin: 0 }}>
{revealed.reason === "create" ? t("admin.users.tempPwNew") : t("admin.users.tempPw")}
{t("admin.users.tempPwPersist")}
</p>
<span className="text-muted" style={{ fontSize: "0.78rem" }}>
{revealed.display_name} · {revealed.email}
{" · "}
{formatLocalDateTime(revealed.created_at)}
</span>
</div>
<div className="hb-admin-temp-pw__row">
<code className="hb-admin-temp-pw__code" id="hb-admin-temp-pw-value">
{revealed.password}
</code>
<div className="hb-admin-temp-pw__actions">
<Button type="button" onClick={() => void copyTempPassword()}>
{t("admin.users.copyPw")}
</Button>
<Button type="button" variant="ghost" onClick={dismissTempPassword}>
{t("admin.users.close")}
</Button>
</div>
</div>
{copyHint ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}>
<strong style={{ color: "var(--hb-brand)" }}>{copyHint}</strong>
</p>
) : null}
</div>
) : null}
<div className="hb-admin-create-bar">
<Button
type="button"
variant={showCreate ? "ghost" : "primary"}
onClick={() => {
setShowCreate((v) => !v);
setError("");
}}
>
{showCreate ? t("common.cancel") : t("admin.users.create")}
</Button>
</div>
{showCreate ? (
<Card title={t("admin.users.createTitle")}>
<div className="hb-admin-create-form">
<Input
label={t("admin.users.memberName")}
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t("admin.users.displayNamePh")}
autoComplete="off"
/>
<Input
label={t("admin.users.email")}
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
placeholder="name@example.com"
autoComplete="off"
/>
<Input
label={t("admin.users.initPassword")}
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder={t("admin.users.initPasswordPh")}
autoComplete="new-password"
/>
<div className="hb-admin-create-checks">
<label className="hb-check-row">
<input
type="checkbox"
checked={createVerified}
onChange={(e) => setCreateVerified(e.target.checked)}
/>
<span>{t("admin.users.markVerifiedCheck")}</span>
</label>
<label className="hb-check-row">
<input
type="checkbox"
checked={createAsAdmin}
onChange={(e) => setCreateAsAdmin(e.target.checked)}
/>
<span>{t("admin.users.alsoAdmin")}</span>
</label>
</div>
<div className="hb-admin-actions">
<Button
type="button"
disabled={Boolean(busy) || !createName.trim() || !createEmail.trim()}
onClick={() => void createMember()}
>
{busy === "create" ? t("admin.users.creating") : t("admin.users.createSubmit")}
</Button>
</div>
</div>
</Card>
) : null}
<div className="hb-admin-users">
<Card title={t("admin.users.list", { n: total })}>
<div className="hb-stack">
<div className="hb-admin-search">
<Input
label={t("admin.users.search")}
value={queryDraft}
onChange={(e) => setQueryDraft(e.target.value)}
placeholder={t("admin.users.searchPh")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applySearch();
}
}}
/>
<Button type="button" onClick={applySearch}>
{t("common.search")}
</Button>
{query ? (
<Button
type="button"
variant="ghost"
onClick={() => {
setQueryDraft("");
setQuery("");
setPage(1);
}}
>
{t("admin.users.clear")}
</Button>
) : null}
</div>
{query ? (
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}>
{t("admin.users.searchActive", { query })}
</p>
) : null}
{users.length === 0 ? (
<EmptyState title={query ? t("admin.users.noMatch") : t("admin.users.none")} />
) : (
<ul className="hb-admin-user-list">
{users.map((u) => {
const role = memberRoleSummary(u, t);
const active = selected?.uid === u.uid;
const suspended = u.status === "suspended";
return (
<li key={u.uid}>
<button
type="button"
className={`hb-admin-user-row${active ? " is-active" : ""}${suspended ? " is-suspended" : ""}`}
onClick={() => {
setSelected(u);
setError("");
}}
>
<div className="hb-admin-user-row__main">
<strong>{u.display_name}</strong>
<span className="text-muted">{u.email}</span>
<span className="hb-admin-user-row__uid text-muted">
uid · {u.uid}
</span>
</div>
<div className="hb-admin-user-row__tags">
{suspended ? (
<Badge tone="danger">{t("admin.users.suspended")}</Badge>
) : null}
<Badge tone={role.isAdmin ? "brand" : "neutral"}>
{role.primaryLabel}
</Badge>
<Badge tone={u.email_verified ? "success" : "warning"}>
{u.email_verified ? t("role.verified") : t("role.unverified")}
</Badge>
</div>
</button>
</li>
);
})}
</ul>
)}
<Pager
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
showWhenSingle
/>
</div>
</Card>
<Card title={t("admin.users.detail")}>
{!selected ? (
<p className="text-muted" style={{ margin: 0 }}>
{t("admin.users.pick")}
</p>
) : (
<div className="hb-stack">
<div className="hb-admin-detail-head">
<div>
<h2 className="hb-admin-detail-name">{selected.display_name}</h2>
<p className="text-muted" style={{ margin: "0.2rem 0 0" }}>
{selected.email}
</p>
</div>
<div className="hb-inline-badges">
{selected.uid === member.uid ? <Badge tone="brand">{t("admin.users.you")}</Badge> : null}
{isSuspended ? (
<Badge tone="danger">{t("admin.users.suspended")}</Badge>
) : (
<Badge tone="success">{t("admin.users.active")}</Badge>
)}
</div>
</div>
<dl className="hb-admin-dl">
<div>
<dt>{t("admin.users.status")}</dt>
<dd>{isSuspended ? t("admin.users.suspended") : t("admin.users.active")}</dd>
</div>
<div>
<dt>{t("admin.users.role")}</dt>
<dd>{selected.roles.map((r) => roleLabel(r, t)).join(t("common.listSep"))}</dd>
</div>
<div>
<dt>{t("admin.users.emailVerify")}</dt>
<dd>
{selected.email_verified ? t("role.verified") : t("role.unverified")}
</dd>
</div>
<div>
<dt>{t("admin.users.bio")}</dt>
<dd>{selected.bio?.trim() || t("common.dash")}</dd>
</div>
<div>
<dt>{t("admin.users.timezone")}</dt>
<dd>{selected.timezone || t("common.dash")}</dd>
</div>
<div>
<dt>{t("admin.users.notifyEmail")}</dt>
<dd>{selected.notify_email ? t("admin.users.on") : t("admin.users.off")}</dd>
</div>
<div>
<dt>uid</dt>
<dd>
<code>{selected.uid}</code>
</dd>
</div>
<div>
<dt>{t("admin.users.createdAt")}</dt>
<dd>{formatLocalDateTime(selected.created_at)}</dd>
</div>
<div>
<dt>{t("admin.users.updatedAt")}</dt>
<dd>{formatLocalDateTime(selected.updated_at)}</dd>
</div>
</dl>
{/* 停權 */}
<div className="hb-admin-suspend">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.accountStatus")}
</p>
<Button
type="button"
variant={isSuspended ? "secondary" : "danger"}
disabled={Boolean(busy) || selected.uid === member.uid}
onClick={() => void toggleSuspended(selected)}
>
{busy === `suspend:${selected.uid}`
? t("admin.users.updating")
: isSuspended
? t("admin.users.unsuspend")
: t("admin.users.suspend")}
</Button>
</div>
{/* 用量:方案 + 不擋額度 */}
<div className="hb-admin-usage">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.usageTitle")}
</p>
{usagePrefs ? (
<>
<div className="hb-admin-usage__row">
<Select
label={t("admin.users.plan")}
value={usagePrefs.plan_id}
disabled={Boolean(busy) || isSuspended}
onChange={(e) => void saveUsagePlan(e.target.value as PlanId)}
>
{(Object.keys(PLANS) as PlanId[]).map((id) => (
<option key={id} value={id}>
{t("admin.users.planOption", {
name: PLANS[id].name,
credits: PLANS[id].monthly_credits,
})}
</option>
))}
</Select>
{usagePrefs.unlimited ? (
<Badge tone="brand">{t("admin.users.unlimited")}</Badge>
) : (
<Badge tone="neutral">{t("admin.users.byPlan")}</Badge>
)}
</div>
<Button
type="button"
variant={usagePrefs.unlimited ? "secondary" : "primary"}
disabled={Boolean(busy) || isSuspended}
onClick={() => void saveUsageUnlimited(!usagePrefs.unlimited)}
>
{busy === `usage:${selected.uid}`
? t("admin.users.updating")
: usagePrefs.unlimited
? t("admin.users.setLimited")
: t("admin.users.setUnlimited")}
</Button>
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("admin.users.unlimitedHint")}
</p>
</>
) : (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("admin.users.loadingUsage")}
</p>
)}
</div>
{/* 權限 */}
<div className="hb-admin-roles">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.assignRoles")}
</p>
<label className="hb-check-row">
<input
type="checkbox"
checked={roleDraft.includes("member")}
disabled
readOnly
/>
<span>{t("admin.users.memberBase", { role: t("role.member") })}</span>
</label>
<label className="hb-check-row">
<input
type="checkbox"
checked={roleDraft.includes("admin")}
onChange={(e) => {
if (e.target.checked) {
setRoleDraft(["member", "admin"]);
} else {
setRoleDraft(["member"]);
}
}}
/>
<span>{t("admin.users.adminDesc", { role: t("role.admin") })}</span>
</label>
<Button
type="button"
disabled={Boolean(busy) || isSuspended}
onClick={() => void saveRoles(selected)}
>
{busy === `roles:${selected.uid}` ? t("admin.users.saving") : t("admin.users.saveRoles")}
</Button>
</div>
<div className="hb-admin-actions">
<Button
type="button"
variant={selected.email_verified ? "ghost" : "primary"}
disabled={Boolean(busy)}
onClick={() => void toggleVerified(selected)}
>
{busy === `verify:${selected.uid}`
? t("admin.users.updating")
: selected.email_verified
? t("admin.users.markUnverifiedBtn")
: t("admin.users.markVerifiedBtn")}
</Button>
<Button
type="button"
variant="danger"
disabled={Boolean(busy) || isSuspended}
onClick={() => void doReset(selected, false)}
>
{busy === `reset:${selected.uid}` ? t("admin.users.resetting") : t("admin.users.resetTemp")}
</Button>
</div>
<div className="hb-admin-custom-pw">
<Input
label={t("admin.users.customPw")}
type="text"
value={customPw}
onChange={(e) => setCustomPw(e.target.value)}
placeholder={t("admin.users.customPwPh")}
disabled={isSuspended}
/>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || isSuspended || customPw.trim().length < 4}
onClick={() => void doReset(selected, true)}
>
{t("admin.users.resetWithCustom")}
</Button>
</div>
</div>
)}
</Card>
</div>
</>
);
}

View File

@ -0,0 +1,670 @@
import { useEffect, useMemo, useState } from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, EmptyState, Input, Pager, Textarea } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { Brand, BrandProduct } from "../domain/types";
import { newId } from "../lib/id";
import { pageSlice } from "../lib/pagination";
import { nowUnixNano } from "../lib/time";
import { useI18n } from "../i18n/I18nContext";
const emptyProductForm = {
label: "",
product_context: "",
pain_points: "",
match_tags: "",
placement_url: "",
};
type DetailTab = "brand" | "products";
const BRAND_PAGE = 12;
const PRODUCT_PAGE = 8;
/** 品牌庫:手機橫向選牌 → 下方編輯;桌面左列表右焦點 */
export function BrandsPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const [brandId, setBrandId] = useState("");
const [detailTab, setDetailTab] = useState<DetailTab>("brand");
const [showCreate, setShowCreate] = useState(false);
const [showProductForm, setShowProductForm] = useState(false);
const [brandQuery, setBrandQuery] = useState("");
const [brandPage, setBrandPage] = useState(1);
const [productQuery, setProductQuery] = useState("");
const [productPage, setProductPage] = useState(1);
const [newBrandName, setNewBrandName] = useState("");
const [editName, setEditName] = useState("");
const [editBrief, setEditBrief] = useState("");
const [editAudience, setEditAudience] = useState("");
const [editGoals, setEditGoals] = useState("");
const [productForm, setProductForm] = useState(emptyProductForm);
const [importUrl, setImportUrl] = useState("");
const [editingProductId, setEditingProductId] = useState<string | null>(null);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
void (async () => {
const [list, active] = await Promise.all([
repos.scout.listBrands(),
repos.scout.getActiveBrandId(),
]);
setBrands(list);
const next = list.find((b) => b.id === brandId)?.id || active || list[0]?.id || "";
setBrandId(next);
if (next) setProducts(await repos.scout.listProducts(next));
else setProducts([]);
})();
}, [repos.scout, tick]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!brandId) {
setProducts([]);
return;
}
void repos.scout.listProducts(brandId).then(setProducts);
}, [brandId, repos.scout]);
const selected = useMemo(() => brands.find((b) => b.id === brandId) || null, [brands, brandId]);
const filteredBrands = useMemo(() => {
const q = brandQuery.trim().toLowerCase();
if (!q) return brands;
return brands.filter(
(b) =>
b.display_name.toLowerCase().includes(q) ||
(b.brief || "").toLowerCase().includes(q),
);
}, [brands, brandQuery]);
const pagedBrands = useMemo(
() => pageSlice(filteredBrands, brandPage, BRAND_PAGE),
[filteredBrands, brandPage],
);
const filteredProducts = useMemo(() => {
const q = productQuery.trim().toLowerCase();
if (!q) return products;
return products.filter(
(p) =>
p.label.toLowerCase().includes(q) ||
p.product_context.toLowerCase().includes(q) ||
p.match_tags.some((t) => t.toLowerCase().includes(q)) ||
p.pain_points.some((t) => t.toLowerCase().includes(q)),
);
}, [products, productQuery]);
const pagedProducts = useMemo(
() => pageSlice(filteredProducts, productPage, PRODUCT_PAGE),
[filteredProducts, productPage],
);
useEffect(() => {
setBrandPage(1);
}, [brandQuery]);
useEffect(() => {
setProductPage(1);
}, [productQuery, brandId]);
useEffect(() => {
if (!selected) {
setEditName("");
setEditBrief("");
setEditAudience("");
setEditGoals("");
return;
}
setEditName(selected.display_name);
setEditBrief(selected.brief || "");
setEditAudience(selected.target_audience || "");
setEditGoals(selected.goals || "");
}, [selected]);
async function selectBrand(id: string) {
setBrandId(id);
await repos.scout.setActiveBrandId(id);
setEditingProductId(null);
setProductForm(emptyProductForm);
setShowProductForm(false);
setImportUrl("");
setMessage("");
}
async function createBrand() {
const name = newBrandName.trim();
if (!name) {
setMessage(t("brands.needName"));
return;
}
setBusy("create");
setMessage("");
try {
const created = await repos.scout.createBrand({ display_name: name });
setNewBrandName("");
setShowCreate(false);
const list = await repos.scout.listBrands();
setBrands(list);
setBrandId(created.id);
setBrandQuery("");
setBrandPage(1);
setDetailTab("brand");
setMessage(t("brands.created", { name: created.display_name }));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.createFail"));
} finally {
setBusy("");
}
}
async function saveBrand() {
if (!selected) return;
setBusy("save");
setMessage("");
try {
const updated = await repos.scout.saveBrand({
...selected,
display_name: editName,
brief: editBrief,
target_audience: editAudience,
goals: editGoals,
});
setBrands(await repos.scout.listBrands());
setBrandId(updated.id);
setMessage(t("brands.saved"));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.saveFail"));
} finally {
setBusy("");
}
}
async function deleteBrand() {
if (!selected) return;
if (!window.confirm(t("brands.confirmDelete", { name: selected.display_name }))) return;
setBusy("del");
try {
await repos.scout.removeBrand(selected.id);
const list = await repos.scout.listBrands();
setBrands(list);
setBrandId(list[0]?.id || "");
setMessage(t("brands.deleted"));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.deleteFail"));
} finally {
setBusy("");
}
}
function startNewProduct() {
setEditingProductId(null);
setProductForm(emptyProductForm);
setImportUrl("");
setShowProductForm(true);
}
function startEditProduct(p: BrandProduct) {
setEditingProductId(p.id);
setProductForm({
label: p.label,
product_context: p.product_context,
pain_points: p.pain_points.join("\n"),
match_tags: p.match_tags.join(", "),
placement_url: p.placement_url || "",
});
setImportUrl(p.placement_url || "");
setShowProductForm(true);
}
function cancelEditProduct() {
setEditingProductId(null);
setProductForm(emptyProductForm);
setImportUrl("");
setShowProductForm(false);
}
async function importFromUrl() {
if (!brandId) return;
setBusy("import");
setMessage("");
try {
const draft = await repos.scout.importProductFromUrl(importUrl || productForm.placement_url);
setProductForm({
label: draft.label,
product_context: draft.product_context,
pain_points: draft.pain_points.join("\n"),
match_tags: draft.match_tags.join(", "),
placement_url: draft.placement_url,
});
setImportUrl(draft.placement_url);
setMessage(draft.source_note);
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.fetchFail"));
} finally {
setBusy("");
}
}
async function saveProduct() {
if (!brandId) return;
const label = productForm.label.trim();
const ctx = productForm.product_context.trim();
if (!label || !ctx) {
setMessage(t("brands.needLabelContext"));
return;
}
setBusy("product");
setMessage("");
try {
const pain_points = productForm.pain_points
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
const match_tags = productForm.match_tags
.split(/[,\s、]+/)
.map((s) => s.trim())
.filter(Boolean);
const existing = editingProductId
? products.find((p) => p.id === editingProductId)
: null;
await repos.scout.saveProduct({
id: editingProductId || newId("prod"),
brand_id: brandId,
label,
product_context: ctx,
pain_points,
match_tags,
placement_url: productForm.placement_url.trim() || undefined,
created_at: existing?.created_at || nowUnixNano(),
updated_at: nowUnixNano(),
});
setProducts(await repos.scout.listProducts(brandId));
cancelEditProduct();
setMessage(editingProductId ? t("brands.productUpdated") : t("brands.productAdded"));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.saveFail"));
} finally {
setBusy("");
}
}
async function removeProduct(id: string) {
if (!window.confirm(t("brands.confirmDeleteProduct"))) return;
setBusy("delProd");
try {
await repos.scout.removeProduct(id);
setProducts(await repos.scout.listProducts(brandId));
if (editingProductId === id) cancelEditProduct();
setMessage(t("brands.deleted"));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("brands.deleteFail"));
} finally {
setBusy("");
}
}
return (
<>
<PageHeader title={t("brands.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
<div className="hb-brands">
{/* 選牌區:手機橫向 chip桌面左側列表 */}
<aside className="hb-brands__rail" aria-label={t("brands.railAria")}>
<div className="hb-brands__rail-head">
<div className="hb-brands__rail-title">
<span className="hb-brands__rail-label">{t("brands.railLabel")}</span>
{brands.length > 0 ? (
<span className="hb-brands__count">{brands.length}</span>
) : null}
</div>
<Button
type="button"
variant={showCreate ? "ghost" : "secondary"}
onClick={() => {
setShowCreate((v) => !v);
setMessage("");
}}
>
{showCreate ? t("common.cancel") : t("brands.add")}
</Button>
</div>
{showCreate ? (
<div className="hb-brands__create">
<Input
label={t("brands.brandName")}
value={newBrandName}
onChange={(e) => setNewBrandName(e.target.value)}
placeholder={t("brands.brandNamePh")}
onKeyDown={(e) => {
if (e.key === "Enter") void createBrand();
}}
/>
<Button type="button" onClick={() => void createBrand()} disabled={busy === "create"}>
{busy === "create" ? t("brands.creating") : t("brands.createBrand")}
</Button>
</div>
) : null}
{brands.length > 0 ? (
<div className="hb-brands__search">
<Input
label=""
aria-label={t("brands.searchAria")}
value={brandQuery}
onChange={(e) => setBrandQuery(e.target.value)}
placeholder={t("brands.searchPh")}
/>
</div>
) : null}
{brands.length === 0 ? (
<p className="hb-brands__empty-hint">{t("brands.empty")}</p>
) : filteredBrands.length === 0 ? (
<p className="hb-brands__empty-hint">{t("brands.noMatch")}</p>
) : (
<>
<div className="hb-brands__chips" role="listbox" aria-label={t("brands.selectAria")}>
{pagedBrands.map((b) => (
<button
key={b.id}
type="button"
role="option"
aria-selected={b.id === brandId}
className={`hb-brands__chip${b.id === brandId ? " is-active" : ""}`}
onClick={() => void selectBrand(b.id)}
>
<span className="hb-brands__chip-mark" aria-hidden>
{(b.display_name.trim()[0] || "?").toUpperCase()}
</span>
<span className="hb-brands__chip-name">{b.display_name}</span>
</button>
))}
</div>
<Pager
className="hb-pager--compact"
total={filteredBrands.length}
page={brandPage}
pageSize={BRAND_PAGE}
onPageChange={setBrandPage}
/>
</>
)}
</aside>
{/* 焦點編輯 */}
<section className="hb-brands__panel">
{!selected ? (
<EmptyState title={t("brands.pickOne")} />
) : (
<>
<header className="hb-brands__panel-head">
<div className="hb-brands__panel-identity">
<span className="hb-brands__avatar" aria-hidden>
{(selected.display_name.trim()[0] || "?").toUpperCase()}
</span>
<div className="hb-brands__panel-titles">
<h2>{selected.display_name}</h2>
<p className="text-muted">{t("brands.inUseHint")}</p>
</div>
</div>
<Badge tone="brand">{t("brands.inUse")}</Badge>
</header>
<div className="hb-tabs hb-tabs--segment" role="tablist">
<button
type="button"
role="tab"
aria-selected={detailTab === "brand"}
className={`hb-tab ${detailTab === "brand" ? "is-active" : ""}`}
onClick={() => setDetailTab("brand")}
>
{t("brands.tabInfo")}
</button>
<button
type="button"
role="tab"
aria-selected={detailTab === "products"}
className={`hb-tab ${detailTab === "products" ? "is-active" : ""}`}
onClick={() => {
setDetailTab("products");
setShowProductForm(false);
setEditingProductId(null);
}}
>
{products.length ? t("brands.tabProductsN", { n: products.length }) : t("brands.tabProducts")}
</button>
</div>
{detailTab === "brand" ? (
<div className="hb-brands__form">
<Input label={t("brands.displayName")} value={editName} onChange={(e) => setEditName(e.target.value)} />
<Textarea
label={t("brands.brief")}
value={editBrief}
onChange={(e) => setEditBrief(e.target.value)}
rows={3}
placeholder={t("brands.briefPh")}
/>
<Textarea
label={t("brands.audience")}
value={editAudience}
onChange={(e) => setEditAudience(e.target.value)}
rows={3}
placeholder={t("brands.audiencePh")}
/>
<Textarea
label={t("brands.goals")}
value={editGoals}
onChange={(e) => setEditGoals(e.target.value)}
rows={3}
placeholder={t("brands.goalsPh")}
/>
<div className="hb-brands__actions">
<Button type="button" onClick={() => void saveBrand()} disabled={busy === "save"}>
{busy === "save" ? t("brands.saving") : t("common.save")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => void deleteBrand()}
disabled={busy === "del"}
className="hb-brands__danger-ghost"
>
{t("brands.deleteBrand")}
</Button>
</div>
</div>
) : null}
{detailTab === "products" ? (
<div className="hb-brands__products">
{!showProductForm ? (
<>
<div className="hb-brands__products-bar">
{products.length > 0 ? (
<div className="hb-brands__products-search">
<Input
label=""
aria-label={t("brands.searchProductAria")}
value={productQuery}
onChange={(e) => setProductQuery(e.target.value)}
placeholder={t("brands.searchProductPh")}
/>
</div>
) : (
<div />
)}
<Button type="button" onClick={startNewProduct}>
{t("brands.addProduct")}
</Button>
</div>
{products.length === 0 ? (
<EmptyState title={t("brands.noProducts")} />
) : filteredProducts.length === 0 ? (
<EmptyState title={t("brands.noMatch")} />
) : (
<>
<ul className="hb-brands__product-list">
{pagedProducts.map((p) => (
<li key={p.id} className="hb-brands__product-card">
<div className="hb-brands__product-top">
<strong className="hb-brands__product-name">{p.label}</strong>
{p.placement_url ? <Badge tone="brand">{t("brands.hasLink")}</Badge> : null}
</div>
{p.pain_points.length ? (
<p className="hb-brands__product-meta">
<span className="text-muted">{t("brands.painLabel")}</span>
{p.pain_points.join(" · ")}
</p>
) : null}
<p className="hb-brands__product-body">{p.product_context}</p>
{p.match_tags.length ? (
<div className="hb-inline-badges">
{p.match_tags.map((t) => (
<Badge key={t} tone="neutral">
{t}
</Badge>
))}
</div>
) : null}
<div className="hb-brands__product-actions">
<Button
type="button"
variant="secondary"
onClick={() => startEditProduct(p)}
>
{t("common.edit")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => void removeProduct(p.id)}
disabled={busy === "delProd"}
>
{t("common.delete")}
</Button>
</div>
</li>
))}
</ul>
<Pager
total={filteredProducts.length}
page={productPage}
pageSize={PRODUCT_PAGE}
onPageChange={setProductPage}
/>
</>
)}
</>
) : (
<div className="hb-brands__product-form">
<div className="hb-brands__product-form-head">
<h3>{editingProductId ? t("brands.editProduct") : t("brands.newProduct")}</h3>
<Button type="button" variant="ghost" onClick={cancelEditProduct}>
{t("common.cancel")}
</Button>
</div>
<div className="hb-brands__import">
<Input
label={t("brands.importFromUrl")}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
placeholder="https://…"
onKeyDown={(e) => {
if (e.key === "Enter") void importFromUrl();
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => void importFromUrl()}
disabled={busy === "import"}
>
{busy === "import" ? t("brands.fetching") : t("brands.fetch")}
</Button>
</div>
<Input
label={t("brands.displayName")}
value={productForm.label}
onChange={(e) => setProductForm((f) => ({ ...f, label: e.target.value }))}
/>
<Textarea
label={t("brands.pains")}
value={productForm.pain_points}
onChange={(e) =>
setProductForm((f) => ({ ...f, pain_points: e.target.value }))
}
placeholder={t("brands.painsPh")}
rows={3}
/>
<Input
label={t("brands.tags")}
value={productForm.match_tags}
onChange={(e) =>
setProductForm((f) => ({ ...f, match_tags: e.target.value }))
}
placeholder={t("brands.tagsPh")}
/>
<Textarea
label={t("brands.intro")}
value={productForm.product_context}
onChange={(e) =>
setProductForm((f) => ({ ...f, product_context: e.target.value }))
}
rows={4}
/>
<Input
label={t("brands.link")}
value={productForm.placement_url}
onChange={(e) =>
setProductForm((f) => ({ ...f, placement_url: e.target.value }))
}
placeholder="https://…"
/>
<div className="hb-brands__actions">
<Button
type="button"
onClick={() => void saveProduct()}
disabled={busy === "product"}
>
{busy === "product"
? t("brands.saving")
: editingProductId
? t("brands.update")
: t("brands.createItem")}
</Button>
<Button type="button" variant="ghost" onClick={cancelEditProduct}>
{t("common.cancel")}
</Button>
</div>
</div>
)}
</div>
) : null}
</>
)}
</section>
</div>
</>
);
}

View File

@ -0,0 +1,241 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PersonaWorkbench } from "../components/persona/PersonaWorkbench";
import { AccountAvatar, Badge, Button, EmptyState, Pager } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { ThreadsAccount } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { pageSlice } from "../lib/pagination";
import { formatLocalDateTime, formatSessionExpiry, type SessionExpiryKind } from "../lib/time";
const ACCOUNTS_PAGE = 8;
type CrewTab = "accounts" | "personas";
function sessionBadgeTone(kind: SessionExpiryKind): "success" | "warning" | "danger" | "neutral" {
switch (kind) {
case "ok":
return "success";
case "soon":
return "warning";
case "expired":
return "danger";
default:
return "neutral";
}
}
export function CrewPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [tab, setTab] = useState<CrewTab>("accounts");
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [accPage, setAccPage] = useState(1);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const sessionBadgeLabel = useCallback(
(kind: SessionExpiryKind): string => {
switch (kind) {
case "ok":
return t("crew.session.ok");
case "soon":
return t("crew.session.soon");
case "expired":
return t("crew.session.expired");
default:
return t("crew.session.unknown");
}
},
[t],
);
const load = useCallback(async () => {
setAccounts(await repos.accounts.list());
}, [repos.accounts]);
const pagedAccounts = useMemo(
() => pageSlice(accounts, accPage, ACCOUNTS_PAGE),
[accounts, accPage],
);
useEffect(() => {
void load();
}, [load, tick]);
async function connectMock() {
setBusy("create");
setMessage("");
try {
await repos.accounts.createMock();
refresh();
await load();
} finally {
setBusy("");
}
}
async function refreshSession(acc: ThreadsAccount) {
setBusy(`refresh:${acc.id}`);
setMessage("");
try {
await repos.accounts.refreshSession(acc.id);
setMessage(t("crew.msg.refreshed", { user: acc.username }));
refresh();
await load();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail"));
} finally {
setBusy("");
}
}
async function refreshAllSessions() {
if (accounts.length === 0) return;
setBusy("refresh:all");
setMessage("");
try {
for (const acc of accounts) {
await repos.accounts.refreshSession(acc.id);
}
setMessage(t("crew.msg.refreshedAll", { n: accounts.length }));
refresh();
await load();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail"));
} finally {
setBusy("");
}
}
async function removeAccount(acc: ThreadsAccount) {
if (!window.confirm(t("crew.confirmDelete", { user: acc.username }))) {
return;
}
setBusy(`del:${acc.id}`);
setMessage("");
try {
await repos.accounts.remove(acc.id);
refresh();
await load();
} finally {
setBusy("");
}
}
return (
<>
<PageHeader title={t("crew.title")} />
<div className="hb-tabs" role="tablist">
<button
type="button"
className={`hb-tab ${tab === "accounts" ? "is-active" : ""}`}
onClick={() => setTab("accounts")}
>
{t("crew.tab.accounts")}
</button>
<button
type="button"
className={`hb-tab ${tab === "personas" ? "is-active" : ""}`}
onClick={() => setTab("personas")}
>
{t("crew.tab.personas")}
</button>
</div>
{tab === "accounts" ? (
<div className="hb-stack">
<div className="hb-toolbar">
<Button type="button" onClick={() => void connectMock()} disabled={Boolean(busy)}>
{busy === "create" ? t("crew.connecting") : t("crew.connect")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => void refreshAllSessions()}
disabled={Boolean(busy) || accounts.length === 0}
>
{busy === "refresh:all" ? t("crew.refreshing") : t("crew.refreshAll")}
</Button>
</div>
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{accounts.length === 0 ? (
<EmptyState title={t("crew.empty")} />
) : (
<>
<ul className="hb-compact-list">
{pagedAccounts.map((acc) => {
const session = formatSessionExpiry(acc.session_expires_at);
const refreshing = busy === `refresh:${acc.id}`;
const deleting = busy === `del:${acc.id}`;
return (
<li key={acc.id} className="hb-compact-row">
<AccountAvatar account={acc} size="lg" />
<div className="hb-compact-row__body">
<div className="hb-compact-row__title">
<strong>{acc.display_name}</strong>
<span className="text-muted">@{acc.username}</span>
</div>
<div className="hb-inline-badges">
<Badge tone={acc.is_usable ? "success" : "danger"}>{acc.connection}</Badge>
{!acc.is_usable ? <Badge tone="warning">{t("crew.unusable")}</Badge> : null}
<Badge tone={sessionBadgeTone(session.kind)}>{sessionBadgeLabel(session.kind)}</Badge>
</div>
<p className="text-muted hb-compact-row__meta">
{t("crew.expires", { time: session.absolute })}
{session.kind !== "unknown" ? ` · ${session.relative}` : ""}
{acc.session_refreshed_at
? ` · ${t("crew.lastRefresh", { time: formatLocalDateTime(acc.session_refreshed_at) })}`
: ""}
</p>
{acc.error_message ? (
<p className="text-muted hb-compact-row__meta">{acc.error_message}</p>
) : null}
</div>
<div className="hb-compact-row__actions">
<Button
type="button"
variant={session.kind === "expired" || session.kind === "soon" ? "primary" : "ghost"}
className="hb-compact-row__action"
disabled={Boolean(busy)}
onClick={() => void refreshSession(acc)}
>
{refreshing ? t("crew.refreshing") : t("crew.refreshSession")}
</Button>
<Button
type="button"
variant="ghost"
className="hb-compact-row__action"
disabled={Boolean(busy)}
onClick={() => void removeAccount(acc)}
>
{deleting ? "…" : t("common.delete")}
</Button>
</div>
</li>
);
})}
</ul>
<Pager
total={accounts.length}
page={accPage}
pageSize={ACCOUNTS_PAGE}
onPageChange={setAccPage}
/>
</>
)}
</div>
) : (
<PersonaWorkbench />
)}
</>
);
}

View File

@ -0,0 +1,103 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { Button, Card, Input } from "../components/ui";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
export function ForgotPasswordPage() {
const repos = useRepos();
const { t } = useI18n();
const [email, setEmail] = useState("demo@harbor.local");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState<{ message: string; mockPath?: string } | null>(null);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
setDone(null);
try {
const res = await repos.auth.requestPasswordReset(email);
setDone({ message: res.message, mockPath: res.mock_reset_path });
} catch (err) {
setError(err instanceof Error ? err.message : t("forgot.fail"));
} finally {
setBusy(false);
}
}
return (
<div className="hb-login">
<Card title={t("forgot.title")}>
{done ? (
<div className="hb-stack">
<p className="hb-banner-ok" role="status" style={{ margin: 0 }}>
{done.message}
</p>
{done.mockPath ? (
<div className="hb-login-mock-mail">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("forgot.mockMail")}
</p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("forgot.mockHint")}
</p>
<Link to={done.mockPath} className="hb-login-mock-mail__link">
{t("forgot.openReset")}
</Link>
</div>
) : (
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("forgot.checkInbox")}
</p>
)}
<div className="hb-wizard-actions">
<Link to="/login">
<Button type="button" variant="ghost">
{t("forgot.back")}
</Button>
</Link>
<Button
type="button"
variant="ghost"
onClick={() => {
setDone(null);
setError("");
}}
>
{t("forgot.retry")}
</Button>
</div>
</div>
) : (
<form className="hb-stack" onSubmit={(e) => void onSubmit(e)}>
<p className="text-muted" style={{ margin: 0, fontSize: "0.875rem" }}>
{t("forgot.hint")}
</p>
<Input
label={t("login.email")}
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? t("forgot.submitting") : t("forgot.submit")}
</Button>
<Link to="/login" className="hb-login-back">
{t("forgot.back")}
</Link>
</form>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,497 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { OwnPost, ThreadsAccount } from "../domain/types";
import {
buildAccountInsights,
ensureInsightHistory,
fmtDelta,
fmtEngRate,
monthKeyFromDate,
type AccountInsightsReport,
type AccountInsightsSnapshot,
type MonthBucket,
} from "../lib/accountInsights";
import { formatLocalDateTime } from "../lib/time";
import { useI18n } from "../i18n/I18nContext";
function DeltaBadge({ value }: { value: number | null }) {
const { t } = useI18n();
if (value == null) return <Badge tone="neutral">{t("common.dash")}</Badge>;
if (value > 0) return <Badge tone="success">{fmtDelta(value)}</Badge>;
if (value < 0) return <Badge tone="danger">{fmtDelta(value)}</Badge>;
return <Badge tone="neutral">{t("insights.zeroPct")}</Badge>;
}
function MonthBars({
months,
metric,
selectedKey,
onSelect,
}: {
months: MonthBucket[];
metric: "views" | "likes" | "replies" | "posts";
selectedKey: string;
onSelect: (monthKey: string) => void;
}) {
const { t, locale } = useI18n();
const max = Math.max(1, ...months.map((m) => m[metric]));
const labels: Record<typeof metric, string> = {
views: t("insights.metricViews"),
likes: t("insights.metricLikes"),
replies: t("insights.metricReplies"),
posts: t("insights.metricPostsFull"),
};
const loc = locale === "en" ? "en-US" : "zh-TW";
return (
<div className="hb-insights-bars" aria-label={t("insights.barsAria", { metric: labels[metric] })}>
<p className="hb-field__label" style={{ margin: "0 0 0.5rem" }}>
{t("insights.barsLabel", { metric: labels[metric], n: months.length })}
<span className="text-muted" style={{ fontWeight: 500 }}>
{t("insights.clickBar")}
</span>
</p>
<div className="hb-insights-bars__row" role="listbox" aria-label={t("insights.pickMonthAria")}>
{months.map((m) => {
const h = Math.round((m[metric] / max) * 100);
const selected = m.key === selectedKey;
const valStr = m[metric].toLocaleString(loc);
return (
<button
key={m.key}
type="button"
role="option"
aria-selected={selected}
className={`hb-insights-bar${selected ? " is-selected" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
onClick={() => onSelect(m.key)}
title={t("insights.barTitle", {
label: m.label,
value: valStr,
est: m.source === "estimate" ? t("insights.est") : "",
})}
>
<div className="hb-insights-bar__track">
<div
className={`hb-insights-bar__fill${selected ? " is-current" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
style={{ height: `${Math.max(h, m[metric] > 0 ? 6 : 2)}%` }}
/>
</div>
<span className="hb-insights-bar__val">
{metric === "views" && m.views >= 1000
? `${Math.round(m.views / 100) / 10}k`
: m[metric]}
</span>
<span className={`hb-insights-bar__lab${selected ? " is-current" : ""}`}>
{t("insights.monthSuffix", { m: m.label.replace(/^\d+\//, "") })}
</span>
</button>
);
})}
</div>
</div>
);
}
function Sparkline({
months,
metric,
selectedKey,
onSelect,
}: {
months: MonthBucket[];
metric: "views" | "likes";
selectedKey: string;
onSelect: (monthKey: string) => void;
}) {
const { t } = useI18n();
const w = 280;
const h = 56;
const pad = 4;
const vals = months.map((m) => m[metric]);
const max = Math.max(1, ...vals);
const min = Math.min(...vals, 0);
const span = Math.max(1, max - min);
const pts = vals.map((v, i) => {
const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1);
const y = h - pad - ((v - min) / span) * (h - pad * 2);
return `${x},${y}`;
});
const poly = pts.join(" ");
return (
<svg
className="hb-insights-spark"
viewBox={`0 0 ${w} ${h}`}
width="100%"
height={h}
role="img"
aria-label={t("insights.sparkAria")}
>
<polyline
fill="none"
stroke="var(--hb-brand)"
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
points={poly}
/>
{vals.map((v, i) => {
const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1);
const y = h - pad - ((v - min) / span) * (h - pad * 2);
const m = months[i]!;
const selected = m.key === selectedKey;
return (
<g key={m.key} style={{ cursor: "pointer" }} onClick={() => onSelect(m.key)}>
<circle cx={x} cy={y} r={12} fill="transparent" />
<circle
cx={x}
cy={y}
r={selected ? 4.5 : 2.4}
fill={selected ? "var(--hb-brand)" : "var(--hb-surface)"}
stroke="var(--hb-brand)"
strokeWidth="1.5"
/>
</g>
);
})}
</svg>
);
}
export function InsightsPage() {
const repos = useRepos();
const { tick, refresh } = useData();
const { t, locale } = useI18n();
const numLoc = locale === "en" ? "en-US" : "zh-TW";
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [accountId, setAccountId] = useState("");
const [posts, setPosts] = useState<OwnPost[]>([]);
const [metric, setMetric] = useState<"views" | "likes" | "replies" | "posts">("views");
const [busy, setBusy] = useState(false);
/** 分析歷史:選中的月份 */
const [analysisMonth, setAnalysisMonth] = useState(monthKeyFromDate());
const [history, setHistory] = useState<AccountInsightsSnapshot[]>([]);
const [historyTick, setHistoryTick] = useState(0);
useEffect(() => {
void (async () => {
const acc = await repos.accounts.list();
const usable = acc.filter((a) => a.is_usable);
const list = usable.length ? usable : acc;
setAccounts(list);
setAccountId((cur) => cur || list[0]?.id || "");
})();
}, [repos, tick]);
useEffect(() => {
void (async () => {
if (!accountId) {
setPosts([]);
return;
}
setPosts(await repos.ownPosts.list(accountId));
setAnalysisMonth(monthKeyFromDate());
})();
}, [accountId, repos.ownPosts, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
const report: AccountInsightsReport | null = useMemo(() => {
if (!accountId) return null;
return buildAccountInsights(accountId, posts, 6);
}, [accountId, posts]);
// 補齊/更新月度分析歷史
useEffect(() => {
if (!report) {
setHistory([]);
return;
}
const list = ensureInsightHistory(report);
setHistory(list);
setAnalysisMonth((cur) => {
if (list.some((s) => s.month_key === cur)) return cur;
return list[0]?.month_key || monthKeyFromDate();
});
}, [report, historyTick]);
const selectedSnap = useMemo(
() => history.find((s) => s.month_key === analysisMonth) || history[0] || null,
[history, analysisMonth],
);
async function syncPosts() {
if (!accountId) return;
setBusy(true);
try {
setPosts(await repos.ownPosts.sync(accountId));
// 同步後重算本月分析(覆寫)
refresh();
setHistoryTick((n) => n + 1);
// force 在 posts 更新後的下一輪 effect此處 posts 尚未 set 完
} finally {
setBusy(false);
}
}
function selectMonth(key: string) {
setAnalysisMonth(key);
}
const account = accounts.find((a) => a.id === accountId);
return (
<>
<PageHeader title={t("insights.title")} />
<div className="hb-insights-toolbar">
<Select
label={t("insights.account")}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
disabled={!accounts.length}
>
{accounts.length === 0 ? (
<option value="">{t("insights.noAccount")}</option>
) : (
accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
{a.display_name ? ` · ${a.display_name}` : ""}
</option>
))
)}
</Select>
<div className="hb-insights-toolbar__actions">
<Button type="button" variant="ghost" disabled={busy || !accountId} onClick={() => void syncPosts()}>
{busy ? t("insights.syncing") : t("insights.syncPosts")}
</Button>
<Link to="/app/studio?tab=posts">
<Button type="button" variant="ghost">
{t("insights.myPosts")}
</Button>
</Link>
</div>
</div>
{!accountId || !report ? (
<EmptyState
title={t("insights.pickAccount")}
action={
<Link to="/app/crew">
<Button type="button">{t("insights.goAccounts")}</Button>
</Link>
}
/>
) : (
<div className="hb-stack">
{/* 本月總覽 */}
<div className="hb-today-metrics hb-insights-kpis" role="group" aria-label={t("insights.kpiMonth")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthViews")}</span>
<span className="hb-today-metric__value">
{report.current.views.toLocaleString(numLoc)}
</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.views} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthLikes")}</span>
<span className="hb-today-metric__value">{report.current.likes}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.likes} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthReplies")}</span>
<span className="hb-today-metric__value">{report.current.replies}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.replies} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.engRate")}</span>
<span className="hb-today-metric__value">
{fmtEngRate(report.current.engagementRate)}
</span>
<span className="hb-today-metric__hint">
{t("insights.avgNear", { rate: fmtEngRate(report.avgEngagementRate) })} ·{" "}
<DeltaBadge value={report.delta.engagementRate} />
</span>
</div>
</div>
<Card title={t("insights.trendTitle", { user: account?.username || "" })}>
<div className="hb-stack">
<Sparkline
months={report.months}
metric={metric === "likes" ? "likes" : "views"}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
<div className="hb-tabs hb-tabs--sm" role="tablist" aria-label={t("insights.chartMetrics")}>
{(["views", "likes", "replies", "posts"] as const).map((m) => (
<button
key={m}
type="button"
className={`hb-tab ${metric === m ? "is-active" : ""}`}
onClick={() => setMetric(m)}
>
{m === "views"
? t("insights.metricViews")
: m === "likes"
? t("insights.metricLikes")
: m === "replies"
? t("insights.metricReplies")
: t("insights.metricPosts")}
</button>
))}
</div>
<MonthBars
months={report.months}
metric={metric}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
{selectedSnap ? (
<div className="hb-insights-snap-panel">
<div className="hb-insights-snap-meta">
<strong>{t("insights.analysisOf", { label: selectedSnap.month_label })}</strong>
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })}
{selectedSnap.metrics.source === "estimate" ? t("insights.hasEstimate") : ""}
{selectedSnap.delta.views != null
? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) })
: ""}
</span>
<div className="hb-insights-snap-metrics">
<span>
{t("insights.statPosts")} <strong>{selectedSnap.metrics.posts}</strong>
</span>
<span>
{t("insights.statViews")}{" "}
<strong>{selectedSnap.metrics.views.toLocaleString(numLoc)}</strong>
</span>
<span>
{t("insights.statLikes")} <strong>{selectedSnap.metrics.likes}</strong>
</span>
<span>
{t("insights.statReplies")} <strong>{selectedSnap.metrics.replies}</strong>
</span>
<span>
{t("insights.engRate")}{" "}
<strong>{fmtEngRate(selectedSnap.metrics.engagementRate)}</strong>
</span>
</div>
</div>
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.conclusions")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.analysis.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.recommendations")}
</p>
<ul className="hb-insights-bullets hb-insights-bullets--action">
{selectedSnap.recommendations.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
{selectedSnap.top_highlights?.length ? (
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.highlights")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.top_highlights.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
) : null}
<div className="hb-wizard-actions">
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("insights.findTopics")}
</Button>
</Link>
<Link to="/app/scout">
<Button type="button" variant="ghost">
{t("insights.goScout")}
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted" style={{ margin: 0 }}>
{t("insights.selectMonth")}
</p>
)}
</div>
</Card>
<Card title={t("insights.topPosts")}>
{report.topPosts.length === 0 ? (
<EmptyState
title={t("insights.noPosts")}
action={
<Button type="button" onClick={() => void syncPosts()} disabled={busy}>
{t("insights.syncPosts")}
</Button>
}
/>
) : (
<ul className="hb-today-list">
{report.topPosts.map((p, i) => (
<li key={p.id}>
<div className="hb-today-list__item" style={{ cursor: "default" }}>
<span className="hb-today-list__meta">
<Badge tone={i === 0 ? "success" : "neutral"}>#{i + 1}</Badge>
{p.topic_tag ? <Badge tone="brand">{p.topic_tag}</Badge> : null}
<span>
{t("insights.postStats", {
views: p.view_count.toLocaleString(numLoc),
likes: p.like_count,
replies: p.reply_count,
})}
</span>
<span>{formatLocalDateTime(p.published_at)}</span>
</span>
<span className="hb-today-list__text">
{p.text.slice(0, 120)}
{p.text.length > 120 ? "…" : ""}
</span>
{p.insight || p.formula_summary ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{p.insight || p.formula_summary}
</span>
) : null}
{p.permalink ? (
<a href={p.permalink} target="_blank" rel="noreferrer" style={{ fontSize: "0.8rem" }}>
{t("insights.openThreads")}
</a>
) : null}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)}
</>
);
}

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { Job } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { formatLocalDateTime } from "../lib/time";
export function JobDetailPage() {
const { id } = useParams();
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
if (!id) return;
void repos.jobs.get(id).then(setJob);
}, [id, repos.jobs, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
if (!job) {
return (
<EmptyState
title={t("jobs.notFound")}
action={
<Link to="/app/jobs">
<Button variant="ghost">{t("common.back")}</Button>
</Link>
}
/>
);
}
return (
<>
<PageHeader title={job.template_type} description={job.id} />
<Card>
<div className="hb-stack">
<Badge tone={job.status === "succeeded" ? "success" : "brand"}>{job.status}</Badge>
<p>{job.progress_summary}</p>
<div className="hb-progress">
<div className="hb-progress__bar" style={{ width: `${job.progress_percent}%` }} />
</div>
<p className="text-muted">{t("jobs.progress", { n: job.progress_percent })}</p>
<p className="text-muted">
{t("jobs.updated", { time: formatLocalDateTime(job.updated_at) })}
</p>
{job.error ? <p className="hb-form-error">{job.error}</p> : null}
<Link to="/app/jobs">
<Button variant="ghost" type="button">
{t("jobs.backList")}
</Button>
</Link>
</div>
</Card>
</>
);
}

View File

@ -0,0 +1,112 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { Job } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { formatLocalDateTime } from "../lib/time";
/** 首屏顯示筆數;太多時用「載入更多」而不是分頁 */
const INITIAL = 15;
const STEP = 15;
export function JobsPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [jobs, setJobs] = useState<Job[]>([]);
const [visible, setVisible] = useState(INITIAL);
const [busy, setBusy] = useState(false);
useEffect(() => {
void repos.jobs.list().then(setJobs);
}, [repos.jobs, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
const sorted = useMemo(
() => jobs.slice().sort((a, b) => b.updated_at - a.updated_at),
[jobs],
);
// 列表變短時收回可見量,避免空白
useEffect(() => {
setVisible((v) => Math.min(Math.max(INITIAL, v), Math.max(INITIAL, sorted.length)));
}, [sorted.length]);
const shown = sorted.slice(0, visible);
const hasMore = visible < sorted.length;
async function startDemo() {
setBusy(true);
try {
await repos.jobs.startDemo();
setVisible(INITIAL);
refresh();
} finally {
setBusy(false);
}
}
return (
<>
<PageHeader title={t("jobs.title")} />
<Button type="button" onClick={() => void startDemo()} disabled={busy}>
{t("jobs.startDemo")}
</Button>
{sorted.length === 0 ? (
<EmptyState title={t("jobs.empty")} />
) : (
<div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("jobs.total", { n: sorted.length })}
{hasMore ? t("jobs.showing", { n: shown.length }) : ""}
</p>
{shown.map((job) => (
<Card key={job.id}>
<div className="hb-list-row">
<div>
<strong>{job.template_type}</strong>
<p className="text-muted">{job.progress_summary}</p>
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{job.progress_percent}% · {formatLocalDateTime(job.updated_at)}
</p>
</div>
<div className="hb-list-row__actions">
<Badge
tone={
job.status === "succeeded"
? "success"
: job.status === "failed"
? "danger"
: "brand"
}
>
{job.status}
</Badge>
<Link to={`/app/jobs/${job.id}`}>
<Button variant="ghost" type="button">
{t("jobs.detail")}
</Button>
</Link>
</div>
</div>
</Card>
))}
{hasMore ? (
<div className="hb-wizard-actions">
<Button type="button" variant="secondary" onClick={() => setVisible((v) => v + STEP)}>
{t("jobs.loadMore", { n: sorted.length - visible })}
</Button>
</div>
) : null}
</div>
)}
</>
);
}

View File

@ -0,0 +1,86 @@
import { useState, type FormEvent } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { BrandMark, Button, Card, Input } from "../components/ui";
import { useI18n } from "../i18n/I18nContext";
export function LoginPage() {
const { member, login, loading } = useAuth();
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: string } | null)?.from || "/app/today";
const [email, setEmail] = useState("demo@harbor.local");
const [password, setPassword] = useState("demo");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
if (!loading && member) {
return <Navigate to={from} replace />;
}
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await login(email, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
}
return (
<div className="hb-login">
<Card title={t("login.title")}>
<div className="hb-login-brand">
<BrandMark size={40} title={t("login.brandTitle")} />
<div className="hb-login-brand__text">
<strong>{t("app.name")}</strong>
<span className="text-muted display-en">{t("app.nameEn")}</span>
</div>
</div>
<form className="hb-stack" onSubmit={(e) => void onSubmit(e)}>
<Input
label={t("login.email")}
name="email"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div className="hb-login-password-field">
<Input
label={t("login.password")}
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Link to="/forgot-password" className="hb-login-forgot">
{t("login.forgot")}
</Link>
</div>
<p className="text-muted" style={{ fontSize: "0.875rem", margin: 0 }}>
{t("login.mockHint")}
</p>
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={busy || loading}>
{busy ? t("login.submitting") : t("login.submit")}
</Button>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { OutboxBundle, ThreadsAccount } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
export function OutboxDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [bundle, setBundle] = useState<OutboxBundle | null>(null);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!id) return;
const [b, acc] = await Promise.all([repos.outbox.get(id), repos.accounts.list()]);
setBundle(b);
setAccounts(acc);
}, [id, repos.outbox, repos.accounts]);
useEffect(() => {
void load();
}, [load, tick]);
async function run(action: () => Promise<OutboxBundle>) {
setBusy(true);
setError("");
try {
await action();
refresh();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : t("outbox.detail.opFail"));
} finally {
setBusy(false);
}
}
async function removeBundle() {
if (!bundle) return;
if (!window.confirm(t("outbox.confirmDelete", { title: bundle.title }))) return;
setBusy(true);
setError("");
try {
await repos.outbox.remove(bundle.id);
refresh();
navigate("/app/outbox");
} catch (e) {
setError(e instanceof Error ? e.message : t("outbox.deleteFail"));
setBusy(false);
}
}
if (!id) return <EmptyState title={t("outbox.detail.missingId")} />;
if (!bundle) return <EmptyState title={t("outbox.detail.notFound")} />;
const nameOf = (accountId: string) =>
accounts.find((a) => a.id === accountId)?.display_name || accountId;
return (
<>
<PageHeader title={bundle.title} description={bundle.status} />
<div className="hb-wizard-actions">
<Button type="button" disabled={busy} onClick={() => void run(() => repos.outbox.simulateSuccess(bundle.id))}>
{t("outbox.detail.markAllOk")}
</Button>
<Button
type="button"
variant="danger"
disabled={busy}
onClick={() => void run(() => repos.outbox.simulateRootFail(bundle.id))}
>
{t("outbox.detail.markRootFail")}
</Button>
<Button type="button" variant="danger" disabled={busy} onClick={() => void removeBundle()}>
{busy ? t("outbox.detail.processing") : t("outbox.detail.delete")}
</Button>
<Link to="/app/outbox">
<Button type="button" variant="ghost">
{t("outbox.detail.back")}
</Button>
</Link>
</div>
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<div className="hb-stack">
{bundle.steps.map((step, index) => (
<Card key={step.id}>
<div className="hb-list-row">
<div>
<strong>
{step.kind === "root" ? t("outbox.detail.root") : t("outbox.detail.replyN", { n: index })} ·{" "}
{nameOf(step.account_id)}
</strong>
<p>{step.text}</p>
{step.error ? <p className="hb-form-error">{step.error}</p> : null}
</div>
<div className="hb-list-row__actions">
<Badge
tone={
step.status === "published"
? "success"
: step.status === "failed" || step.status === "blocked"
? "danger"
: "neutral"
}
>
{step.status}
</Badge>
{(step.status === "failed" || step.status === "blocked") && (
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={() => void run(() => repos.outbox.retryStep(bundle.id, step.id))}
>
{t("outbox.detail.retry")}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</>
);
}

View File

@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Pager } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { OutboxBundle, OutboxBundleStatus } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { pageSlice } from "../lib/pagination";
import { formatLocalDateTime } from "../lib/time";
const OUTBOX_PAGE = 10;
type OutboxTab = "active" | "history";
function progress(b: OutboxBundle) {
const done = b.steps.filter((s) => s.status === "published").length;
return `${done}/${b.steps.length}`;
}
/** 尚未收工:仍在排程/發送/有失敗可處理 */
function isActiveQueue(status: OutboxBundleStatus): boolean {
return status === "scheduling" || status === "active" || status === "partial_failed";
}
function statusTone(status: OutboxBundleStatus): "success" | "danger" | "brand" | "neutral" | "warning" {
switch (status) {
case "completed":
return "success";
case "partial_failed":
return "danger";
case "cancelled":
return "neutral";
case "scheduling":
return "warning";
default:
return "brand";
}
}
export function OutboxPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [items, setItems] = useState<OutboxBundle[]>([]);
const [tab, setTab] = useState<OutboxTab>("active");
const [page, setPage] = useState(1);
const [busyId, setBusyId] = useState("");
const [message, setMessage] = useState("");
function statusLabel(status: OutboxBundleStatus): string {
switch (status) {
case "scheduling":
return t("outbox.status.scheduling");
case "active":
return t("outbox.status.active");
case "completed":
return t("outbox.status.completed");
case "partial_failed":
return t("outbox.status.partial_failed");
case "cancelled":
return t("outbox.status.cancelled");
default:
return status;
}
}
useEffect(() => {
void repos.outbox.list().then(setItems);
}, [repos.outbox, tick]);
useEffect(() => {
setPage(1);
}, [tab]);
const activeItems = useMemo(
() => items.filter((i) => isActiveQueue(i.status)).sort((a, b) => b.updated_at - a.updated_at),
[items],
);
const historyItems = useMemo(
() =>
items
.filter((i) => !isActiveQueue(i.status))
.sort((a, b) => b.updated_at - a.updated_at),
[items],
);
const tabItems = tab === "active" ? activeItems : historyItems;
const shown = useMemo(
() => pageSlice(tabItems, page, OUTBOX_PAGE),
[tabItems, page],
);
async function removeItem(item: OutboxBundle) {
if (!window.confirm(t("outbox.confirmDelete", { title: item.title }))) return;
setBusyId(item.id);
setMessage("");
try {
await repos.outbox.remove(item.id);
setItems(await repos.outbox.list());
setMessage(t("outbox.deleted", { title: item.title }));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("outbox.deleteFail"));
} finally {
setBusyId("");
}
}
return (
<>
<PageHeader title={t("outbox.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
<div className="hb-tabs hb-tabs--sm" role="tablist" aria-label={t("outbox.tabsAria")} style={{ marginBottom: "0.85rem" }}>
<button
type="button"
role="tab"
aria-selected={tab === "active"}
className={`hb-tab ${tab === "active" ? "is-active" : ""}`}
onClick={() => setTab("active")}
>
{t("outbox.tab.active")}
<span className="hb-tab__count"> {activeItems.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={tab === "history"}
className={`hb-tab ${tab === "history" ? "is-active" : ""}`}
onClick={() => setTab("history")}
>
{t("outbox.tab.history")}
<span className="hb-tab__count"> {historyItems.length}</span>
</button>
</div>
{items.length === 0 ? (
<EmptyState
title={t("outbox.empty")}
action={
<Link to="/app/studio/new">
<Button type="button">{t("today.newThread")}</Button>
</Link>
}
/>
) : shown.length === 0 ? (
<EmptyState
title={tab === "active" ? t("outbox.activeEmpty") : t("outbox.historyEmpty")}
action={
tab === "active" && historyItems.length > 0 ? (
<Button type="button" variant="ghost" onClick={() => setTab("history")}>
{t("outbox.historyN", { n: historyItems.length })}
</Button>
) : tab === "active" ? (
<Link to="/app/studio/new">
<Button type="button">{t("today.newThread")}</Button>
</Link>
) : activeItems.length > 0 ? (
<Button type="button" variant="ghost" onClick={() => setTab("active")}>
{t("outbox.backActive", { n: activeItems.length })}
</Button>
) : undefined
}
/>
) : (
<div className="hb-stack">
{shown.map((item) => (
<Card key={item.id}>
<div className="hb-list-row">
<div>
<strong>{item.title}</strong>
<p className="text-muted">
{t("outbox.progress", { progress: progress(item) })} · {formatLocalDateTime(item.updated_at)}
</p>
</div>
<div className="hb-list-row__actions">
<Badge tone={statusTone(item.status)}>{statusLabel(item.status)}</Badge>
<Link to={`/app/outbox/${item.id}`}>
<Button variant="ghost" type="button">
{t("outbox.detail")}
</Button>
</Link>
<Button
type="button"
variant="danger"
disabled={busyId === item.id}
onClick={() => void removeItem(item)}
>
{busyId === item.id ? t("outbox.deleting") : t("common.delete")}
</Button>
</div>
</div>
</Card>
))}
<Pager
total={tabItems.length}
page={page}
pageSize={OUTBOX_PAGE}
onPageChange={setPage}
/>
</div>
)}
</>
);
}

View File

@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { Button, Card, Input } from "../components/ui";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
import { getPlanRights, planCtaLabel } from "../lib/planRights";
import { PLANS, type PlanId } from "../lib/usageMeter";
function isPlanId(v: string | null): v is PlanId {
return v === "free" || v === "starter" || v === "pro";
}
/**
* + + mock
*/
export function PlanCheckoutPage() {
const repos = useRepos();
const { member } = useAuth();
const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [params] = useSearchParams();
const planParam = params.get("plan");
const planId: PlanId | null = isPlanId(planParam) ? planParam : null;
const plan = planId ? PLANS[planId] : null;
const rights = planId ? getPlanRights(planId, t) : null;
const [currentId, setCurrentId] = useState<PlanId>("free");
const [cardName, setCardName] = useState(member?.display_name || "");
const [cardLast4, setCardLast4] = useState("4242");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
void repos.usage.getPlanId().then(setCurrentId);
}, [repos.usage]);
const free = plan?.price_twd === 0;
const already = planId != null && currentId === planId;
if (!member) return <Navigate to="/login" replace />;
if (!plan || !planId || !rights) {
return (
<>
<PageHeader title={t("checkout.title")} />
<Card>
<p className="text-muted" style={{ margin: 0 }}>
{t("checkout.pickFirst")}
</p>
<div style={{ marginTop: "0.75rem" }}>
<Link to="/app/usage/plans">
<Button type="button">{t("checkout.viewPlans")}</Button>
</Link>
</div>
</Card>
</>
);
}
async function submit() {
if (!planId || already) return;
setError("");
if (!free) {
const digits = cardLast4.replace(/\D/g, "");
if (digits.length < 4) {
setError(t("checkout.cardLast4Required"));
return;
}
if (!cardName.trim()) {
setError(t("checkout.cardNameRequired"));
return;
}
}
setBusy(true);
try {
await repos.usage.purchasePlan(planId, {
mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`,
});
navigate("/app/usage", {
replace: true,
state: { purchaseOk: planId },
});
} catch (e) {
setError(e instanceof Error ? e.message : t("checkout.fail"));
} finally {
setBusy(false);
}
}
const actionLabel = free
? t("checkout.confirmFree")
: t("checkout.payAndAction", {
action: planCtaLabel(currentId, planId, t),
price: formatPlanPrice(plan.price_twd),
});
return (
<>
<PageHeader title={t("checkout.title")} />
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<div className="hb-checkout">
<div className="hb-checkout__main hb-stack">
<Card>
<div className="hb-checkout__order">
<div>
<p className="hb-usage-checkout__label">{t("checkout.subscribe")}</p>
<p className="hb-usage-checkout__name">{plan.name}</p>
<p
className="text-muted"
style={{ margin: "0.35rem 0 0", fontSize: "var(--hb-text-sm)" }}
>
{rights.headline}
</p>
</div>
<div className="hb-checkout__price-block">
<p className="hb-checkout__big-price">
{formatPlanPrice(plan.price_twd)}
<span className="hb-checkout__period">{t("checkout.perMonth")}</span>
</p>
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("checkout.monthlyCredits", { n: plan.monthly_credits })}
</p>
</div>
</div>
<div className="hb-usage-checkout__rights">
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.youGet")}</h3>
<ul className="hb-usage-checkout__list">
{rights.rights.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.quota")}</h3>
<ul className="hb-usage-checkout__list">
{rights.quota.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
<section className="hb-usage-checkout__block">
<h3 className="hb-usage-checkout__h">{t("checkout.notes")}</h3>
<ul className="hb-usage-checkout__list hb-usage-checkout__list--notes">
{rights.notes.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
</div>
</Card>
{!free && !already ? (
<Card>
<div className="hb-stack">
<Input
label={t("checkout.cardholder")}
value={cardName}
onChange={(e) => setCardName(e.target.value)}
autoComplete="cc-name"
disabled={busy}
/>
<Input
label={t("checkout.cardLast4")}
value={cardLast4}
onChange={(e) => setCardLast4(e.target.value.replace(/\D/g, "").slice(0, 4))}
inputMode="numeric"
maxLength={4}
placeholder="4242"
disabled={busy}
/>
</div>
</Card>
) : null}
</div>
<aside className="hb-checkout__aside">
<Card>
<p className="hb-usage-checkout__label">{t("checkout.amountDue")}</p>
<p className="hb-checkout__big-price">
{formatPlanPrice(plan.price_twd)}
<span className="hb-checkout__period">{t("checkout.perMonth")}</span>
</p>
<p className="text-muted" style={{ margin: "0.25rem 0 1rem", fontSize: "var(--hb-text-sm)" }}>
{t("checkout.billedMonthly", { name: plan.name })}
</p>
{already ? (
<p className="hb-banner-ok" style={{ margin: "0 0 0.75rem" }}>
{t("checkout.already")}
</p>
) : null}
<Button type="button" disabled={busy || already} onClick={() => void submit()}>
{busy
? t("checkout.processing")
: already
? t("checkout.currentPlan")
: actionLabel}
</Button>
<div className="hb-checkout__links">
<Link to="/app/usage/plans">{t("checkout.pickOther")}</Link>
<Link to="/app/usage">{t("checkout.cancel")}</Link>
</div>
</Card>
</aside>
</div>
</>
);
}

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { PlanPricingGrid } from "../components/usage/PlanPricingGrid";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
import type { PlanId } from "../lib/usageMeter";
import { PLANS } from "../lib/usageMeter";
/**
* SaaS
*/
export function PlansPage() {
const repos = useRepos();
const { member } = useAuth();
const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [currentId, setCurrentId] = useState<PlanId | null>(null);
useEffect(() => {
void repos.usage.getPlanId().then(setCurrentId);
}, [repos.usage]);
if (!member) return null;
const current = currentId ?? "free";
return (
<>
<PageHeader title={t("plans.title")} />
<div className="hb-stack hb-plans-page">
<div className="hb-plans-page__current">
<span className="text-muted">{t("plans.current")}</span>
<strong>{PLANS[current].name}</strong>
<span className="text-muted">
{formatPlanPrice(PLANS[current].price_twd)}
{t("plans.perMonth")}
</span>
<span className="text-muted">
{t("plans.monthlyCredits", { n: PLANS[current].monthly_credits })}
</span>
<Link to="/app/usage" className="hb-plans-page__back">
{t("plans.usageLink")}
</Link>
</div>
<PlanPricingGrid
currentId={current}
formatPrice={formatPlanPrice}
onChoose={(id) => navigate(`/app/usage/checkout?plan=${id}`)}
/>
</div>
</>
);
}

View File

@ -0,0 +1,364 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { AccountAvatar, Badge, Button, Card, Input, Select, Textarea } from "../components/ui";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
import { fileToMemberAvatarDataUrl } from "../lib/memberAvatar";
import { memberRoleSummary } from "../lib/memberRole";
const TIMEZONES = [
"Asia/Taipei",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Singapore",
"UTC",
"America/Los_Angeles",
"America/New_York",
"Europe/London",
];
export function ProfilePage() {
const repos = useRepos();
const { member, reload } = useAuth();
const { t, locale } = useI18n();
const fileRef = useRef<HTMLInputElement>(null);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [bio, setBio] = useState("");
const [timezone, setTimezone] = useState("Asia/Taipei");
const [notifyEmail, setNotifyEmail] = useState(true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [basicBusy, setBasicBusy] = useState(false);
const [pwBusy, setPwBusy] = useState(false);
const [avatarBusy, setAvatarBusy] = useState(false);
/** 頭像草稿:選檔/移除只改預覽,按儲存才寫入 */
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
useEffect(() => {
if (!member) return;
setDisplayName(member.display_name || "");
setEmail(member.email || "");
setBio(member.bio || "");
setTimezone(member.timezone || "Asia/Taipei");
setNotifyEmail(member.notify_email ?? true);
setAvatarUrl(member.avatar_url?.trim() || null);
}, [member]);
async function onSaveBasic(e: FormEvent) {
e.preventDefault();
setMessage("");
setError("");
setBasicBusy(true);
try {
const next = await repos.auth.updateProfile({
display_name: displayName,
email,
bio,
timezone,
notify_email: notifyEmail,
avatar_url: avatarUrl,
});
await reload();
if (!next.email_verified) {
setMessage(t("profile.savedUnverified"));
} else {
setMessage(t("profile.saved"));
}
} catch (err) {
setError(err instanceof Error ? err.message : t("profile.saveFail"));
} finally {
setBasicBusy(false);
}
}
async function onSavePassword(e: FormEvent) {
e.preventDefault();
setMessage("");
setError("");
if (!newPassword) {
setError(t("profile.needNewPassword"));
return;
}
if (newPassword !== confirmPassword) {
setError(t("profile.passwordMismatch"));
return;
}
if (!currentPassword) {
setError(t("profile.needCurrentPassword"));
return;
}
setPwBusy(true);
try {
await repos.auth.updateProfile({
current_password: currentPassword,
new_password: newPassword,
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
await reload();
setMessage(t("profile.passwordUpdated"));
} catch (err) {
setError(err instanceof Error ? err.message : t("profile.passwordFail"));
} finally {
setPwBusy(false);
}
}
async function onPickAvatar(file: File | null) {
if (!file) return;
setMessage("");
setError("");
setAvatarBusy(true);
try {
const dataUrl = await fileToMemberAvatarDataUrl(file);
setAvatarUrl(dataUrl);
} catch (err) {
setError(err instanceof Error ? err.message : t("profile.avatarFail"));
} finally {
setAvatarBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
}
function onClearAvatar() {
setError("");
setAvatarUrl(null);
}
const role = useMemo(() => memberRoleSummary(member, t), [member, t]);
const anyBusy = basicBusy || pwBusy || avatarBusy;
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
if (!member) {
return (
<>
<PageHeader title={t("profile.title")} />
<p className="text-muted">{t("common.loading")}</p>
</>
);
}
return (
<>
<PageHeader title={t("profile.title")} description={t("profile.desc")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<div className="hb-stack">
<Card title={t("profile.accountStatus")}>
<div className="hb-profile-status">
<div className="hb-profile-role">
<div className="hb-profile-role__badge">
<Badge tone={member.email_verified ? "success" : "warning"}>
{member.email_verified
? t("profile.emailVerified")
: t("profile.emailUnverified")}
</Badge>
</div>
<div className="hb-profile-role__text">
<p className="hb-profile-role__title">
{member.email_verified
? t("profile.emailVerified")
: t("profile.emailUnverified")}
</p>
<p className="hb-profile-role__meta">
{t("profile.loginEmail", { email: member.email })}
{member.email_verified_at
? t("profile.verifiedAt", {
time: new Date(
Math.floor(member.email_verified_at / 1_000_000),
).toLocaleString(dateLocale),
})
: ""}
</p>
{!member.email_verified ? (
<p className="hb-profile-role__action">
<Link to="/verify-email">
<Button type="button">{t("profile.goVerify")}</Button>
</Link>
</p>
) : null}
</div>
</div>
<div className="hb-profile-role">
<div className="hb-profile-role__badge">
<Badge tone={role.isAdmin ? "brand" : "neutral"}>{role.primaryLabel}</Badge>
</div>
<div className="hb-profile-role__text">
<p className="hb-profile-role__title">
{role.isAdmin ? t("profile.roleAdmin") : t("profile.roleMember")}
</p>
<p className="hb-profile-role__meta">
{t("profile.roleTags", {
labels: role.labels.join(t("profile.listJoin")),
})}
{t("profile.tenantUid", {
tenant: member.tenant_id,
uid: member.uid,
})}
</p>
<p className="hb-profile-role__meta">{t("profile.roleNote")}</p>
</div>
</div>
</div>
</Card>
<form className="hb-stack" onSubmit={(e) => void onSaveBasic(e)}>
<Card title={t("profile.basic")}>
<div className="hb-stack">
<div className="hb-profile-avatar">
<AccountAvatar
account={{
display_name: displayName || member.display_name,
username: member.email,
avatar_color: "var(--hb-brand)",
avatar_url: avatarUrl,
}}
size="lg"
className="hb-profile-avatar__img"
/>
<div className="hb-profile-avatar__actions">
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hb-sr-only"
id="member-avatar-file"
disabled={avatarBusy || basicBusy}
onChange={(e) => void onPickAvatar(e.target.files?.[0] ?? null)}
/>
<div className="hb-profile-avatar__btns">
<Button
type="button"
disabled={avatarBusy || basicBusy}
onClick={() => fileRef.current?.click()}
>
{avatarBusy ? t("common.loading") : t("profile.avatarUpload")}
</Button>
{avatarUrl ? (
<Button
type="button"
variant="ghost"
disabled={avatarBusy || basicBusy}
onClick={() => onClearAvatar()}
>
{t("profile.avatarRemove")}
</Button>
) : null}
</div>
<p className="text-muted hb-profile-avatar__hint">{t("profile.avatarHint")}</p>
</div>
</div>
<Input
label={t("profile.displayName")}
name="display_name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
maxLength={40}
autoComplete="nickname"
/>
<Input
label={t("login.email")}
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<Textarea
label={t("profile.bio")}
name="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
placeholder=""
/>
<Select
label={t("profile.timezone")}
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</Select>
<label className="hb-check-row">
<input
type="checkbox"
checked={notifyEmail}
onChange={(e) => setNotifyEmail(e.target.checked)}
/>
<span>{t("profile.notifyEmail")}</span>
</label>
<div className="hb-wizard-actions">
<Button type="submit" disabled={anyBusy}>
{basicBusy ? t("common.loading") : t("profile.saveBasic")}
</Button>
</div>
</div>
</Card>
</form>
<form className="hb-stack" onSubmit={(e) => void onSavePassword(e)}>
<Card title={t("profile.password")}>
<div className="hb-stack">
<Input
label={t("profile.currentPassword")}
name="current_password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
<Input
label={t("profile.newPassword")}
name="new_password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<Input
label={t("profile.confirmPassword")}
name="confirm_password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="hb-wizard-actions">
<Button type="submit" disabled={anyBusy}>
{pwBusy ? t("common.loading") : t("profile.updatePassword")}
</Button>
</div>
</div>
</Card>
</form>
</div>
</>
);
}

View File

@ -0,0 +1,112 @@
import { useMemo, useState, type FormEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Button, Card, Input } from "../components/ui";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
export function ResetPasswordPage() {
const repos = useRepos();
const { t } = useI18n();
const navigate = useNavigate();
const [params] = useSearchParams();
const token = useMemo(() => (params.get("token") || "").trim(), [params]);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [okMsg, setOkMsg] = useState("");
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setOkMsg("");
if (password !== confirm) {
setError(t("reset.mismatch"));
return;
}
setBusy(true);
try {
const res = await repos.auth.resetPassword(token, password);
setOkMsg(res.message);
window.setTimeout(() => navigate("/login", { replace: true }), 1200);
} catch (err) {
setError(err instanceof Error ? err.message : t("reset.fail"));
} finally {
setBusy(false);
}
}
if (!token) {
return (
<div className="hb-login">
<Card title={t("reset.cardTitle")}>
<div className="hb-stack">
<p className="hb-form-error" role="alert">
{t("reset.missingToken")}
</p>
<Link to="/forgot-password">
<Button type="button">{t("reset.forgotLink")}</Button>
</Link>
<Link to="/login" className="hb-login-back">
{t("forgot.back")}
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="hb-login">
<Card title={t("reset.title")}>
{okMsg ? (
<div className="hb-stack">
<p className="hb-banner-ok" role="status" style={{ margin: 0 }}>
{okMsg}
</p>
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("reset.redirecting")}
</p>
<Link to="/login">
<Button type="button">{t("reset.loginNow")}</Button>
</Link>
</div>
) : (
<form className="hb-stack" onSubmit={(e) => void onSubmit(e)}>
<Input
label={t("reset.newPassword")}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder={t("reset.passwordPh")}
/>
<Input
label={t("reset.confirm")}
name="confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? t("reset.submitting") : t("reset.submit")}
</Button>
<Link to="/login" className="hb-login-back">
{t("forgot.back")}
</Link>
</form>
)}
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,335 @@
import { useEffect, useState } from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { Button, Card, Input, Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { AiSettings, PlacementSettings } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { CURRENCIES, LOCALES, type AppCurrency, type AppLocale } from "../lib/i18n/types";
import type { ThemePreference } from "../lib/theme";
import { useTheme } from "../theme/ThemeContext";
export function SettingsPage() {
const repos = useRepos();
const { refresh } = useData();
const { t, locale, currency, setPrefs } = useI18n();
const { preference, setPreference } = useTheme();
const [ai, setAi] = useState<AiSettings | null>(null);
const [placement, setPlacement] = useState<PlacementSettings | null>(null);
const [apiKey, setApiKey] = useState("");
const [researchKey, setResearchKey] = useState("");
const [braveKey, setBraveKey] = useState("");
const [exaKey, setExaKey] = useState("");
const [copyModels, setCopyModels] = useState<string[]>([]);
const [researchModels, setResearchModels] = useState<string[]>([]);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
const [draftLocale, setDraftLocale] = useState<AppLocale>(locale);
const [draftCurrency, setDraftCurrency] = useState<AppCurrency>(currency);
useEffect(() => {
setDraftLocale(locale);
setDraftCurrency(currency);
}, [locale, currency]);
useEffect(() => {
void (async () => {
setAi(await repos.settings.getAi());
setPlacement(await repos.settings.getPlacement());
})();
}, [repos.settings]);
async function loadModels(kind: "copy" | "research") {
if (!ai) return;
setBusy(kind);
try {
const provider = kind === "copy" ? ai.provider : ai.research_provider;
const models = await repos.settings.listModels(provider);
if (kind === "copy") setCopyModels(models);
else setResearchModels(models);
setMessage(t("settings.modelsLoaded", { provider }));
} finally {
setBusy("");
}
}
async function saveAi() {
if (!ai) return;
setBusy("ai");
try {
const next = await repos.settings.saveAi({
...ai,
api_key: apiKey || undefined,
research_api_key: researchKey || undefined,
});
setAi(next);
setApiKey("");
setResearchKey("");
setMessage(t("settings.aiSaved"));
refresh();
} finally {
setBusy("");
}
}
async function savePlacement() {
if (!placement) return;
setBusy("placement");
try {
const next = await repos.settings.savePlacement({
...placement,
brave_api_key: braveKey || undefined,
exa_api_key: exaKey || undefined,
});
setPlacement(next);
setBraveKey("");
setExaKey("");
setMessage(t("settings.searchSaved"));
refresh();
} finally {
setBusy("");
}
}
function saveLocaleCurrency() {
setPrefs({ locale: draftLocale, currency: draftCurrency });
setMessage(t("settings.localeSaved"));
}
function pickTheme(next: ThemePreference) {
setPreference(next);
setMessage(t("settings.themeSaved"));
}
return (
<>
<PageHeader title={t("settings.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
<Card title={t("settings.appearance")}>
<div className="hb-stack">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("settings.theme")}
</p>
<div className="hb-theme-picker" role="group" aria-label={t("settings.theme")}>
{(
[
["light", t("settings.themeLight")],
["dark", t("settings.themeDark")],
["system", t("settings.themeSystem")],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
className={`hb-theme-picker__btn${preference === id ? " is-active" : ""}`}
onClick={() => pickTheme(id)}
>
<span className={`hb-theme-picker__swatch hb-theme-picker__swatch--${id}`} aria-hidden />
{label}
</button>
))}
</div>
</div>
</Card>
<Card title={t("settings.localeCurrency")}>
<div className="hb-stack">
<div className="hb-grid-2">
<Select
label={t("settings.locale")}
value={draftLocale}
onChange={(e) => setDraftLocale(e.target.value as AppLocale)}
>
{LOCALES.map((l) => (
<option key={l.id} value={l.id}>
{t(`locale.${l.id}`)}
</option>
))}
</Select>
<Select
label={t("settings.currency")}
value={draftCurrency}
onChange={(e) => setDraftCurrency(e.target.value as AppCurrency)}
>
{CURRENCIES.map((c) => (
<option key={c.id} value={c.id}>
{t(`currency.${c.id}`)}
</option>
))}
</Select>
</div>
<Button type="button" onClick={saveLocaleCurrency}>
{t("common.save")}
</Button>
</div>
</Card>
<Card title={t("settings.ai")}>
{ai ? (
<div className="hb-stack">
<div className="hb-grid-2">
<Select
label={t("settings.copyProvider")}
value={ai.provider}
onChange={(e) => setAi({ ...ai, provider: e.target.value })}
>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
<div>
<Select
label={t("settings.copyModel")}
value={ai.model}
onChange={(e) => setAi({ ...ai, model: e.target.value })}
>
{(copyModels.length ? copyModels : [ai.model || "deepseek-v4-pro"]).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</Select>
<Button
type="button"
variant="ghost"
className="hb-mt-2"
disabled={busy === "copy"}
onClick={() => void loadModels("copy")}
>
{busy === "copy" ? t("settings.fetchingModels") : t("settings.fetchModels")}
</Button>
</div>
<Select
label={t("settings.researchProvider")}
value={ai.research_provider}
onChange={(e) => setAi({ ...ai, research_provider: e.target.value })}
>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
<div>
<Select
label={t("settings.researchModel")}
value={ai.research_model}
onChange={(e) => setAi({ ...ai, research_model: e.target.value })}
>
{(researchModels.length ? researchModels : [ai.research_model || "deepseek-v4-pro"]).map(
(m) => (
<option key={m} value={m}>
{m}
</option>
),
)}
</Select>
<Button
type="button"
variant="ghost"
className="hb-mt-2"
disabled={busy === "research"}
onClick={() => void loadModels("research")}
>
{busy === "research" ? t("settings.fetchingModels") : t("settings.fetchModels")}
</Button>
</div>
</div>
<Input
label={t("settings.copyApiKey")}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={ai.api_key_configured ? t("settings.configured") : ""}
/>
<Input
label={t("settings.researchApiKey")}
type="password"
value={researchKey}
onChange={(e) => setResearchKey(e.target.value)}
placeholder={ai.research_api_key_configured ? t("settings.configured") : ""}
/>
<Button type="button" onClick={() => void saveAi()} disabled={busy === "ai"}>
{t("common.save")}
</Button>
</div>
) : (
<p className="text-muted">{t("common.loading")}</p>
)}
</Card>
<Card title={t("settings.search")}>
{placement ? (
<div className="hb-stack">
<div className="hb-grid-2">
<Select
label={t("settings.searchProvider")}
value={placement.web_search_provider}
onChange={(e) =>
setPlacement({
...placement,
web_search_provider: e.target.value as PlacementSettings["web_search_provider"],
})
}
>
<option value="brave">Brave</option>
<option value="exa">Exa</option>
</Select>
<Select
label={t("settings.expand")}
value={placement.expand_strategy}
onChange={(e) =>
setPlacement({
...placement,
expand_strategy: e.target.value as PlacementSettings["expand_strategy"],
})
}
>
<option value="brave">Brave</option>
<option value="llm">LLM</option>
<option value="hybrid">Hybrid</option>
</Select>
</div>
<Input
label={t("settings.braveKey")}
type="password"
value={braveKey}
onChange={(e) => setBraveKey(e.target.value)}
placeholder={
placement.brave_api_key_configured
? t("settings.configured")
: t("settings.notConfigured")
}
/>
<Input
label={t("settings.exaKey")}
type="password"
value={exaKey}
onChange={(e) => setExaKey(e.target.value)}
placeholder={
placement.exa_api_key_configured
? t("settings.configured")
: t("settings.notConfigured")
}
/>
<label className="hb-check-row">
<input
type="checkbox"
checked={placement.dev_mode_enabled}
onChange={(e) => setPlacement({ ...placement, dev_mode_enabled: e.target.checked })}
/>
<span>{t("settings.devMode")}</span>
</label>
<Button type="button" onClick={() => void savePlacement()} disabled={busy === "placement"}>
{t("common.save")}
</Button>
</div>
) : (
<p className="text-muted">{t("common.loading")}</p>
)}
</Card>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More