fix frontend
This commit is contained in:
parent
2e0b625735
commit
c57987f6a8
|
|
@ -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 # 型別檢查 + 生產建置
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 TC(npm)承接 -->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 T004–T005.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { OwnPost } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
|
||||
type Props = {
|
||||
post: OwnPost;
|
||||
/** compact = 一排 badge;detail = 網格全欄位 */
|
||||
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");
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 頭像 URL(Dicebear 軟粉彩;接真後改 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`;
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 雙長條時間序列:hover/focus 才露出該桶數字,不靠長說明。
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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: "目標長度像一則可滑完的短貼(約 80~180 字感),不要寫成長文。",
|
||||
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 [];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
/**
|
||||
* 頭像 URL(data 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;
|
||||
/** 附圖 URL(mock 記張數/可選縮圖) */
|
||||
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 mock;live 應走後端爬頁。
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
|
@ -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 key(play.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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
/**
|
||||
* 會員自訂頭像(mock:data 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;
|
||||
/** 附圖 URL(mock:本機 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 時 = 該貼 account;external 時 = 預設主回帳) */
|
||||
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 = how(8D + 指紋)
|
||||
* - 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;
|
||||
/** 我們回覆時用的帳號 username(mock) */
|
||||
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;
|
||||
/** 學習重點(3~5 條,可掃讀) */
|
||||
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;
|
||||
/** 學習重點:3~5 條子彈 */
|
||||
learn_points?: string[];
|
||||
/** 回帖可直接借的鉤子(1~3 句) */
|
||||
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 熱度 1–100 */
|
||||
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;
|
||||
/** 0–100 契合分(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[];
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
|
@ -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;
|
||||
/** 0–1 */
|
||||
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: ["先同步我的貼文,或該月至少發 1~2 則再分析。"],
|
||||
};
|
||||
}
|
||||
|
||||
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("該月發文偏少,可固定每週 2~3 則,曲線與月比才比較得準。");
|
||||
} 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}%`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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("請選擇圖片檔(JPG/PNG/WebP)");
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 || "2~3 短段",
|
||||
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 ||
|
||||
"先痛點、再經驗、最後輕帶觀點,避免一句廣告。",
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
// 粗分類:用路徑關鍵字推痛點/tags(mock 啟發式)
|
||||
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: "已填入,可再改",
|
||||
};
|
||||
}
|
||||
|
|
@ -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] }));
|
||||
}
|
||||
|
|
@ -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}」,你們會先問什麼?`,
|
||||
];
|
||||
}
|
||||
|
|
@ -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") ? "2~4 個短段落,段間空行" : "一段講完再補一句問句",
|
||||
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 或 0~1 個"),
|
||||
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: "2~3 短段,段間空行",
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { PLANS, type PlanId } from "./usageMeter";
|
||||
|
||||
export type PlanRightsCopy = {
|
||||
headline: string;
|
||||
/** 比價卡上 4~5 條短權益 */
|
||||
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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
// 0–100
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,保留 locale/currency */
|
||||
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;
|
||||
}
|
||||
|
|
@ -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())}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
/** 達方案上限仍可繼續用;AI/Search/點數照樣記帳 */
|
||||
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_cap:pct 最高顯示 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;
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue