+
+
+
+ {personas.length === 0 ? (
+
+ ) : (
+
+ {personas.map((p) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+
+ const detailPane = selected ? (
+
+
+
+
+ {selected.name}
+
+ {statusLabel(selected.status)}
+
+
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+
+ {(
+ [
+ ["overview", "persona.tabOverview"],
+ ["analyze", "persona.tabAnalyze"],
+ ["fingerprint", "persona.tabFingerprint"],
+ ["preview", "persona.tabPreview"],
+ ] as const
+ ).map(([key, labelKey]) => (
+
+ ))}
+
+
+ {detailTab === "overview" ? (
+
+ ) : null}
+
+ {detailTab === "analyze" ? (
+
+
+
+
+
+
+ {analyzeMode === "account" ? (
+
+ setBenchmarkUsername(e.target.value.replace(/^@/, ""))}
+ placeholder="harbor_main"
+ />
+ {accounts.length > 0 ? (
+
+ ) : null}
+
+
+ ) : (
+
+
+ )}
+
+ {hasAnalysis ? (
+
+
+ {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}
+
+
+ {DIM_ORDER.map((key) => {
+ const d = selected.style.dimensions[key as StyleDimKey];
+ if (!d?.summary) return null;
+ return (
+
+
{t(`persona.dim.${key}`)}
+
{d.summary}
+
+ );
+ })}
+
+ {selected.style.samplePreviews?.length ? (
+
+ {selected.style.samplePreviews.slice(0, 3).map((s) => (
+ - {s}
+ ))}
+
+ ) : null}
+
+ ) : (
+
+ {t("persona.analyzeHint")}
+
+ )}
+
+ ) : null}
+
+ {detailTab === "fingerprint" ? (
+
+
+ {t("persona.fingerprintHint")}
+
+
+ ) : null}
+
+ {detailTab === "preview" ? (
+
+
+ {!isPersonaReady(selected) ? (
+
+ {t("persona.notReadyMsg")}
+
+ ) : null}
+ {previewPost ? (
+ <>
+
+
+
+ {showPrompt ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ ) : null}
+
+ ) : (
+ (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 (
+
+ {images.length > 0 ? (
+
+ {images.map((img) => (
+
+

+
+
+ ))}
+
+ ) : null}
+
+ void onPick(e.target.files)}
+ />
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/studio/PostMetrics.tsx b/apps/web/src/components/studio/PostMetrics.tsx
new file mode 100644
index 0000000..e1e0daf
--- /dev/null
+++ b/apps/web/src/components/studio/PostMetrics.tsx
@@ -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 (
+
+ {rows.map((r) => (
+
+ {r.value.toLocaleString(dateLocale)}
+ {r.label}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {rows.map((r) => (
+
+ {r.value.toLocaleString(dateLocale)} {r.label}
+
+ ))}
+
+ );
+}
+
+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");
+}
diff --git a/apps/web/src/components/studio/ReplyComposer.tsx b/apps/web/src/components/studio/ReplyComposer.tsx
new file mode 100644
index 0000000..ef4172a
--- /dev/null
+++ b/apps/web/src/components/studio/ReplyComposer.tsx
@@ -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 (
+
+
+
+
+
+ {!ready ? (
+
+ {t("reply.notReady")}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/studio/Stepper.tsx b/apps/web/src/components/studio/Stepper.tsx
new file mode 100644
index 0000000..39e42a7
--- /dev/null
+++ b/apps/web/src/components/studio/Stepper.tsx
@@ -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 (
+
+ {WIZARD_STEPS.map((labelKey, index) => {
+ const active = index === activeIndex;
+ const done = index < activeIndex;
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/ui/AccountAvatar.tsx b/apps/web/src/components/ui/AccountAvatar.tsx
new file mode 100644
index 0000000..a882092
--- /dev/null
+++ b/apps/web/src/components/ui/AccountAvatar.tsx
@@ -0,0 +1,54 @@
+import type { ThreadsAccount } from "../../domain/types";
+
+type Props = {
+ account: Pick & {
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {initialsOf(account)}
+
+ );
+}
+
+/** 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`;
+}
diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx
new file mode 100644
index 0000000..4e332fe
--- /dev/null
+++ b/apps/web/src/components/ui/AppIcons.tsx
@@ -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, "name">;
+
+/**
+ * 精緻線稿 icon:圓潤端點、雙層深度、細微星芒(魔法感但不吵)
+ * 可隨 currentColor 換色,適配 light / dark。
+ */
+export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
+ return (
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+const glyphs: Record = {
+ // 今日:圓角日曆 + 星
+ today: (
+ <>
+
+
+
+
+ >
+ ),
+ // 帳號:雙圓 + 柔弧(島民)
+ crew: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // 創作:羽毛筆 + 星
+ studio: (
+ <>
+
+
+
+
+ >
+ ),
+ // 海巡:水晶球 / 羅盤弧 + 十字光
+ scout: (
+ <>
+
+
+
+
+
+ >
+ ),
+ // 發送:紙飛機 + 軌跡星
+ outbox: (
+ <>
+
+
+
+ >
+ ),
+ // 任務:魔法卷軸/手提袋簡化
+ jobs: (
+ <>
+
+
+
+
+ >
+ ),
+ // 品牌:旗幟堡壘簡化 + 星
+ brands: (
+ <>
+
+
+
+
+ >
+ ),
+ more: (
+ <>
+
+
+
+ >
+ ),
+ spark: (
+ <>
+
+
+ >
+ ),
+};
diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..c711e71
--- /dev/null
+++ b/apps/web/src/components/ui/Badge.tsx
@@ -0,0 +1,16 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+export type BadgeTone = "neutral" | "brand" | "success" | "danger" | "warning";
+
+type Props = HTMLAttributes & {
+ tone?: BadgeTone;
+ children: ReactNode;
+};
+
+export function Badge({ tone = "neutral", className = "", children, ...rest }: Props) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/ui/BrandMark.tsx b/apps/web/src/components/ui/BrandMark.tsx
new file mode 100644
index 0000000..b29dd62
--- /dev/null
+++ b/apps/web/src/components/ui/BrandMark.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx
new file mode 100644
index 0000000..dac13ce
--- /dev/null
+++ b/apps/web/src/components/ui/Button.tsx
@@ -0,0 +1,16 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+
+export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
+
+type Props = ButtonHTMLAttributes & {
+ variant?: ButtonVariant;
+ children: ReactNode;
+};
+
+export function Button({ variant = "primary", className = "", children, type = "button", ...rest }: Props) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx
new file mode 100644
index 0000000..2b90495
--- /dev/null
+++ b/apps/web/src/components/ui/Card.tsx
@@ -0,0 +1,15 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+type Props = HTMLAttributes & {
+ title?: string;
+ children: ReactNode;
+};
+
+export function Card({ title, children, className = "", ...rest }: Props) {
+ return (
+
+ {title ? {title}
: null}
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..c58c1db
--- /dev/null
+++ b/apps/web/src/components/ui/EmptyState.tsx
@@ -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 (
+
+
{title}
+ {description ?
{description}
: null}
+ {action}
+
+ );
+}
diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx
new file mode 100644
index 0000000..6b1852b
--- /dev/null
+++ b/apps/web/src/components/ui/Input.tsx
@@ -0,0 +1,17 @@
+import type { InputHTMLAttributes } from "react";
+
+type Props = InputHTMLAttributes & {
+ label?: string;
+ hint?: string;
+};
+
+export function Input({ label, hint, id, className = "", ...rest }: Props) {
+ const inputId = id || rest.name;
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/Pager.tsx b/apps/web/src/components/ui/Pager.tsx
new file mode 100644
index 0000000..02de921
--- /dev/null
+++ b/apps/web/src/components/ui/Pager.tsx
@@ -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 (
+
+
{pageRangeLabel(safePage, pageSize, total)}
+
+ {onPageSizeChange ? (
+
+ ) : null}
+
+
+ {safePage} / {totalPages}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx
new file mode 100644
index 0000000..be9f525
--- /dev/null
+++ b/apps/web/src/components/ui/Select.tsx
@@ -0,0 +1,20 @@
+import type { SelectHTMLAttributes, ReactNode } from "react";
+
+type Props = SelectHTMLAttributes & {
+ label?: string;
+ hint?: string;
+ children: ReactNode;
+};
+
+export function Select({ label, hint, id, className = "", children, ...rest }: Props) {
+ const inputId = id || rest.name;
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/Textarea.tsx b/apps/web/src/components/ui/Textarea.tsx
new file mode 100644
index 0000000..00ca94e
--- /dev/null
+++ b/apps/web/src/components/ui/Textarea.tsx
@@ -0,0 +1,17 @@
+import type { TextareaHTMLAttributes } from "react";
+
+type Props = TextareaHTMLAttributes & {
+ label?: string;
+ hint?: string;
+};
+
+export function Textarea({ label, hint, id, className = "", ...rest }: Props) {
+ const inputId = id || rest.name;
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts
new file mode 100644
index 0000000..2129bd2
--- /dev/null
+++ b/apps/web/src/components/ui/index.ts
@@ -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";
diff --git a/apps/web/src/components/usage/PlanPricingGrid.tsx b/apps/web/src/components/usage/PlanPricingGrid.tsx
new file mode 100644
index 0000000..bf6f2c2
--- /dev/null
+++ b/apps/web/src/components/usage/PlanPricingGrid.tsx
@@ -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 (
+
+ {(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 (
+
+
+
+
{p.name}
+ {current ? {t("plans.inUse")} : null}
+ {!current && popular ? {t("plans.recommended")} : null}
+
+ {rights.headline}
+
+ {formatPrice(p.price_twd)}
+ {t("plans.perMonth")}
+
+
+ {t("plans.creditsPerMonth", { n: p.monthly_credits })}
+
+
+
+
+ {rights.bullets.map((b) => (
+ - {b}
+ ))}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsageLedger.tsx b/apps/web/src/components/usage/UsageLedger.tsx
new file mode 100644
index 0000000..c3a3c54
--- /dev/null
+++ b/apps/web/src/components/usage/UsageLedger.tsx
@@ -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: (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 {emptyAction}
;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsageMeterBars.tsx b/apps/web/src/components/usage/UsageMeterBars.tsx
new file mode 100644
index 0000000..d9e791e
--- /dev/null
+++ b/apps/web/src/components/usage/UsageMeterBars.tsx
@@ -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(null);
+ const ordered = rows
+ .slice()
+ .sort((a, b) => METER_META[a.meter].order - METER_META[b.meter].order);
+
+ return (
+ 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 (
+ -
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsageSeriesChart.tsx b/apps/web/src/components/usage/UsageSeriesChart.tsx
new file mode 100644
index 0000000..ee6f7f2
--- /dev/null
+++ b/apps/web/src/components/usage/UsageSeriesChart.tsx
@@ -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(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 ;
+ }
+
+ 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 (
+ setActive(null)}
+ >
+
+
+ {readLabel || t("usage.chart.period")}
+
+
+
+ {t("usage.chart.allocated")}
+ {readPurchased}
+
+
+
+ {t("usage.chart.consumed")}
+ {readConsumed}
+
+ {readPurchased > 0 ? (
+
+ {Math.min(999, Math.round((readConsumed / readPurchased) * 100))}%
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/data/DataContext.tsx b/apps/web/src/data/DataContext.tsx
new file mode 100644
index 0000000..bddefad
--- /dev/null
+++ b/apps/web/src/data/DataContext.tsx
@@ -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(null);
+
+export function DataProvider({ children }: { children: ReactNode }) {
+ const [tick, setTick] = useState(0);
+ const [dataSource, setDs] = useState(() => 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 {children};
+}
+
+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;
+}
diff --git a/apps/web/src/data/createRepos.ts b/apps/web/src/data/createRepos.ts
new file mode 100644
index 0000000..e98e581
--- /dev/null
+++ b/apps/web/src/data/createRepos.ts
@@ -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;
+}
diff --git a/apps/web/src/data/mock/keys.ts b/apps/web/src/data/mock/keys.ts
new file mode 100644
index 0000000..e40ad7c
--- /dev/null
+++ b/apps/web/src/data/mock/keys.ts
@@ -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 */
+ 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;
diff --git a/apps/web/src/data/mock/outboxSimulate.ts b/apps/web/src/data/mock/outboxSimulate.ts
new file mode 100644
index 0000000..9385b90
--- /dev/null
+++ b/apps/web/src/data/mock/outboxSimulate.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/data/mock/repos.ts b/apps/web/src/data/mock/repos.ts
new file mode 100644
index 0000000..43237ab
--- /dev/null
+++ b/apps/web/src/data/mock/repos.ts
@@ -0,0 +1,2088 @@
+import { mockAvatarUrl } from "../../components/ui/AccountAvatar";
+import { newId } from "../../lib/id";
+import { nowUnixNano } from "../../lib/time";
+import { validatePlaySteps } from "../../domain/speakers";
+import {
+ mockDelay,
+ mockGenerateInspiration,
+ mockGenerateOwnPostReply,
+ mockGenerateRoot,
+ mockGenerateScoutDraft,
+ mockGenerateTopic,
+} from "../../lib/mockAi";
+import {
+ filesToAttachedImages,
+ toPersistableImageUrl,
+ withAttachedImageNote,
+} from "../../lib/attachImage";
+import { mockGenerateImage } from "../../lib/mockImage";
+import {
+ externalTargetKey,
+ mockResolveThreadLink,
+} from "../../lib/mockThreadLink";
+import {
+ mockExpandKnowledgePages,
+ mockWebResearch,
+ researchHitsToScoutNotes,
+} from "../../lib/mockResearch";
+import {
+ mockAnalyzePersonaFromAccount,
+ mockAnalyzePersonaFromText,
+} from "../../lib/mockStyleAnalyze";
+import { emptyInspireSession, mockInspireChat } from "../../lib/mockInspireChat";
+import { mockGenerateInspireAngles, trendFromQuery } from "../../lib/mockInspireAngles";
+import { mockRefreshTrends, mockSearchTrends, sparkCopyFromTrend } from "../../lib/mockTrends";
+import { formatViralAnalysis, mockAnalyzeViral, mockMimicPost } from "../../lib/mockViral";
+import { isPersonaReady, normalizePersona } from "../../lib/personaPrompt";
+import type {
+ AiSettings,
+ AppNotification,
+ Brand,
+ BrandProduct,
+ InspirationIdea,
+ InspireElement,
+ Job,
+ Member,
+ OutboxBundle,
+ OutboxStep,
+ Persona,
+ PlacementSettings,
+ ScoutHomeworkRecord,
+ ScoutPost,
+ ScoutRunBrief,
+ ScoutTopic,
+ ThreadPlay,
+ ThreadsAccount,
+ TokenPair,
+} from "../../domain/types";
+import { mockImportProductFromUrl } from "../../lib/mockProductImport";
+import {
+ buildScoutScanContext,
+ mockHitTextsForTerm,
+ prepareScoutBrief,
+} from "../../lib/mockScoutExpand";
+import { opportunityLine, resolveProductForPost } from "../../lib/productMatch";
+import {
+ listPlanPurchases,
+ mockCompletePlanPurchase,
+} from "../../lib/planPurchase";
+import {
+ getPlanId,
+ getMemberPrefs,
+ listUsageEvents,
+ recordUsage,
+ ensureDemoUsageHistory,
+ setMemberPrefs,
+ summarizeTenantAnalytics,
+ setPlanId,
+ summarizeTenantUsage,
+ summarizeUsage,
+} from "../../lib/usageMeter";
+import {
+ adminCreateMember,
+ adminResetPassword,
+ adminSetEmailVerified,
+ adminSetRoles,
+ adminSetSuspended,
+ findUserByEmail,
+ findUserByUid,
+ isMemberSuspended,
+ listAdminViews,
+ listAdminViewsPage,
+ loadTenantUsers,
+ migrateLegacyCredentials,
+ toAdminView,
+ toMember,
+ upsertTenantUser,
+} from "../../lib/tenantUsers";
+import type { Repos } from "../repos";
+import { KEYS } from "./keys";
+import {
+ getActiveBrandId,
+ getActivePersonaId,
+ getAiSettings,
+ getDataSource,
+ getInspireElements,
+ getInspireSession,
+ getPlacementSettings,
+ getScoutHomework,
+ getSession,
+ getStore,
+ setActiveBrandId,
+ setActivePersonaId,
+ setAiSettings,
+ setDataSource,
+ setInspireElements,
+ setInspireSession,
+ setPlacementSettings,
+ setScoutHomework,
+ setSession,
+ updateStore,
+} from "./store";
+import { retryStep, simulateRootFail, simulateSuccess } from "./outboxSimulate";
+import { readJson, writeJson } from "../../lib/storage";
+
+const DEMO_EMAIL = "demo@harbor.local";
+const DEMO_PASSWORD = "demo";
+
+function hashStr(s: string): number {
+ let h = 0;
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
+ return h;
+}
+
+const MODELS: Record = {
+ "opencode-go": ["deepseek-v4-pro", "deepseek-v4-flash", "gpt-mock-1"],
+ xai: ["grok-3", "grok-3-mini", "grok-2"],
+};
+
+function memberFromSession(): Member | null {
+ const s = getSession();
+ if (!s?.member) return null;
+ const m = s.member;
+ return {
+ tenant_id: m.tenant_id,
+ uid: m.uid,
+ email: m.email,
+ display_name: m.display_name,
+ roles: m.roles,
+ bio: m.bio,
+ timezone: m.timezone,
+ notify_email: m.notify_email,
+ email_verified: m.email_verified === true,
+ email_verified_at: m.email_verified_at ?? null,
+ };
+}
+
+function pushNotification(partial: Omit) {
+ updateStore((store) => {
+ store.notifications.unshift({
+ id: newId("ntf"),
+ created_at: nowUnixNano(),
+ read_at: null,
+ ...partial,
+ });
+ });
+}
+
+function playToOutbox(play: ThreadPlay): OutboxBundle {
+ const now = nowUnixNano();
+ let cursor = play.schedule_start_at || now;
+ const steps: OutboxStep[] = play.steps
+ .slice()
+ .sort((a, b) => a.sort_order - b.sort_order)
+ .map((step, index) => {
+ if (index > 0) cursor += (step.delay_from_previous_sec || 0) * 1_000_000_000;
+ return {
+ id: newId("obstep"),
+ step_id: step.id,
+ sort_order: step.sort_order,
+ kind: step.kind,
+ account_id: step.account_id,
+ text: step.text,
+ status: "scheduled" as const,
+ scheduled_at: cursor,
+ };
+ });
+
+ return {
+ id: newId("outbox"),
+ play_id: play.id,
+ title: play.title || play.topic || "未命名串場",
+ status: "scheduling",
+ steps,
+ created_at: now,
+ updated_at: now,
+ };
+}
+
+function personaById(id?: string): Persona | undefined {
+ const store = getStore();
+ const pid = id || getActivePersonaId();
+ const raw = store.personas.find((p) => p.id === pid);
+ return raw ? normalizePersona(raw) : undefined;
+}
+
+function requireReadyPersona(id?: string): Persona {
+ const p = personaById(id);
+ if (!p) throw new Error("請先選擇人設");
+ if (!isPersonaReady(p)) {
+ throw new Error(`人設「${p.name}」尚未完成分析(需 ready)。請到帳號 → 人設貼參考文分析。`);
+ }
+ return p;
+}
+
+async function mockRunScanFromBrief(brief: ScoutRunBrief): Promise {
+ await mockDelay(750);
+ const terms = (brief.scan_terms || []).map((t) => t.trim()).filter(Boolean);
+ if (!terms.length) throw new Error("請至少勾選一個掃描詞");
+ const mode = brief.mode || (brief.product_id ? "product" : "theme");
+ const brandId = brief.brand_id || null;
+ const products = brandId
+ ? getStore().brandProducts.filter((p) => p.brand_id === brandId)
+ : [];
+ const pref = brief.product_id
+ ? products.find((p) => p.id === brief.product_id) || null
+ : null;
+
+ const authors = ["itchy_days", "new_mom_tw", "remote_worker", "curious_one", "night_owl"];
+ const hits: ScoutPost[] = [];
+ const take = terms.slice(0, mode === "activity" ? 5 : 4);
+ const theme_label =
+ brief.theme_label ||
+ (mode === "product" && brief.product_label
+ ? brief.product_label
+ : mode === "activity"
+ ? `活躍 · ${brief.intent.slice(0, 28)}${brief.intent.length > 28 ? "…" : ""}`
+ : brief.intent.slice(0, 36) + (brief.intent.length > 36 ? "…" : ""));
+ const theme_key =
+ brief.theme_key ||
+ [mode, brief.product_id || "", brief.intent.trim().slice(0, 48)].join("|");
+ take.forEach((term, i) => {
+ const templates = mockHitTextsForTerm(term, mode, brief.product_label);
+ const text = templates[i % templates.length]!;
+ let product = pref;
+ let score = 55 + ((term.length * 3 + i * 7) % 40);
+ if (mode === "product" && products.length) {
+ const resolved = resolveProductForPost({
+ products,
+ preferredProductId: brief.product_id,
+ searchTag: term,
+ postText: text,
+ });
+ product = resolved.product;
+ score = Math.max(score, resolved.score || score);
+ }
+ if (mode === "activity") {
+ score = Math.min(99, 50 + ((term.length * 5 + i * 9) % 45));
+ }
+ const match_reason =
+ mode === "product"
+ ? product
+ ? `對上痛點/詞「${term}」· 可服務「${product.label}」`
+ : `掃描詞「${term}」命中`
+ : mode === "activity"
+ ? `關鍵字「${term}」· 可短回養活躍`
+ : `對主題「${term}」有興趣/在討論`;
+ const opportunity =
+ mode === "product"
+ ? opportunityLine(product, term)
+ : mode === "activity"
+ ? `活躍回覆 · 快速接話即可(${term})`
+ : `主題互動 · 可接話聊聊「${term}」`;
+ hits.push({
+ id: newId("scan"),
+ brand_id: brandId,
+ author: authors[i % authors.length]!,
+ text,
+ search_tag: term,
+ opportunity,
+ match_reason,
+ scout_mode: mode,
+ intent_snippet: brief.intent.slice(0, 48),
+ theme_key,
+ theme_label,
+ outreach_status: "new",
+ score: Math.min(99, score),
+ matched_product_id: mode === "product" ? product?.id || null : null,
+ matched_product_label: mode === "product" ? product?.label || null : null,
+ });
+ });
+
+ updateStore((s) => {
+ s.scoutPosts = [...hits, ...s.scoutPosts].slice(0, 80);
+ });
+ if (brandId) {
+ return getStore().scoutPosts.filter((p) => p.brand_id === brandId);
+ }
+ return getStore().scoutPosts.filter(
+ (p) => p.scout_mode === "theme" || !p.brand_id || hits.some((h) => h.id === p.id),
+ );
+}
+
+export function createMockRepos(): Repos {
+ getStore();
+ loadTenantUsers();
+ migrateLegacyCredentials();
+
+ return {
+ dataSource: "mock",
+ settings: {
+ getDataSource,
+ setDataSource,
+ async getAi() {
+ return getAiSettings();
+ },
+ async saveAi(patch) {
+ const prev = getAiSettings();
+ const next: AiSettings = {
+ provider: patch.provider ?? prev.provider,
+ model: patch.model ?? prev.model,
+ research_provider: patch.research_provider ?? prev.research_provider,
+ research_model: patch.research_model ?? prev.research_model,
+ api_key_configured: patch.api_key ? true : (patch.api_key_configured ?? prev.api_key_configured),
+ research_api_key_configured: patch.research_api_key
+ ? true
+ : (patch.research_api_key_configured ?? prev.research_api_key_configured),
+ };
+ setAiSettings(next);
+ return next;
+ },
+ async getPlacement() {
+ return getPlacementSettings();
+ },
+ async savePlacement(patch) {
+ const prev = getPlacementSettings();
+ const next: PlacementSettings = {
+ web_search_provider: patch.web_search_provider ?? prev.web_search_provider,
+ expand_strategy: patch.expand_strategy ?? prev.expand_strategy,
+ brave_api_key_configured: patch.brave_api_key
+ ? true
+ : (patch.brave_api_key_configured ?? prev.brave_api_key_configured),
+ exa_api_key_configured: patch.exa_api_key
+ ? true
+ : (patch.exa_api_key_configured ?? prev.exa_api_key_configured),
+ dev_mode_enabled: patch.dev_mode_enabled ?? prev.dev_mode_enabled,
+ };
+ setPlacementSettings(next);
+ return next;
+ },
+ async listModels(provider) {
+ await mockDelay(200);
+ return MODELS[provider] || ["custom-model"];
+ },
+ },
+ auth: {
+ async login(email, password) {
+ const emailNorm = email.trim().toLowerCase();
+ loadTenantUsers();
+ migrateLegacyCredentials();
+ let user = findUserByEmail(emailNorm);
+ // 相容:舊 DEMO 密碼
+ if (!user && emailNorm === DEMO_EMAIL && password === DEMO_PASSWORD) {
+ user = findUserByEmail(DEMO_EMAIL);
+ }
+ if (!user || user.password !== password) {
+ throw new Error(
+ "帳號或密碼錯誤(demo@harbor.local / demo · alice@harbor.local / alice)",
+ );
+ }
+ if (isMemberSuspended(user)) {
+ throw new Error("此帳號已被停權,請聯絡管理員");
+ }
+ const member = toMember(user);
+ setSession({ member, password: user.password });
+ writeJson(KEYS.memberCredentials, { email: user.email, password: user.password });
+ writeJson(KEYS.memberProfile, {
+ email: member.email,
+ display_name: member.display_name,
+ bio: member.bio,
+ timezone: member.timezone,
+ notify_email: member.notify_email,
+ avatar_url: member.avatar_url ?? null,
+ email_verified: member.email_verified,
+ email_verified_at: member.email_verified_at,
+ status: member.status,
+ });
+ const tokens: TokenPair = {
+ access_token: "mock-access",
+ refresh_token: "mock-refresh",
+ };
+ return { tokens, member };
+ },
+ async logout() {
+ setSession(null);
+ },
+ async me() {
+ const m = memberFromSession();
+ if (!m) return null;
+ const fresh = findUserByUid(m.uid);
+ if (fresh) {
+ // 會話期間被停權 → 清 session
+ if (isMemberSuspended(fresh)) {
+ setSession(null);
+ return null;
+ }
+ return toMember(fresh);
+ }
+ return {
+ ...m,
+ email_verified: m.email_verified === true,
+ email_verified_at: m.email_verified_at ?? null,
+ status: m.status === "suspended" ? "suspended" : "active",
+ };
+ },
+ async updateProfile(patch) {
+ await mockDelay(200);
+ const cur = getSession();
+ if (!cur?.member) throw new Error("尚未登入");
+ const rec = findUserByUid(cur.member.uid) || findUserByEmail(cur.member.email);
+ if (!rec) throw new Error("找不到會員資料");
+
+ const nextEmail = (patch.email ?? rec.email).trim().toLowerCase();
+ if (!nextEmail || !nextEmail.includes("@")) throw new Error("Email 格式不正確");
+ const display = (patch.display_name ?? rec.display_name).trim();
+ if (!display) throw new Error("顯示名稱不可空白");
+ if (display.length > 40) throw new Error("顯示名稱請在 40 字內");
+
+ // email 不可與他人重複
+ const clash = findUserByEmail(nextEmail);
+ if (clash && clash.uid !== rec.uid) throw new Error("此 Email 已被其他使用者使用");
+
+ let password = rec.password;
+ if (patch.new_password != null && patch.new_password !== "") {
+ if (!patch.current_password) throw new Error("請輸入目前密碼");
+ if (patch.current_password !== password) throw new Error("目前密碼不正確");
+ if (patch.new_password.length < 4) throw new Error("新密碼至少 4 碼");
+ password = patch.new_password;
+ }
+
+ const emailChanged = nextEmail !== rec.email.trim().toLowerCase();
+
+ let avatar_url = rec.avatar_url ?? null;
+ if (patch.avatar_url !== undefined) {
+ if (patch.avatar_url === null || patch.avatar_url.trim() === "") {
+ avatar_url = null;
+ } else {
+ const url = patch.avatar_url.trim();
+ // mock:允許 data URL 或 https;限制體積避免撐爆 localStorage
+ if (!url.startsWith("data:image/") && !url.startsWith("https://")) {
+ throw new Error("頭像格式不支援");
+ }
+ if (url.length > 400_000) {
+ throw new Error("頭像檔案過大,請換較小圖片");
+ }
+ avatar_url = url;
+ }
+ }
+
+ const updated = upsertTenantUser({
+ ...rec,
+ email: nextEmail,
+ password,
+ display_name: display,
+ bio: patch.bio !== undefined ? patch.bio.trim().slice(0, 200) : rec.bio || "",
+ timezone: patch.timezone ?? rec.timezone ?? "Asia/Taipei",
+ notify_email:
+ patch.notify_email !== undefined ? patch.notify_email : (rec.notify_email ?? true),
+ avatar_url,
+ email_verified: emailChanged ? false : rec.email_verified === true,
+ email_verified_at: emailChanged ? null : rec.email_verified_at ?? null,
+ });
+ const member = toMember(updated);
+ writeJson(KEYS.memberCredentials, { email: member.email, password });
+ writeJson(KEYS.memberProfile, {
+ email: member.email,
+ display_name: member.display_name,
+ bio: member.bio,
+ timezone: member.timezone,
+ notify_email: member.notify_email,
+ avatar_url: member.avatar_url ?? null,
+ email_verified: member.email_verified,
+ email_verified_at: member.email_verified_at,
+ });
+ setSession({ member, password });
+ return member;
+ },
+ async requestPasswordReset(email) {
+ await mockDelay(350);
+ const emailNorm = email.trim().toLowerCase();
+ if (!emailNorm || !emailNorm.includes("@")) {
+ throw new Error("請輸入有效的 Email");
+ }
+ const known = Boolean(findUserByEmail(emailNorm));
+ const generic =
+ "若此 Email 已註冊,你會收到重設連結。";
+ if (!known) {
+ return { ok: true as const, message: generic };
+ }
+ const user = findUserByEmail(emailNorm)!;
+ const token = newId("rst").replace(/^rst_/, "rst_");
+ const expires_at = nowUnixNano() + 30 * 60 * 1_000_000_000;
+ writeJson(KEYS.passwordReset, {
+ token,
+ email: user.email,
+ uid: user.uid,
+ expires_at,
+ });
+ return {
+ ok: true as const,
+ message: generic,
+ mock_reset_path: `/reset-password?token=${encodeURIComponent(token)}`,
+ };
+ },
+ async resetPassword(token, newPassword) {
+ await mockDelay(300);
+ const t = (token || "").trim();
+ if (!t) throw new Error("缺少重設 token");
+ if (!newPassword || newPassword.length < 4) {
+ throw new Error("新密碼至少 4 碼");
+ }
+ const rec = readJson<{
+ token: string;
+ email: string;
+ uid?: string;
+ expires_at: number;
+ } | null>(KEYS.passwordReset, null);
+ if (!rec || rec.token !== t) throw new Error("連結無效或已使用");
+ if (rec.expires_at < nowUnixNano()) throw new Error("連結已過期,請重新申請");
+ const user =
+ (rec.uid ? findUserByUid(rec.uid) : null) || findUserByEmail(rec.email);
+ if (!user) throw new Error("找不到使用者");
+ const updated = upsertTenantUser({ ...user, password: newPassword });
+ writeJson(KEYS.memberCredentials, {
+ email: updated.email,
+ password: newPassword,
+ });
+ writeJson(KEYS.passwordReset, null);
+ const sess = getSession();
+ if (sess?.member?.uid === updated.uid) {
+ setSession({
+ ...sess,
+ password: newPassword,
+ member: toMember(updated),
+ });
+ }
+ return {
+ ok: true as const,
+ message: "密碼已更新,請用新密碼登入。",
+ };
+ },
+ async sendEmailVerificationCode() {
+ await mockDelay(320);
+ const cur = getSession();
+ if (!cur?.member) throw new Error("尚未登入");
+ const fresh = findUserByUid(cur.member.uid);
+ if (fresh?.email_verified || cur.member.email_verified) {
+ return {
+ ok: true as const,
+ message: "信箱已驗證,無需再寄。",
+ };
+ }
+ const email = (fresh?.email || cur.member.email).toLowerCase();
+ const code = String(100000 + (Math.abs(hashStr(email)) % 900000));
+ const expires_at = nowUnixNano() + 15 * 60 * 1_000_000_000;
+ writeJson(KEYS.emailVerification, {
+ email,
+ code,
+ expires_at,
+ });
+ return {
+ ok: true as const,
+ message: `驗證碼已寄至 ${email}`,
+ mock_code: code,
+ };
+ },
+ async verifyEmail(code) {
+ await mockDelay(280);
+ const cur = getSession();
+ if (!cur?.member) throw new Error("尚未登入");
+ const input = (code || "").trim().replace(/\s/g, "");
+ if (!/^\d{6}$/.test(input)) throw new Error("請輸入 6 位數字驗證碼");
+ const rec = readJson<{
+ email: string;
+ code: string;
+ expires_at: number;
+ } | null>(KEYS.emailVerification, null);
+ const email = cur.member.email.toLowerCase();
+ if (!rec || rec.email !== email) {
+ throw new Error("請先點「寄送驗證碼」");
+ }
+ if (rec.expires_at < nowUnixNano()) {
+ throw new Error("驗證碼已過期,請重新寄送");
+ }
+ if (rec.code !== input) throw new Error("驗證碼不正確");
+ const user = findUserByUid(cur.member.uid) || findUserByEmail(email);
+ if (!user) throw new Error("找不到會員");
+ const now = nowUnixNano();
+ const updated = upsertTenantUser({
+ ...user,
+ email_verified: true,
+ email_verified_at: now,
+ });
+ const member = toMember(updated);
+ writeJson(KEYS.memberProfile, {
+ email: member.email,
+ display_name: member.display_name,
+ bio: member.bio,
+ timezone: member.timezone,
+ notify_email: member.notify_email,
+ avatar_url: member.avatar_url ?? null,
+ email_verified: true,
+ email_verified_at: now,
+ });
+ writeJson(KEYS.emailVerification, null);
+ setSession({ ...cur, member });
+ return member;
+ },
+ },
+ adminUsers: {
+ async listUsers() {
+ await mockDelay(120);
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ return listAdminViews();
+ },
+ async listUsersPage(opts) {
+ await mockDelay(100);
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ return listAdminViewsPage(opts?.page ?? 1, opts?.pageSize ?? 10, opts?.query ?? "");
+ },
+ async getUser(uid) {
+ await mockDelay(80);
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ const u = findUserByUid(uid);
+ return u ? toAdminView(u) : null;
+ },
+ async createMember(input) {
+ await mockDelay(260);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ return adminCreateMember(me, input);
+ },
+ async setSuspended(uid, suspended) {
+ await mockDelay(200);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ const view = adminSetSuspended(me, uid, suspended);
+ // 若停權對象剛好是自己(理論上擋了),或對方有 session 由 me() 處理
+ if (me.uid === uid && suspended) {
+ setSession(null);
+ }
+ return view;
+ },
+ async setEmailVerified(uid, verified) {
+ await mockDelay(200);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ const view = adminSetEmailVerified(me, uid, verified);
+ if (me.uid === uid) {
+ const sess = getSession();
+ if (sess) {
+ setSession({
+ ...sess,
+ member: {
+ ...sess.member,
+ email_verified: view.email_verified,
+ email_verified_at: view.email_verified_at,
+ },
+ });
+ }
+ }
+ return view;
+ },
+ async setRoles(uid, roles) {
+ await mockDelay(200);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ const view = adminSetRoles(me, uid, roles);
+ if (me.uid === uid) {
+ const sess = getSession();
+ if (sess) {
+ setSession({
+ ...sess,
+ member: {
+ ...sess.member,
+ roles: view.roles,
+ },
+ });
+ }
+ }
+ return view;
+ },
+ async resetPassword(uid, newPassword) {
+ await mockDelay(220);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ return adminResetPassword(me, uid, newPassword);
+ },
+ },
+ accounts: {
+ async list() {
+ return getStore().accounts;
+ },
+ async createMock() {
+ const n = getStore().accounts.length + 1;
+ const now = nowUnixNano();
+ const day = 86_400_000_000_000;
+ const acc: ThreadsAccount = {
+ id: newId("acc"),
+ username: `harbor_user_${n}`,
+ display_name: `Threads 帳號 ${n}`,
+ connection: "connected",
+ is_usable: true,
+ avatar_color: ["#5ec4a8", "#5b8fd4", "#f08a72", "#8b8fd9", "#e7ae3c", "#69b5e8"][n % 6],
+ avatar_url: mockAvatarUrl(`harbor_user_${n}`),
+ session_expires_at: now + 30 * day,
+ session_refreshed_at: now,
+ };
+ updateStore((s) => {
+ s.accounts.push(acc);
+ });
+ return acc;
+ },
+ async remove(id) {
+ updateStore((s) => {
+ s.accounts = s.accounts.filter((a) => a.id !== id);
+ });
+ },
+ async refreshSession(id) {
+ await mockDelay(550);
+ const now = nowUnixNano();
+ const day = 86_400_000_000_000;
+ let next: ThreadsAccount | null = null;
+ updateStore((s) => {
+ const i = s.accounts.findIndex((a) => a.id === id);
+ if (i < 0) return;
+ s.accounts[i] = {
+ ...s.accounts[i],
+ connection: "connected",
+ is_usable: true,
+ error_message: undefined,
+ session_refreshed_at: now,
+ session_expires_at: now + 30 * day,
+ };
+ next = s.accounts[i];
+ });
+ if (!next) throw new Error("找不到帳號");
+ return next;
+ },
+ },
+ plays: {
+ async list() {
+ return getStore().plays.slice().sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async listByPost(ownPostId) {
+ return getStore()
+ .plays.filter((p) => p.target_own_post_id === ownPostId)
+ .slice()
+ .sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async listByExternalUrl(url) {
+ const key = externalTargetKey(url);
+ if (!key) return [];
+ return getStore()
+ .plays.filter(
+ (p) => p.target_external?.url && externalTargetKey(p.target_external) === key,
+ )
+ .slice()
+ .sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async resolveExternalLink(url) {
+ return mockResolveThreadLink(url);
+ },
+ async get(id) {
+ return getStore().plays.find((p) => p.id === id) ?? null;
+ },
+ async save(play) {
+ const now = nowUnixNano();
+ const underPost = Boolean(play.target_own_post_id || play.target_external?.url);
+ // under-post: 強制步驟為 reply;附圖 data URL 過長改存佔位縮圖
+ const steps = play.steps.map((s, i) => {
+ const image_urls = s.image_urls?.length
+ ? s.image_urls.map((url, j) =>
+ toPersistableImageUrl({ id: `${s.id}_${j}`, url, name: `step-${i + 1}-${j + 1}` }),
+ )
+ : undefined;
+ const text = withAttachedImageNote(s.text, image_urls?.length || 0);
+ return {
+ ...s,
+ text,
+ image_urls,
+ kind: underPost ? ("reply" as const) : s.kind,
+ sort_order: i,
+ };
+ });
+ // own 與 external 互斥
+ let target_own_post_id = play.target_own_post_id || null;
+ let target_external = play.target_external || null;
+ if (target_own_post_id) target_external = null;
+ if (target_external?.url) {
+ target_own_post_id = null;
+ target_external = {
+ ...target_external,
+ url: externalTargetKey(target_external),
+ };
+ }
+ const next = {
+ ...play,
+ steps,
+ target_own_post_id,
+ target_external,
+ updated_at: now,
+ created_at: play.created_at || now,
+ };
+ updateStore((s) => {
+ const i = s.plays.findIndex((p) => p.id === next.id);
+ if (i >= 0) s.plays[i] = next;
+ else s.plays.unshift(next);
+ });
+ return next;
+ },
+ async remove(id) {
+ updateStore((s) => {
+ s.plays = s.plays.filter((p) => p.id !== id);
+ });
+ },
+ async submit(id) {
+ const play = getStore().plays.find((p) => p.id === id);
+ if (!play) throw new Error("play.err.notFound");
+ const err = validatePlaySteps(play);
+ if (err) throw new Error(err);
+ const n = play.steps.length;
+ let title = play.title;
+ if (play.target_own_post_id) {
+ title = `${play.title} · ${n}`;
+ } else if (play.target_external?.url) {
+ const who = play.target_external.author_username
+ ? `@${play.target_external.author_username}`
+ : "external";
+ title = `${play.title} · ${who} · ${n}`;
+ }
+ const bundle = playToOutbox({
+ ...play,
+ title,
+ });
+ updateStore((s) => {
+ const i = s.plays.findIndex((p) => p.id === id);
+ if (i >= 0) {
+ s.plays[i] = { ...s.plays[i], status: "scheduling", updated_at: nowUnixNano() };
+ }
+ s.outbox.unshift(bundle);
+ });
+ return bundle;
+ },
+ },
+ outbox: {
+ async list() {
+ return getStore().outbox.slice().sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async get(id) {
+ return getStore().outbox.find((o) => o.id === id) ?? null;
+ },
+ async remove(id) {
+ const key = (id || "").trim();
+ if (!key) throw new Error("無效 id");
+ const before = getStore().outbox.length;
+ updateStore((s) => {
+ s.outbox = s.outbox.filter((o) => o.id !== key);
+ });
+ if (getStore().outbox.length === before) throw new Error("找不到 Outbox");
+ },
+ async simulateSuccess(id) {
+ const current = getStore().outbox.find((o) => o.id === id);
+ if (!current) throw new Error("找不到 Outbox");
+ const next = simulateSuccess(current);
+ updateStore((s) => {
+ const i = s.outbox.findIndex((o) => o.id === id);
+ if (i >= 0) s.outbox[i] = next;
+ });
+ pushNotification({
+ title: "串場發送完成",
+ body: `「${next.title}」已全部發送成功`,
+ kind: "outbox",
+ ref_type: "outbox",
+ ref_id: next.id,
+ });
+ return next;
+ },
+ async simulateRootFail(id) {
+ const current = getStore().outbox.find((o) => o.id === id);
+ if (!current) throw new Error("找不到 Outbox");
+ const next = simulateRootFail(current);
+ updateStore((s) => {
+ const i = s.outbox.findIndex((o) => o.id === id);
+ if (i >= 0) s.outbox[i] = next;
+ });
+ pushNotification({
+ title: "主貼發送失敗",
+ body: `「${next.title}」主貼失敗,後續回覆已阻擋`,
+ kind: "outbox",
+ ref_type: "outbox",
+ ref_id: next.id,
+ });
+ return next;
+ },
+ async retryStep(bundleId, stepId) {
+ const current = getStore().outbox.find((o) => o.id === bundleId);
+ if (!current) throw new Error("找不到 Outbox");
+ const next = retryStep(current, stepId);
+ updateStore((s) => {
+ const i = s.outbox.findIndex((o) => o.id === bundleId);
+ if (i >= 0) s.outbox[i] = next;
+ });
+ return next;
+ },
+ },
+ jobs: {
+ async list() {
+ return getStore().jobs.slice().sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async get(id) {
+ return getStore().jobs.find((j) => j.id === id) ?? null;
+ },
+ async startDemo() {
+ const now = nowUnixNano();
+ const job: Job = {
+ id: newId("job"),
+ template_type: "demo_long_task",
+ status: "running",
+ progress_summary: "任務啟動",
+ progress_percent: 5,
+ created_at: now,
+ updated_at: now,
+ };
+ updateStore((s) => {
+ s.jobs.unshift(job);
+ });
+ runJobTimer(job.id);
+ return job;
+ },
+ },
+ notifications: {
+ async list() {
+ return getStore().notifications.slice().sort((a, b) => b.created_at - a.created_at);
+ },
+ async unreadCount() {
+ return getStore().notifications.filter((n) => !n.read_at).length;
+ },
+ async markRead(id) {
+ updateStore((s) => {
+ const n = s.notifications.find((x) => x.id === id);
+ if (n && !n.read_at) n.read_at = nowUnixNano();
+ });
+ },
+ async markAllRead() {
+ const now = nowUnixNano();
+ updateStore((s) => {
+ for (const n of s.notifications) {
+ if (!n.read_at) n.read_at = now;
+ }
+ });
+ },
+ },
+ personas: {
+ async list() {
+ return getStore().personas.map((p) => normalizePersona(p));
+ },
+ async get(id) {
+ const p = getStore().personas.find((x) => x.id === id);
+ return p ? normalizePersona(p) : null;
+ },
+ async getActiveId() {
+ return getActivePersonaId();
+ },
+ async setActiveId(id) {
+ setActivePersonaId(id);
+ },
+ async save(persona) {
+ const next = normalizePersona(persona);
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === next.id);
+ if (i >= 0) s.personas[i] = next;
+ else s.personas.push(next);
+ });
+ return next;
+ },
+ async remove(id) {
+ updateStore((s) => {
+ s.personas = s.personas.filter((p) => p.id !== id);
+ });
+ if (getActivePersonaId() === id) {
+ const first = getStore().personas[0]?.id || "";
+ setActivePersonaId(first);
+ }
+ },
+ async analyzeFromText(id, rawText, sourceLabel) {
+ const current = getStore().personas.find((p) => p.id === id);
+ if (!current) throw new Error("找不到人設");
+ recordUsage({
+ meter: "ai_copy",
+ label: "人設風格分析(文字)",
+ source: "personas.analyzeFromText",
+ credits: 2,
+ });
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = { ...normalizePersona(s.personas[i]), status: "analyzing" };
+ });
+ try {
+ const analyzed = await mockAnalyzePersonaFromText(normalizePersona(current), rawText, sourceLabel);
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = analyzed;
+ });
+ return analyzed;
+ } catch (e) {
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = normalizePersona(current);
+ });
+ throw e;
+ }
+ },
+ async analyzeFromAccount(id, username) {
+ const current = getStore().personas.find((p) => p.id === id);
+ if (!current) throw new Error("找不到人設");
+ recordUsage({
+ meter: "ai_copy",
+ label: "人設風格分析(帳號)",
+ source: "personas.analyzeFromAccount",
+ credits: 2,
+ });
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = { ...normalizePersona(s.personas[i]), status: "analyzing" };
+ });
+ try {
+ const { persona: analyzed } = await mockAnalyzePersonaFromAccount(
+ normalizePersona(current),
+ username,
+ );
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = analyzed;
+ });
+ return analyzed;
+ } catch (e) {
+ updateStore((s) => {
+ const i = s.personas.findIndex((p) => p.id === id);
+ if (i >= 0) s.personas[i] = normalizePersona(current);
+ });
+ throw e;
+ }
+ },
+ },
+ ownPosts: {
+ async list(accountId) {
+ const list = getStore().ownPosts;
+ return accountId ? list.filter((p) => p.account_id === accountId) : list;
+ },
+ async lastSyncedAt() {
+ return readJson(KEYS.ownPostsSyncedAt, null);
+ },
+ async sync(accountId) {
+ await mockDelay(800);
+ const now = nowUnixNano();
+ writeJson(KEYS.ownPostsSyncedAt, now);
+ // mock: 對齊 Threads insights 全欄位微幅更新
+ updateStore((s) => {
+ for (const p of s.ownPosts) {
+ if (p.account_id !== accountId) continue;
+ p.view_count = (p.view_count || 0) + Math.floor(Math.random() * 40);
+ p.like_count = (p.like_count || 0) + Math.floor(Math.random() * 4);
+ p.reply_count = Math.max(p.reply_count || 0, p.replies?.length || 0);
+ p.repost_count = (p.repost_count || 0) + Math.floor(Math.random() * 2);
+ p.quote_count = (p.quote_count || 0) + Math.floor(Math.random() * 2);
+ p.share_count = (p.share_count || 0) + Math.floor(Math.random() * 2);
+ p.insights_status = p.insights_status || "ok";
+ if (p.repost_count == null) p.repost_count = 0;
+ if (p.quote_count == null) p.quote_count = 0;
+ if (p.share_count == null) p.share_count = 0;
+ }
+ });
+ return getStore().ownPosts.filter((p) => p.account_id === accountId);
+ },
+ async generateReply({ postId, replyId, personaId }) {
+ await mockDelay();
+ recordUsage({
+ meter: "ai_copy",
+ label: "已發文回覆草稿",
+ source: "ownPosts.generateReply",
+ });
+ const post = getStore().ownPosts.find((p) => p.id === postId);
+ if (!post) throw new Error("找不到貼文");
+ const persona = requireReadyPersona(personaId);
+ const reply = replyId ? post.replies.find((r) => r.id === replyId) : undefined;
+ return mockGenerateOwnPostReply({
+ postText: post.text,
+ replyText: reply?.text,
+ persona,
+ });
+ },
+ async sendReply({ postId, replyId, text, accountId, imageUrls }) {
+ await mockDelay(400);
+ const body = withAttachedImageNote(text, imageUrls?.length || 0);
+ if (!body) throw new Error("請輸入回覆內容");
+ let nextPost = getStore().ownPosts.find((p) => p.id === postId);
+ if (!nextPost) throw new Error("找不到貼文");
+ const acc =
+ getStore().accounts.find((a) => a.id === accountId) ||
+ getStore().accounts.find((a) => a.id === nextPost?.account_id);
+ if (acc && !acc.is_usable) throw new Error("此帳號不可用,請換帳號回覆");
+ const asUser = acc?.username || "me_harbor";
+ const now = nowUnixNano();
+ updateStore((s) => {
+ const p = s.ownPosts.find((x) => x.id === postId);
+ if (!p) return;
+ // 回某則留言:標記該則為已回覆,子留言掛在 parent 下
+ if (replyId) {
+ const target = p.replies.find((r) => r.id === replyId);
+ if (target) {
+ target.reply_status = "replied";
+ target.replied_by = asUser;
+ target.replied_at = now;
+ }
+ }
+ p.replies.push({
+ id: newId("or"),
+ username: asUser,
+ text: body,
+ created_at: now,
+ reply_status: "replied",
+ like_count: 0,
+ parent_reply_id: replyId || null,
+ is_mine: true,
+ });
+ p.reply_count = p.replies.length;
+ nextPost = p;
+ });
+ return nextPost!;
+ },
+ async analyzePost(postId) {
+ const post = getStore().ownPosts.find((p) => p.id === postId);
+ if (!post) throw new Error("找不到貼文");
+ recordUsage({
+ meter: "ai_copy",
+ label: "貼文結構分析",
+ source: "ownPosts.analyzePost",
+ });
+ const analysis = await mockAnalyzeViral(post.text);
+ const detail = formatViralAnalysis(analysis);
+ let next = post;
+ updateStore((s) => {
+ const p = s.ownPosts.find((x) => x.id === postId);
+ if (!p) return;
+ p.formula_summary = analysis.hooks;
+ p.formula_detail = detail;
+ p.insight = analysis.summary;
+ next = p;
+ });
+ return next;
+ },
+ async generateFromFormula(postId, personaId) {
+ await mockDelay();
+ recordUsage({
+ meter: "ai_copy",
+ label: "依公式產新串",
+ source: "ownPosts.generateFromFormula",
+ });
+ const post = getStore().ownPosts.find((p) => p.id === postId);
+ if (!post) throw new Error("找不到貼文");
+ const persona = requireReadyPersona(personaId);
+ const topic = post.topic_tag || post.text.slice(0, 20);
+ return {
+ title: `${topic} · 再發一波`,
+ topic: mockGenerateTopic(post.formula_summary || topic, persona),
+ root: mockGenerateRoot(post.text, persona),
+ };
+ },
+ },
+ mentions: {
+ async list(accountId) {
+ const list = getStore().mentions;
+ return (accountId ? list.filter((m) => m.account_id === accountId) : list).sort(
+ (a, b) => b.created_at - a.created_at,
+ );
+ },
+ async generateReply(id, personaId) {
+ const item = getStore().mentions.find((m) => m.id === id);
+ if (!item) throw new Error("找不到提及");
+ recordUsage({
+ meter: "ai_copy",
+ label: "提及回覆草稿",
+ source: "mentions.generateReply",
+ });
+ const persona = requireReadyPersona(personaId);
+ const text = mockGenerateOwnPostReply({
+ postText: item.context_snippet,
+ replyText: item.text,
+ persona,
+ });
+ let next = item;
+ updateStore((s) => {
+ const m = s.mentions.find((x) => x.id === id);
+ if (!m) return;
+ m.draft_text = text;
+ next = m;
+ });
+ return next;
+ },
+ async markReplied(id, text, imageUrls) {
+ await mockDelay(300);
+ let next = getStore().mentions.find((m) => m.id === id);
+ if (!next) throw new Error("找不到提及");
+ updateStore((s) => {
+ const m = s.mentions.find((x) => x.id === id);
+ if (!m) return;
+ if (text?.trim()) {
+ m.draft_text = withAttachedImageNote(text, imageUrls?.length || 0);
+ } else if (imageUrls?.length) {
+ m.draft_text = withAttachedImageNote(m.draft_text || "", imageUrls.length);
+ }
+ m.status = "replied";
+ next = m;
+ });
+ return next!;
+ },
+ async skip(id) {
+ let next = getStore().mentions.find((m) => m.id === id);
+ if (!next) throw new Error("找不到提及");
+ updateStore((s) => {
+ const m = s.mentions.find((x) => x.id === id);
+ if (!m) return;
+ m.status = "skipped";
+ next = m;
+ });
+ return next!;
+ },
+ },
+ inspiration: {
+ async list() {
+ return getStore().inspirations;
+ },
+ async listTrends(kind = "all") {
+ const all = [...getStore().trends].sort((a, b) => b.heat - a.heat);
+ if (kind === "all") return all;
+ return all.filter((t) => t.kind === kind);
+ },
+ async refreshTrends(kind = "all") {
+ const current = getStore().trends;
+ const refreshed = await mockRefreshTrends(current, kind);
+ recordUsage({
+ meter: "web_search",
+ label: "刷新熱點",
+ source: "inspiration.refreshTrends",
+ });
+ updateStore((s) => {
+ s.trends = refreshed;
+ });
+ if (kind === "all") return refreshed;
+ return refreshed.filter((t) => t.kind === kind);
+ },
+ async searchTrends(query) {
+ const found = await mockSearchTrends(query);
+ recordUsage({
+ meter: "web_search",
+ label: `搜尋熱點「${query.trim().slice(0, 24) || "…"}」`,
+ source: "inspiration.searchTrends",
+ });
+ updateStore((s) => {
+ // merge by label: new search hits float to top
+ const labels = new Set(found.map((t) => t.label));
+ const rest = s.trends.filter((t) => !labels.has(t.label));
+ s.trends = [...found, ...rest].slice(0, 24);
+ });
+ return found;
+ },
+ async listElements() {
+ return getInspireElements().slice().sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async saveElement(el) {
+ const now = nowUnixNano();
+ const next: InspireElement = {
+ ...el,
+ id: el.id || newId("el"),
+ title: el.title.trim() || el.kind,
+ body: el.body.trim(),
+ reusable: el.reusable !== false,
+ created_at: el.created_at || now,
+ updated_at: now,
+ };
+ if (!next.body && next.kind !== "persona" && next.kind !== "brand") {
+ throw new Error("請填元素內容");
+ }
+ const list = getInspireElements();
+ const i = list.findIndex((x) => x.id === next.id);
+ if (i >= 0) list[i] = next;
+ else list.unshift(next);
+ setInspireElements(list);
+ return next;
+ },
+ async removeElement(id) {
+ setInspireElements(getInspireElements().filter((x) => x.id !== id));
+ const sess = getInspireSession();
+ if (sess.pinned_element_ids.includes(id)) {
+ setInspireSession({
+ ...sess,
+ pinned_element_ids: sess.pinned_element_ids.filter((x) => x !== id),
+ updated_at: nowUnixNano(),
+ });
+ }
+ },
+ async getSession() {
+ return getInspireSession();
+ },
+ async saveSession(session) {
+ const next = { ...session, updated_at: nowUnixNano() };
+ setInspireSession(next);
+ return next;
+ },
+ async clearSession() {
+ const next = emptyInspireSession();
+ setInspireSession(next);
+ return next;
+ },
+ async chat({ message, pinnedIds, mode }) {
+ const elements = getInspireElements().filter((e) => pinnedIds.includes(e.id));
+ // also allow pinned ids that are virtual persona/brand refs stored as elements
+ const store = getStore();
+ recordUsage({
+ meter: "ai_copy",
+ label: mode === "generate" ? "靈感產文" : "靈感聊天",
+ source: `inspiration.chat.${mode}`,
+ credits: mode === "generate" ? 2 : 1,
+ });
+ const added = await mockInspireChat({
+ userMessage: message,
+ mode,
+ elements,
+ personas: store.personas,
+ brands: store.brands,
+ });
+ const prev = getInspireSession();
+ const session = {
+ ...prev,
+ pinned_element_ids: [...pinnedIds],
+ messages: [...prev.messages, ...added],
+ updated_at: nowUnixNano(),
+ };
+ setInspireSession(session);
+ return { session, messages: added };
+ },
+ async sparkAngles(trendId, opts) {
+ const trend = getStore().trends.find((t) => t.id === trendId);
+ if (!trend) throw new Error("找不到這個熱點");
+ const rawP = opts?.personaId
+ ? getStore().personas.find((p) => p.id === opts.personaId)
+ : null;
+ const persona = rawP && isPersonaReady(rawP) ? rawP : null;
+ const brand = opts?.brandId
+ ? getStore().brands.find((b) => b.id === opts.brandId) || null
+ : null;
+ return mockGenerateInspireAngles({ trend, persona, brand });
+ },
+ async sparkAnglesForTopic(label, opts) {
+ const trend = trendFromQuery(label);
+ updateStore((s) => {
+ if (!s.trends.some((t) => t.id === trend.id)) s.trends.unshift(trend);
+ });
+ const rawP = opts?.personaId
+ ? getStore().personas.find((p) => p.id === opts.personaId)
+ : null;
+ const persona = rawP && isPersonaReady(rawP) ? rawP : null;
+ const brand = opts?.brandId
+ ? getStore().brands.find((b) => b.id === opts.brandId) || null
+ : null;
+ if (opts?.samples?.length) trend.samples = opts.samples;
+ const angles = await mockGenerateInspireAngles({ trend, persona, brand });
+ return { trend, angles };
+ },
+ async sparkFromTrend(trendId, personaId, opts) {
+ await mockDelay();
+ const trend = getStore().trends.find((t) => t.id === trendId);
+ if (!trend) throw new Error("找不到這個熱點");
+ const persona = personaId
+ ? getStore().personas.find((p) => p.id === personaId)
+ : null;
+ const ready = persona && isPersonaReady(persona) ? persona : null;
+ const gen = sparkCopyFromTrend(trend, ready);
+ const idea: InspirationIdea = {
+ id: newId("insp"),
+ title: gen.title,
+ hook: gen.hook,
+ angle: gen.angle,
+ source: "trend",
+ trend_id: trend.id,
+ };
+ if (opts?.save) {
+ updateStore((s) => {
+ s.inspirations.unshift(idea);
+ });
+ }
+ return idea;
+ },
+ async bookmarkTrend(trendId) {
+ await mockDelay(200);
+ const trend = getStore().trends.find((t) => t.id === trendId);
+ if (!trend) throw new Error("找不到這個熱點");
+ const sample = trend.samples[0] || trend.summary;
+ const existing = getStore().inspirations.find(
+ (i) => i.trend_id === trend.id && i.source === "trend" && i.hook === sample,
+ );
+ if (existing) return existing;
+ const idea: InspirationIdea = {
+ id: newId("insp"),
+ title: trend.label,
+ hook: sample,
+ angle: trend.summary,
+ source: "trend",
+ trend_id: trend.id,
+ };
+ updateStore((s) => {
+ s.inspirations.unshift(idea);
+ });
+ return idea;
+ },
+ async saveIdea(idea) {
+ await mockDelay(150);
+ const next = { ...idea, id: idea.id || newId("insp") };
+ updateStore((s) => {
+ const idx = s.inspirations.findIndex((i) => i.id === next.id);
+ if (idx >= 0) s.inspirations[idx] = next;
+ else s.inspirations.unshift(next);
+ });
+ return next;
+ },
+ async generate(topic, personaId) {
+ await mockDelay();
+ const persona = requireReadyPersona(personaId);
+ const gen = mockGenerateInspiration(topic, persona);
+ const idea: InspirationIdea = {
+ id: newId("insp"),
+ title: gen.title,
+ hook: gen.hook,
+ angle: gen.angle,
+ source: "persona",
+ };
+ updateStore((s) => {
+ s.inspirations.unshift(idea);
+ });
+ return idea;
+ },
+ async listViral() {
+ return getStore().viralSamples;
+ },
+ },
+ research: {
+ async search(query) {
+ recordUsage({
+ meter: "web_search",
+ label: "研究檢索",
+ source: "research.search",
+ });
+ return mockWebResearch(query);
+ },
+ },
+ media: {
+ async generateImage(prompt) {
+ recordUsage({
+ meter: "ai_image",
+ label: "AI 生圖",
+ source: "media.generateImage",
+ });
+ return mockGenerateImage(prompt);
+ },
+ async attachLocal(files) {
+ return filesToAttachedImages(files);
+ },
+ },
+ compose: {
+ async mimic(sourceText, personaId) {
+ const persona = requireReadyPersona(personaId);
+ recordUsage({
+ meter: "ai_copy",
+ label: "仿寫產文",
+ source: "compose.mimic",
+ });
+ return mockMimicPost(sourceText, persona);
+ },
+ async analyzeViral(text) {
+ recordUsage({
+ meter: "ai_copy",
+ label: "爆紅結構分析",
+ source: "compose.analyzeViral",
+ });
+ return mockAnalyzeViral(text);
+ },
+ async publishSingle({ accountId, text, title, imageUrls }) {
+ const body = withAttachedImageNote(text, imageUrls?.length || 0);
+ if (!body) throw new Error("請輸入正文");
+ const acc = getStore().accounts.find((a) => a.id === accountId);
+ if (!acc?.is_usable) throw new Error("帳號不可用");
+ const now = nowUnixNano();
+ const persistedUrls = imageUrls?.length
+ ? imageUrls.slice(0, 10).map((url, j) =>
+ toPersistableImageUrl({ id: `pub_${j}`, url, name: `attach-${j + 1}` }),
+ )
+ : undefined;
+ const play: ThreadPlay = {
+ id: newId("play"),
+ title: title?.trim() || body.slice(0, 24) || "單篇貼文",
+ topic: body.slice(0, 80),
+ status: "draft",
+ lead_account_id: accountId,
+ cast_account_ids: [],
+ steps: [
+ {
+ id: newId("step"),
+ sort_order: 0,
+ kind: "root",
+ account_id: accountId,
+ text: body,
+ delay_from_previous_sec: 0,
+ image_urls: persistedUrls,
+ },
+ ],
+ schedule_start_at: now,
+ created_at: now,
+ updated_at: now,
+ };
+ updateStore((s) => {
+ s.plays.unshift(play);
+ });
+ // reuse plays.submit path
+ const err = validatePlaySteps(play);
+ if (err) throw new Error(err);
+ const bundle = playToOutbox(play);
+ updateStore((s) => {
+ const i = s.plays.findIndex((p) => p.id === play.id);
+ if (i >= 0) s.plays[i] = { ...s.plays[i], status: "scheduling", updated_at: nowUnixNano() };
+ s.outbox.unshift(bundle);
+ });
+ return bundle;
+ },
+ },
+ scout: {
+ async listBrands() {
+ return getStore().brands;
+ },
+ async get(id) {
+ return getStore().brands.find((b) => b.id === id) || null;
+ },
+ async getActiveBrandId() {
+ const id = getActiveBrandId();
+ const brands = getStore().brands;
+ if (brands.some((b) => b.id === id)) return id;
+ return brands[0]?.id || "";
+ },
+ async setActiveBrandId(id) {
+ setActiveBrandId(id);
+ },
+ async createBrand(input) {
+ await mockDelay(200);
+ const brand: Brand = {
+ id: newId("brand"),
+ display_name: (input?.display_name || "").trim() || "新品牌",
+ brief: (input?.brief || "").trim(),
+ target_audience: "",
+ goals: "",
+ };
+ updateStore((s) => {
+ s.brands.unshift(brand);
+ });
+ setActiveBrandId(brand.id);
+ return brand;
+ },
+ async saveBrand(brand) {
+ const name = brand.display_name.trim();
+ if (!name) throw new Error("牌子名稱不可為空白");
+ const next: Brand = {
+ ...brand,
+ display_name: name,
+ brief: (brand.brief || "").trim(),
+ target_audience: (brand.target_audience || "").trim(),
+ goals: (brand.goals || "").trim(),
+ };
+ updateStore((s) => {
+ const i = s.brands.findIndex((b) => b.id === next.id);
+ if (i >= 0) s.brands[i] = next;
+ else s.brands.unshift(next);
+ });
+ return next;
+ },
+ async removeBrand(id) {
+ updateStore((s) => {
+ s.brands = s.brands.filter((b) => b.id !== id);
+ s.brandProducts = s.brandProducts.filter((p) => p.brand_id !== id);
+ s.scoutTopics = s.scoutTopics.filter((t) => t.brand_id !== id);
+ s.scoutPosts = s.scoutPosts.filter((p) => p.brand_id !== id);
+ });
+ if (getActiveBrandId() === id) {
+ const first = getStore().brands[0]?.id || "";
+ setActiveBrandId(first);
+ }
+ },
+ async listProducts(brandId) {
+ return getStore().brandProducts.filter((p) => p.brand_id === brandId);
+ },
+ async listAllProducts() {
+ return getStore().brandProducts.slice().sort((a, b) => b.updated_at - a.updated_at);
+ },
+ async getProduct(id) {
+ return getStore().brandProducts.find((p) => p.id === id) || null;
+ },
+ async saveProduct(product) {
+ const label = product.label.trim();
+ const ctx = product.product_context.trim();
+ if (!label || !ctx) throw new Error("產品名稱與介紹為必填");
+ if (!product.brand_id) throw new Error("產品需綁定品牌");
+ const now = nowUnixNano();
+ const next: BrandProduct = {
+ ...product,
+ id: product.id || newId("prod"),
+ label,
+ product_context: ctx,
+ match_tags: (product.match_tags || []).map((t) => t.trim()).filter(Boolean),
+ pain_points: (product.pain_points || []).map((t) => t.trim()).filter(Boolean),
+ placement_url: (product.placement_url || "").trim() || undefined,
+ created_at: product.created_at || now,
+ updated_at: now,
+ };
+ updateStore((s) => {
+ const i = s.brandProducts.findIndex((p) => p.id === next.id);
+ if (i >= 0) s.brandProducts[i] = next;
+ else s.brandProducts.unshift(next);
+ });
+ return next;
+ },
+ async removeProduct(id) {
+ updateStore((s) => {
+ s.brandProducts = s.brandProducts.filter((p) => p.id !== id);
+ s.scoutTopics = s.scoutTopics.map((t) =>
+ t.preferred_product_id === id ? { ...t, preferred_product_id: null } : t,
+ );
+ });
+ },
+ async importProductFromUrl(url) {
+ return mockImportProductFromUrl(url);
+ },
+ async listTopics(brandId) {
+ const list = getStore().scoutTopics;
+ return brandId ? list.filter((t) => t.brand_id === brandId) : list;
+ },
+ async saveTopic(topic) {
+ const next: ScoutTopic = {
+ ...topic,
+ name: topic.name.trim() || "未命名主題",
+ keywords: topic.keywords.map((k) => k.trim()).filter(Boolean),
+ preferred_product_id: topic.preferred_product_id || null,
+ };
+ if (!next.brand_id) throw new Error("主題需綁定品牌");
+ updateStore((s) => {
+ const i = s.scoutTopics.findIndex((t) => t.id === next.id);
+ if (i >= 0) s.scoutTopics[i] = next;
+ else s.scoutTopics.unshift(next);
+ });
+ return next;
+ },
+ async removeTopic(id) {
+ updateStore((s) => {
+ s.scoutTopics = s.scoutTopics.filter((t) => t.id !== id);
+ // posts 已改以 brand_id 為主;主題刪除不強制清 post
+ });
+ },
+ async prepareBrief({ intent, brandId, productId, purpose, deep }) {
+ // 輕量路徑:幾乎不 delay,避免卡在「做功課」
+ if (deep) await mockDelay(280);
+ else await mockDelay(80);
+ const pid = (productId || "").trim();
+ const product =
+ purpose === "activity"
+ ? null
+ : pid
+ ? getStore().brandProducts.find((p) => p.id === pid) || null
+ : null;
+ if (purpose !== "activity" && pid && !product) {
+ throw new Error("找不到產品(可能已刪除,請重新選擇)");
+ }
+ const brief = prepareScoutBrief({
+ intent,
+ brandId: purpose === "activity" ? null : brandId || product?.brand_id || null,
+ product: purpose === "activity" ? null : product,
+ purpose: purpose || "value",
+ });
+ // 產品欄位一律帶上(不需等上網)
+ if (brief.mode !== "activity") {
+ if (product) {
+ brief.product_id = product.id;
+ brief.product_label = product.label;
+ brief.product_context = product.product_context || brief.placement_note;
+ brief.pains = product.pain_points?.length
+ ? [...product.pain_points]
+ : brief.pains;
+ brief.match_tags_detail = product.match_tags?.length
+ ? [...product.match_tags]
+ : brief.tags;
+ brief.placement_note =
+ product.product_context?.trim() ||
+ brief.placement_note ||
+ `共感後輕帶「${product.label}」`;
+ } else {
+ brief.product_context = brief.placement_note;
+ brief.match_tags_detail = brief.tags;
+ }
+ }
+ // 完整上網功課只在 deep=true(背景補),不擋海巡
+ if (brief.mode !== "activity" && deep) {
+ recordUsage({
+ meter: "ai_research",
+ label: "海巡周邊知識(深度)",
+ source: "scout.prepareBrief.deep",
+ });
+ recordUsage({
+ meter: "web_search",
+ label: "海巡上網檢索",
+ source: "scout.prepareBrief.research",
+ credits: 2,
+ });
+ const q = [intent, product?.label, product?.pain_points?.[0]]
+ .filter(Boolean)
+ .join(" ");
+ const hits = await mockWebResearch(q.slice(0, 48));
+ const expandPages = await mockExpandKnowledgePages(
+ brief.periphery.length ? brief.periphery : brief.scan_terms,
+ product?.label || brief.intent.slice(0, 20),
+ );
+ const notes = [...researchHitsToScoutNotes(hits), ...expandPages];
+ brief.research_notes = notes;
+ const extraKw = notes.flatMap((n) => n.keywords || []).filter(Boolean);
+ brief.scan_terms = [...new Set([...brief.scan_terms, ...extraKw])].slice(0, 16);
+ }
+ return brief;
+ },
+ async runScanFromBrief(brief) {
+ return mockRunScanFromBrief(brief);
+ },
+ async getScanContext(brandId, productId) {
+ const products = getStore().brandProducts.filter((p) => p.brand_id === brandId);
+ const focused = productId
+ ? products.filter((p) => p.id === productId)
+ : products;
+ if (!focused.length && !products.length) {
+ return {
+ brand_id: brandId,
+ product_ids: [],
+ pains: [],
+ tags: [],
+ expand_terms: [],
+ };
+ }
+ return buildScoutScanContext(brandId, focused.length ? focused : products);
+ },
+ async listPosts(brandId) {
+ const list = getStore().scoutPosts;
+ if (!brandId) return list;
+ return list.filter((p) => p.brand_id === brandId);
+ },
+ async runScan(brandId, productId, extraTerms) {
+ // 相容舊 API:轉 brief
+ const products = getStore().brandProducts.filter((p) => p.brand_id === brandId);
+ const pref =
+ (productId ? products.find((p) => p.id === productId) : null) || products[0] || null;
+ if (!pref) throw new Error("此品牌尚無產品(請改用意圖海巡)");
+ const brief: ScoutRunBrief = prepareScoutBrief({
+ intent: `海巡 ${pref.label}`,
+ brandId,
+ product: pref,
+ });
+ if (extraTerms?.length) {
+ brief.scan_terms = [...new Set([...brief.scan_terms, ...extraTerms])];
+ }
+ return mockRunScanFromBrief(brief);
+ },
+ async draftOutreach(postId, personaId) {
+ await mockDelay();
+ recordUsage({
+ meter: "ai_copy",
+ label: "海巡外展草稿",
+ source: "scout.draftOutreach",
+ });
+ const post = getStore().scoutPosts.find((p) => p.id === postId);
+ if (!post) throw new Error("找不到掃描貼文");
+ const mode =
+ post.scout_mode ||
+ (post.matched_product_id ? "product" : "theme");
+ const brand = post.brand_id
+ ? getStore().brands.find((b) => b.id === post.brand_id)
+ : null;
+ const products = post.brand_id
+ ? getStore().brandProducts.filter((p) => p.brand_id === post.brand_id)
+ : [];
+ let product = products.find((p) => p.id === post.matched_product_id) || null;
+ if (mode === "product" && !product && products.length) {
+ product = resolveProductForPost({
+ products,
+ preferredProductId: post.matched_product_id,
+ searchTag: post.search_tag,
+ postText: post.text,
+ }).product;
+ }
+ const rawPersona = personaId
+ ? getStore().personas.find((p) => p.id === personaId)
+ : null;
+ const persona = rawPersona && isPersonaReady(rawPersona) ? rawPersona : null;
+ const draft = mockGenerateScoutDraft({
+ postText: post.text,
+ brandName: brand?.display_name || "日常",
+ brandBrief: brand?.brief,
+ targetAudience: brand?.target_audience,
+ productLabel: mode === "product" ? product?.label : undefined,
+ productContext: mode === "product" ? product?.product_context : undefined,
+ painHint:
+ mode === "product"
+ ? product?.pain_points[0] || post.search_tag
+ : post.search_tag,
+ placementUrl: mode === "product" ? product?.placement_url : undefined,
+ persona,
+ mode,
+ });
+ let next: ScoutPost = post;
+ updateStore((s) => {
+ const i = s.scoutPosts.findIndex((p) => p.id === postId);
+ if (i >= 0) {
+ s.scoutPosts[i] = {
+ ...s.scoutPosts[i],
+ draft_text: draft,
+ outreach_status: "drafted",
+ scout_mode: mode,
+ matched_product_id:
+ mode === "product"
+ ? product?.id || s.scoutPosts[i].matched_product_id
+ : null,
+ matched_product_label:
+ mode === "product"
+ ? product?.label || s.scoutPosts[i].matched_product_label
+ : null,
+ };
+ next = s.scoutPosts[i];
+ }
+ });
+ return next;
+ },
+ async skipOutreach(postId) {
+ let next: ScoutPost | null = null;
+ updateStore((s) => {
+ const i = s.scoutPosts.findIndex((p) => p.id === postId);
+ if (i >= 0) {
+ s.scoutPosts[i] = { ...s.scoutPosts[i], outreach_status: "skipped" };
+ next = s.scoutPosts[i];
+ }
+ });
+ if (!next) throw new Error("找不到掃描貼文");
+ return next;
+ },
+ async markPublished(postId) {
+ let next: ScoutPost | null = null;
+ updateStore((s) => {
+ const i = s.scoutPosts.findIndex((p) => p.id === postId);
+ if (i >= 0) {
+ s.scoutPosts[i] = { ...s.scoutPosts[i], outreach_status: "published" };
+ next = s.scoutPosts[i];
+ }
+ });
+ if (!next) throw new Error("找不到掃描貼文");
+ pushNotification({
+ title: "外展已標記發送",
+ body: "已將探查草稿標記為已回覆",
+ kind: "system",
+ ref_type: "none",
+ });
+ return next;
+ },
+ async sendOutreach({ postId, text, accountId }) {
+ await mockDelay(350);
+ const body = text.trim();
+ if (!body) throw new Error("請先有回覆草稿");
+ const post = getStore().scoutPosts.find((p) => p.id === postId);
+ if (!post) throw new Error("找不到命中");
+ const acc = accountId
+ ? getStore().accounts.find((a) => a.id === accountId)
+ : getStore().accounts.find((a) => a.is_usable);
+ if (acc && !acc.is_usable) throw new Error("此帳號不可用");
+ const asUser = acc?.username || "me_harbor";
+ let next: ScoutPost = post;
+ updateStore((s) => {
+ const i = s.scoutPosts.findIndex((p) => p.id === postId);
+ if (i < 0) return;
+ s.scoutPosts[i] = {
+ ...s.scoutPosts[i],
+ draft_text: body,
+ outreach_status: "published",
+ };
+ next = s.scoutPosts[i];
+ });
+ pushNotification({
+ title: "外展已發送",
+ body: `@${asUser} 已回 @${post.author}`,
+ kind: "system",
+ ref_type: "none",
+ });
+ return next;
+ },
+ async removePost(postId) {
+ const before = getStore().scoutPosts.length;
+ updateStore((s) => {
+ s.scoutPosts = s.scoutPosts.filter((p) => p.id !== postId);
+ });
+ if (getStore().scoutPosts.length === before) throw new Error("找不到命中");
+ },
+ async removeTheme(themeKey) {
+ const key = (themeKey || "").trim();
+ if (!key) throw new Error("無效主題");
+ updateStore((s) => {
+ s.scoutPosts = s.scoutPosts.filter((p) => {
+ const k =
+ p.theme_key ||
+ (p.matched_product_id
+ ? `product|${p.matched_product_id}`
+ : p.intent_snippet
+ ? `intent|${p.intent_snippet}`
+ : p.scout_mode === "activity"
+ ? `activity|${p.search_tag || "x"}`
+ : `tag|${p.search_tag || "other"}`);
+ return k !== key;
+ });
+ });
+ setScoutHomework(getScoutHomework().filter((h) => h.theme_key !== key));
+ },
+ async listHomework() {
+ return getScoutHomework()
+ .slice()
+ .sort((a, b) => b.created_at - a.created_at);
+ },
+ async saveHomework(record) {
+ const next: ScoutHomeworkRecord = {
+ ...record,
+ theme_key: record.theme_key || record.brief.theme_key || "",
+ theme_label: record.theme_label || record.brief.theme_label || record.brief.intent,
+ brief: {
+ ...record.brief,
+ theme_key: record.theme_key || record.brief.theme_key,
+ theme_label: record.theme_label || record.brief.theme_label,
+ },
+ };
+ if (!next.theme_key) throw new Error("功課缺少 theme_key");
+ const list = getScoutHomework().filter((h) => h.theme_key !== next.theme_key);
+ list.unshift(next);
+ setScoutHomework(list.slice(0, 40));
+ return next;
+ },
+ async getHomework(themeKey) {
+ const key = (themeKey || "").trim();
+ if (!key) return null;
+ return getScoutHomework().find((h) => h.theme_key === key) || null;
+ },
+ async removeHomework(themeKey) {
+ const key = (themeKey || "").trim();
+ if (!key) return;
+ setScoutHomework(getScoutHomework().filter((h) => h.theme_key !== key));
+ },
+ },
+ usage: {
+ async getSummary(monthKey, uid) {
+ return summarizeUsage(uid, monthKey);
+ },
+ async listEvents(limit = 100, uid) {
+ return listUsageEvents({ limit, uid });
+ },
+ async getPlanId() {
+ return getPlanId();
+ },
+ async setPlanId(id) {
+ setPlanId(id);
+ },
+ async getMemberPrefs(uid) {
+ return getMemberPrefs(uid || memberFromSession()?.uid || "unknown");
+ },
+ async setMemberPrefs(uid, patch) {
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ return setMemberPrefs(uid, patch);
+ },
+ async getTenantSummary(monthKey) {
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ const members = listAdminViews().map((u) => ({
+ uid: u.uid,
+ email: u.email,
+ display_name: u.display_name,
+ }));
+ ensureDemoUsageHistory(members);
+ return summarizeTenantUsage(members, monthKey);
+ },
+ async getTenantAnalytics(query) {
+ const me = memberFromSession();
+ if (!me?.roles.includes("admin")) throw new Error("需要管理員權限");
+ const members = listAdminViews().map((u) => ({
+ uid: u.uid,
+ email: u.email,
+ display_name: u.display_name,
+ }));
+ ensureDemoUsageHistory(members);
+ return summarizeTenantAnalytics(members, query);
+ },
+ async purchasePlan(plan_id, opts) {
+ await mockDelay(420);
+ const me = memberFromSession();
+ if (!me) throw new Error("尚未登入");
+ const user = findUserByUid(me.uid);
+ if (user && isMemberSuspended(user)) {
+ throw new Error("帳號已停權,無法購買");
+ }
+ return mockCompletePlanPurchase({
+ uid: me.uid,
+ plan_id,
+ mock_ref: opts?.mock_ref,
+ });
+ },
+ async listMyPurchases(limit = 20) {
+ const me = memberFromSession();
+ if (!me) return [];
+ return listPlanPurchases(me.uid, limit);
+ },
+ },
+ };
+}
+
+const timers = new Map();
+
+function runJobTimer(jobId: string) {
+ if (timers.has(jobId)) return;
+ const handle = window.setInterval(() => {
+ let done = false;
+ updateStore((s) => {
+ const job = s.jobs.find((j) => j.id === jobId);
+ if (!job || job.status !== "running") {
+ done = true;
+ return;
+ }
+ const next = Math.min(100, job.progress_percent + 20);
+ job.progress_percent = next;
+ job.updated_at = nowUnixNano();
+ job.progress_summary = next >= 100 ? "任務完成" : `進度 ${next}%`;
+ if (next >= 100) {
+ job.status = "succeeded";
+ job.completed_at = nowUnixNano();
+ done = true;
+ s.notifications.unshift({
+ id: newId("ntf"),
+ title: "任務完成",
+ body: "demo_long_task 已成功",
+ kind: "job",
+ ref_type: "job",
+ ref_id: job.id,
+ read_at: null,
+ created_at: nowUnixNano(),
+ });
+ }
+ });
+ if (done) {
+ window.clearInterval(handle);
+ timers.delete(jobId);
+ window.dispatchEvent(new CustomEvent("harbor:store"));
+ } else {
+ window.dispatchEvent(new CustomEvent("harbor:store"));
+ }
+ }, 800);
+ timers.set(jobId, handle);
+}
+
+/** Resume timers for running jobs after reload */
+export function resumeRunningJobs(): void {
+ for (const job of getStore().jobs) {
+ if (job.status === "running") runJobTimer(job.id);
+ }
+}
diff --git a/apps/web/src/data/mock/seed.ts b/apps/web/src/data/mock/seed.ts
new file mode 100644
index 0000000..d92a3a8
--- /dev/null
+++ b/apps/web/src/data/mock/seed.ts
@@ -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 [];
+}
diff --git a/apps/web/src/data/mock/store.ts b/apps/web/src/data/mock/store.ts
new file mode 100644
index 0000000..b2eda0e
--- /dev/null
+++ b/apps/web/src/data/mock/store.ts
@@ -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(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(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(KEYS.session, null);
+}
+
+export function setSession(session: SessionState | null): void {
+ if (!session) {
+ writeJson(KEYS.session, null);
+ return;
+ }
+ writeJson(KEYS.session, session);
+}
diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts
new file mode 100644
index 0000000..2ff8d53
--- /dev/null
+++ b/apps/web/src/data/repos.ts
@@ -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;
+ me(): Promise;
+ /** 更新自己的會員資料(mock 寫 session) */
+ updateProfile(patch: MemberProfilePatch): Promise;
+ /**
+ * 申請重設密碼。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;
+};
+
+/** 管理者新增島民 */
+export type AdminCreateMemberInput = {
+ display_name: string;
+ email: string;
+ password?: string;
+ roles?: Role[];
+ email_verified?: boolean;
+ bio?: string;
+};
+
+/** 管理員:島民列表與可寫操作 */
+export type AdminUsersRepo = {
+ /** @deprecated 請用 listUsersPage */
+ listUsers(): Promise;
+ /** 分頁列表 page 從 1 起;query 可搜名稱 / Email / uid */
+ listUsersPage(opts?: {
+ page?: number;
+ pageSize?: number;
+ query?: string;
+ }): Promise;
+ getUser(uid: string): Promise;
+ /** 新增島民(回傳臨時密碼) */
+ createMember(
+ input: AdminCreateMemberInput,
+ ): Promise<{ user: MemberAdminView; temporary_password: string }>;
+ /** 停權 / 復權 */
+ setSuspended(uid: string, suspended: boolean): Promise;
+ /** 改驗證狀態 */
+ setEmailVerified(uid: string, verified: boolean): Promise;
+ /** 指派角色(member / admin) */
+ setRoles(uid: string, roles: Role[]): Promise;
+ /**
+ * 幫使用者重設密碼。未傳 newPassword 則產生臨時密碼。
+ * 回傳 temporary_password 僅此次顯示。
+ */
+ resetPassword(
+ uid: string,
+ newPassword?: string,
+ ): Promise<{ user: MemberAdminView; temporary_password: string }>;
+};
+
+export type AccountsRepo = {
+ list(): Promise;
+ createMock(): Promise;
+ remove(id: string): Promise;
+ refreshSession(id: string): Promise;
+};
+
+export type PlaysRepo = {
+ list(): Promise;
+ listByPost(ownPostId: string): Promise;
+ /** 依外部 Threads 連結列出方案(URL 正規化後比對) */
+ listByExternalUrl(url: string): Promise;
+ /** 貼連結 → mock 解析目標貼文 */
+ resolveExternalLink(url: string): Promise;
+ get(id: string): Promise;
+ save(play: ThreadPlay): Promise;
+ remove(id: string): Promise;
+ submit(id: string): Promise;
+};
+
+export type OutboxRepo = {
+ list(): Promise;
+ get(id: string): Promise;
+ simulateSuccess(id: string): Promise;
+ simulateRootFail(id: string): Promise;
+ retryStep(bundleId: string, stepId: string): Promise;
+ /** 刪除整筆發送佇列項目 */
+ remove(id: string): Promise;
+};
+
+export type JobsRepo = {
+ list(): Promise;
+ get(id: string): Promise;
+ startDemo(): Promise;
+};
+
+export type NotificationsRepo = {
+ list(): Promise;
+ unreadCount(): Promise;
+ markRead(id: string): Promise;
+ markAllRead(): Promise;
+};
+
+export type SettingsRepo = {
+ getDataSource(): DataSource;
+ setDataSource(ds: DataSource): void;
+ getAi(): Promise;
+ saveAi(patch: Partial & { api_key?: string; research_api_key?: string }): Promise;
+ getPlacement(): Promise;
+ savePlacement(
+ patch: Partial & { brave_api_key?: string; exa_api_key?: string },
+ ): Promise;
+ listModels(provider: string): Promise;
+};
+
+export type PersonasRepo = {
+ list(): Promise;
+ get(id: string): Promise;
+ getActiveId(): Promise;
+ setActiveId(id: string): Promise;
+ save(persona: Persona): Promise;
+ remove(id: string): Promise;
+ analyzeFromText(id: string, rawText: string, sourceLabel?: string): Promise;
+ analyzeFromAccount(id: string, username: string): Promise;
+};
+
+export type OwnPostsRepo = {
+ list(accountId?: string): Promise;
+ lastSyncedAt(): Promise;
+ sync(accountId: string): Promise;
+ generateReply(opts: {
+ postId: string;
+ replyId?: string;
+ personaId?: string;
+ }): Promise;
+ sendReply(opts: {
+ postId: string;
+ replyId?: string;
+ text: string;
+ /** 用哪個帳號送出(mock 寫入 username) */
+ accountId?: string;
+ /** 附圖 URL(mock 記張數/可選縮圖) */
+ imageUrls?: string[];
+ }): Promise;
+ analyzePost(postId: string): Promise;
+ generateFromFormula(postId: string, personaId?: string): Promise<{ title: string; topic: string; root: string }>;
+};
+
+export type MentionsRepo = {
+ list(accountId?: string): Promise;
+ generateReply(id: string, personaId?: string): Promise;
+ markReplied(id: string, text?: string, imageUrls?: string[]): Promise;
+ skip(id: string): Promise;
+};
+
+export type InspirationRepo = {
+ list(): Promise;
+ /** 預設靈感榜:Threads 熱標(提示用) */
+ listTrends(kind?: TrendKind | "all"): Promise;
+ refreshTrends(kind?: TrendKind | "all"): Promise;
+ searchTrends(query: string): Promise;
+ /** 元素庫 */
+ listElements(): Promise;
+ saveElement(el: InspireElement): Promise;
+ removeElement(id: string): Promise;
+ /** 當前聊天 session(單一會話) */
+ getSession(): Promise;
+ saveSession(session: InspireSession): Promise;
+ clearSession(): Promise;
+ /**
+ * 聊天/產文。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;
+ /** @deprecated */
+ sparkAnglesForTopic(
+ label: string,
+ opts?: { personaId?: string | null; brandId?: string | null; samples?: string[] },
+ ): Promise<{ trend: TrendItem; angles: InspireAngle[] }>;
+ bookmarkTrend(trendId: string): Promise;
+ saveIdea(idea: InspirationIdea): Promise;
+ generate(topic: string, personaId?: string): Promise;
+ listViral(): Promise;
+ /** @deprecated */
+ sparkFromTrend(
+ trendId: string,
+ personaId?: string,
+ opts?: { save?: boolean },
+ ): Promise;
+};
+
+export type ResearchRepo = {
+ search(query: string): Promise;
+};
+
+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;
+ analyzeViral(text: string): Promise;
+ /** 單篇送出 Outbox */
+ publishSingle(opts: {
+ accountId: string;
+ text: string;
+ title?: string;
+ imageUrls?: string[];
+ }): Promise;
+};
+
+export type ScoutRepo = {
+ listBrands(): Promise;
+ get(id: string): Promise;
+ getActiveBrandId(): Promise;
+ setActiveBrandId(id: string): Promise;
+ /** 對齊舊 BrandsPage:建立牌子 */
+ createBrand(input?: { display_name?: string; brief?: string }): Promise;
+ saveBrand(brand: Brand): Promise;
+ removeBrand(id: string): Promise;
+ listProducts(brandId: string): Promise;
+ /** 全部產品(探查下拉用) */
+ listAllProducts(): Promise;
+ getProduct(id: string): Promise;
+ saveProduct(product: BrandProduct): Promise;
+ removeProduct(id: string): Promise;
+ /**
+ * 反著做:貼商品連結 → 抓取/推估後回填表單草稿(尚未存檔)。
+ * 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;
+ saveTopic(topic: ScoutTopic): Promise;
+ removeTopic(id: string): Promise;
+ /**
+ * 意圖 + 可選產品 → 可審知識 brief(痛點/周邊/掃描詞)
+ * 無產品 = theme 模式
+ */
+ prepareBrief(opts: {
+ intent: string;
+ brandId?: string | null;
+ productId?: string | null;
+ /** value=痛點/置入(預設);activity=關鍵字活躍 */
+ purpose?: "value" | "activity";
+ /**
+ * 是否做完整上網功課(摘要+分層來源)。
+ * 預設 false:只組痛點/掃描詞,不擋海巡;背景再 deep 補齊。
+ */
+ deep?: boolean;
+ }): Promise;
+ /** 依 brief(含使用者勾選後的 scan_terms)海巡 */
+ runScanFromBrief(brief: ScoutRunBrief): Promise;
+ /** @deprecated 用 prepareBrief */
+ getScanContext(brandId: string, productId?: string | null): Promise;
+ /** 列出命中;brandId 空 = 全部(含主題模式) */
+ listPosts(brandId?: string | null): Promise;
+ /** @deprecated 用 runScanFromBrief */
+ runScan(
+ brandId: string,
+ productId?: string | null,
+ extraTerms?: string[],
+ ): Promise;
+ draftOutreach(postId: string, personaId?: string): Promise;
+ skipOutreach(postId: string): Promise;
+ markPublished(postId: string): Promise;
+ /**
+ * 模擬發送外展回覆:寫入草稿、標記 published、可帶帳號
+ */
+ sendOutreach(opts: {
+ postId: string;
+ text: string;
+ accountId?: string;
+ personaId?: string;
+ }): Promise;
+ /** 刪除單則命中 */
+ removePost(postId: string): Promise;
+ /** 刪除同一主題分組下全部命中 */
+ removeTheme(themeKey: string): Promise;
+ /** 已完成的功課列表(持久化) */
+ listHomework(): Promise;
+ /** 存一輪功課(同 theme_key 覆蓋) */
+ saveHomework(record: ScoutHomeworkRecord): Promise;
+ getHomework(themeKey: string): Promise;
+ removeHomework(themeKey: string): Promise;
+};
+
+export type UsageRepo = {
+ /** 目前登入者(或指定 uid)本月摘要 + 明細 */
+ getSummary(monthKey?: string, uid?: string): Promise;
+ listEvents(limit?: number, uid?: string): Promise;
+ getPlanId(): Promise;
+ setPlanId(id: PlanId): Promise;
+ getMemberPrefs(uid?: string): Promise;
+ /** 管理員:設某人方案/無限 */
+ setMemberPrefs(uid: string, patch: Partial): Promise;
+ /** 管理員:全體本月用量 */
+ getTenantSummary(monthKey?: string): Promise;
+ /** 管理員:全體區間分析(日/月/年 + 購買 vs 消耗) */
+ getTenantAnalytics(query?: TenantAnalyticsQuery): Promise;
+ /**
+ * 會員自己購買方案(mock:假付款成功後才改 plan)。
+ * 管理員直接 setMemberPrefs 不算購買。
+ */
+ purchasePlan(plan_id: PlanId, opts?: { mock_ref?: string }): Promise;
+ /** 自己的購買紀錄 */
+ listMyPurchases(limit?: number): Promise;
+};
+
+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;
+};
diff --git a/apps/web/src/domain/speakers.ts b/apps/web/src/domain/speakers.ts
new file mode 100644
index 0000000..6a0a946
--- /dev/null
+++ b/apps/web/src/domain/speakers.ts
@@ -0,0 +1,66 @@
+import type { ThreadPlay } from "./types";
+
+export function speakersOf(play: Pick): string[] {
+ const set = new Set([play.lead_account_id, ...play.cast_account_ids]);
+ return [...set].filter(Boolean);
+}
+
+export function isUnderPostScheme(
+ play: Pick,
+): 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;
+}
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
new file mode 100644
index 0000000..76727ed
--- /dev/null
+++ b/apps/web/src/domain/types.ts
@@ -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>;
+ 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[];
+};
diff --git a/apps/web/src/i18n/I18nContext.tsx b/apps/web/src/i18n/I18nContext.tsx
new file mode 100644
index 0000000..24e51e0
--- /dev/null
+++ b/apps/web/src/i18n/I18nContext.tsx
@@ -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;
+ formatMoney: (amountTwd: number) => string;
+ formatPlanPrice: (amountTwd: number) => string;
+};
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [locale, setLocaleState] = useState(() => loadUiPrefs().locale);
+ const [currency, setCurrencyState] = useState(
+ () => 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) =>
+ translate(locale, key, params),
+ [locale],
+ );
+
+ const value = useMemo(
+ () => ({
+ 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 {children};
+}
+
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) throw new Error("useI18n must be used within I18nProvider");
+ return ctx;
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
deleted file mode 100644
index 819ad3c..0000000
--- a/apps/web/src/index.css
+++ /dev/null
@@ -1,16 +0,0 @@
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html,
-body,
-#root {
- margin: 0;
- min-height: 100%;
-}
-
-body {
- -webkit-font-smoothing: antialiased;
-}
diff --git a/apps/web/src/lib/accountInsights.ts b/apps/web/src/lib/accountInsights.ts
new file mode 100644
index 0000000..c999599
--- /dev/null
+++ b/apps/web/src/lib/accountInsights.ts
@@ -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();
+
+ 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}%`;
+}
diff --git a/apps/web/src/lib/attachImage.ts b/apps/web/src/lib/attachImage.ts
new file mode 100644
index 0000000..f615e5d
--- /dev/null
+++ b/apps/web/src/lib/attachImage.ts
@@ -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 {
+ 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 {
+ 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;
+}
diff --git a/apps/web/src/lib/i18n/format.ts b/apps/web/src/lib/i18n/format.ts
new file mode 100644
index 0000000..ae6fe95
--- /dev/null
+++ b/apps/web/src/lib/i18n/format.ts
@@ -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);
+}
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
new file mode 100644
index 0000000..ec2ae98
--- /dev/null
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -0,0 +1,2464 @@
+import type { AppLocale } from "./types";
+
+/** 扁平 key → 字串;{name} 可插值 */
+export type MessageDict = Record;
+
+export const zhTW: MessageDict = {
+ "app.name": "巡樓",
+ "app.nameEn": "Lapras",
+ "app.nameZh": "巡樓",
+ "app.tagline": "Threads 海巡好幫手",
+ "app.taglineEn": "Patrol Threads with ease",
+ "nav.today": "今日",
+ "nav.crew": "帳號",
+ "nav.studio": "創作",
+ "nav.scout": "海巡",
+ "nav.outbox": "發送",
+ "nav.jobs": "任務",
+ "nav.brands": "品牌",
+ "nav.more": "更多",
+ "nav.moreTitle": "更多功能",
+ "nav.users": "島民管理",
+ "nav.usage": "用量與方案",
+ "nav.profile": "會員資料",
+ "nav.settings": "系統設定",
+ "nav.logout": "登出",
+ "nav.navigate": "導覽",
+
+ "common.save": "儲存",
+ "common.cancel": "取消",
+ "common.loading": "載入中…",
+ "common.back": "返回",
+ "common.delete": "刪除",
+ "common.edit": "編輯",
+ "common.search": "搜尋",
+ "common.confirm": "確定",
+ "common.optional": "選填",
+ "common.success": "已儲存",
+ "common.error": "發生錯誤",
+ "common.yes": "是",
+ "common.no": "否",
+
+ "topbar.notifications": "通知",
+ "topbar.unread": "{n} 未讀",
+ "topbar.markAllRead": "全已讀",
+ "topbar.noNotifications": "目前沒有通知",
+ "topbar.jobsCenter": "任務中心",
+ "topbar.moreOlder": "還有 {n} 則較舊",
+ "topbar.account": "帳號選單",
+
+ "role.admin": "管理員",
+ "role.member": "一般會員",
+ "role.verified": "已驗證",
+ "role.unverified": "未驗證",
+
+ "login.title": "登入巡樓",
+ "login.email": "Email",
+ "login.password": "密碼",
+ "login.submit": "登入",
+ "login.submitting": "登入中…",
+ "login.forgot": "忘記密碼?",
+ "login.mockHint": "管理員 demo@harbor.local / demo · 一般會員 alice@harbor.local / alice",
+
+ "forgot.title": "忘記密碼",
+ "forgot.submit": "寄送重設連結",
+ "forgot.submitting": "送出中…",
+ "forgot.back": "返回登入",
+ "forgot.hint": "輸入註冊用 Email,我們會寄送重設連結。",
+ "forgot.mockMail": "重設信",
+ "forgot.openReset": "開啟重設密碼頁",
+ "forgot.retry": "再試一次",
+
+ "reset.title": "設定新密碼",
+ "reset.newPassword": "新密碼",
+ "reset.confirm": "確認新密碼",
+ "reset.submit": "更新密碼",
+ "reset.submitting": "更新中…",
+ "reset.missingToken": "連結缺少 token。請從「忘記密碼」重新申請。",
+
+ "verify.title": "驗證信箱",
+ "verify.pending": "尚未驗證",
+ "verify.body": "帳號已開通登入,但信箱未驗證前無法使用功能。請輸入寄到你信箱的 6 位驗證碼。",
+ "verify.code": "驗證碼",
+ "verify.submit": "完成驗證",
+ "verify.submitting": "驗證中…",
+ "verify.resend": "重新寄送驗證碼",
+ "verify.sending": "寄送中…",
+ "verify.logout": "登出",
+ "verify.mockMail": "驗證信",
+ "verify.mockHint": "請輸入驗證碼:",
+ "verify.after": "驗證完成後即可使用今日、創作、海巡等功能。",
+
+ "settings.title": "設定",
+ "settings.localeCurrency": "語言與幣別",
+ "settings.locale": "介面語言",
+ "settings.currency": "顯示幣別",
+ "settings.currencyHint": "",
+ "settings.localeSaved": "語言與幣別已更新",
+ "settings.appearance": "外觀",
+ "settings.theme": "主題",
+ "settings.themeLight": "淺色",
+ "settings.themeDark": "深色",
+ "settings.themeSystem": "跟隨系統",
+ "settings.themeHint": "",
+ "settings.themeSaved": "主題已更新",
+ "settings.themeToLight": "切換成淺色",
+ "settings.themeToDark": "切換成深色",
+ "settings.dataSource": "資料來源",
+ "settings.ai": "AI",
+ "settings.search": "搜尋",
+ "settings.member": "會員與登入",
+ "settings.usageCard": "AI/搜尋額度",
+ "settings.usageCardHint": "",
+ "settings.viewUsage": "查看用量",
+ "settings.editProfile": "編輯會員資料",
+ "settings.mockLogin": "demo@harbor.local / demo",
+ "settings.memberHint": "",
+
+ "usage.title": "用量與方案",
+ "usage.desc": "",
+ "usage.creditsUsed": "本月已用點數",
+ "usage.remaining": "剩餘 {n} 點",
+ "usage.percentUsed": "{n}% 已使用",
+ "usage.breakdown": "分項用量",
+ "usage.plans": "方案",
+ "usage.current": "目前",
+ "usage.inUse": "使用中",
+ "usage.switchMock": "切換方案",
+ "usage.perMonth": "點/月",
+ "usage.ledger": "最近使用紀錄",
+ "usage.emptyTitle": "本月還沒有扣點",
+ "usage.emptyDesc": "",
+ "usage.planNote": "",
+ "usage.switched": "已切換為 {name}",
+ "usage.meter.times": "{count} 次 · {credits} 點",
+ "usage.meter.cap": "單項參考上限 {count}/{cap} 次",
+ "usage.plan.free.blurb": "試用與個人輕量經營",
+ "usage.plan.starter.blurb": "小團隊日常發文與海巡",
+ "usage.plan.pro.blurb": "多帳、重度 AI 與研究",
+
+ "profile.title": "會員資料",
+ "profile.desc": "",
+ "profile.accountStatus": "帳號開通狀態",
+ "profile.basic": "基本資料",
+ "profile.avatar": "頭像",
+ "profile.avatarUpload": "上傳頭像",
+ "profile.avatarRemove": "移除頭像",
+ "profile.avatarHint": "JPG/PNG/WebP,5MB 內。選圖後僅預覽,按「儲存基本資料」才會套用。",
+ "profile.avatarSaved": "頭像已更新",
+ "profile.avatarCleared": "已移除頭像",
+ "profile.avatarFail": "無法讀取圖片",
+ "profile.displayName": "顯示名稱",
+ "profile.bio": "簡介(選填)",
+ "profile.timezone": "時區",
+ "profile.notifyEmail": "Email 通知",
+ "profile.password": "變更密碼(選填)",
+ "profile.currentPassword": "目前密碼",
+ "profile.newPassword": "新密碼",
+ "profile.confirmPassword": "確認新密碼",
+ "profile.emailVerified": "信箱已驗證",
+ "profile.emailUnverified": "信箱未驗證",
+ "profile.roleAdmin": "管理員",
+ "profile.roleMember": "一般會員",
+ "profile.loginEmail": "登入信箱:{email}",
+ "profile.verifiedAt": " · 驗證於 {time}",
+ "profile.goVerify": "去驗證信箱",
+ "profile.roleTags": "權限標籤:{labels}",
+ "profile.roleNote": "角色由系統指派;改信箱後需重新驗證。",
+ "profile.saveBasic": "儲存基本資料",
+ "profile.updatePassword": "更新密碼",
+ "profile.saved": "會員資料已儲存",
+ "profile.savedUnverified": "資料已儲存。信箱尚未驗證或已變更,請完成驗證後才能繼續使用功能。",
+ "profile.saveFail": "儲存失敗",
+ "profile.needNewPassword": "請輸入新密碼",
+ "profile.passwordMismatch": "兩次新密碼不一致",
+ "profile.needCurrentPassword": "請輸入目前密碼",
+ "profile.passwordUpdated": "密碼已更新",
+ "profile.passwordFail": "變更密碼失敗",
+ "profile.listJoin": "、",
+ "profile.tenantUid": " · tenant {tenant} · uid {uid}",
+
+ "admin.users.title": "島民管理",
+ "admin.users.desc": "",
+ "admin.users.needAdmin": "需要管理員權限",
+ "admin.users.list": "島民列表 · {n}",
+ "admin.users.detail": "島民詳情",
+ "admin.users.pick": "選擇島民",
+ "admin.users.search": "搜尋名稱 / uid",
+ "admin.users.searchPh": "島民名稱、Email 或 uid",
+ "admin.users.create": "新增島民",
+ "admin.users.suspend": "停權",
+ "admin.users.unsuspend": "復權",
+ "admin.users.suspended": "已停權",
+ "admin.users.active": "正常",
+
+ "crew.title": "帳號",
+ "crew.tab.accounts": "帳號",
+ "crew.tab.personas": "人設",
+ "crew.connect": "連接帳號",
+ "crew.connecting": "新增中…",
+ "crew.refreshAll": "全部刷新 session",
+ "crew.refreshing": "刷新中…",
+ "crew.empty": "尚無帳號",
+ "crew.unusable": "不可用",
+ "crew.expires": "過期 {time}",
+ "crew.lastRefresh": "上次更新 {time}",
+ "crew.refreshSession": "刷新 session",
+ "crew.session.ok": "session 有效",
+ "crew.session.soon": "即將到期",
+ "crew.session.expired": "session 過期",
+ "crew.session.unknown": "session 未知",
+ "crew.msg.refreshed": "@{user} session 已刷新(延長約 30 天)",
+ "crew.msg.refreshedAll": "已刷新 {n} 個帳號 session",
+ "crew.msg.refreshFail": "刷新失敗",
+ "crew.confirmDelete": "確定刪除帳號 @{user}?\n刪除後無法再選為 lead / cast。",
+
+ "today.findTopic": "找話題",
+ "today.pendingReplies": "待回覆",
+ "today.pendingRepliesN": "待回覆 · {n}",
+ "today.newThread": "開新串",
+ "today.metricsAria": "今日數值",
+ "today.metric.pending": "待回",
+ "today.metric.pendingHint": "海巡佇列",
+ "today.metric.doneGoal": "已回/目標",
+ "today.metric.sentToday": "今日已發",
+ "today.metric.running": "進行中 {n}",
+ "today.metric.sentDone": "完成的發送",
+ "today.metric.failed": "發送異常",
+ "today.metric.needAction": "需處理",
+ "today.metric.ok": "正常",
+ "today.pending.title": "待回覆 · {n}",
+ "today.pending.empty": "目前沒有待回",
+ "today.goScout": "去海巡",
+ "today.pending.more": "還有 {n} 則 →",
+ "today.pending.handle": "海巡處理",
+ "today.pending.start": "開始處理",
+ "today.topics.title": "找話題",
+ "today.topics.empty": "還沒有話題",
+ "today.goStudio": "去創作",
+ "today.heat": "熱度 {n}",
+ "today.topicAngle": "可當開場角度",
+ "today.moreInspire": "更多靈感",
+ "today.useTopic": "用話題開串",
+ "today.outbox.title": "今日發送",
+ "today.outbox.empty": "還沒有今日發送。可先",
+ "today.outbox.emptyMid": ",完成後會出現在",
+ "today.outbox.emptyEnd": "。",
+ "today.outbox.summary": "已完成 {sent} · 進行中 {running} · 異常 {failed}",
+ "today.badge.failed": "失敗",
+ "today.badge.scheduling": "排程",
+ "today.badge.sending": "發送中",
+ "today.openOutbox": "開啟發送",
+ "today.accounts.title": "帳號成效",
+ "today.accounts.empty": "尚無成效",
+ "today.postsCount": "{n} 則貼文",
+ "today.views": "瀏覽",
+ "today.likes": "讚",
+ "today.repliesShort": "回",
+ "today.fullInsights": "完整成效 · 月比圖",
+ "today.viewPosts": "看已發文",
+ "today.manageAccounts": "管理帳號",
+
+ "outbox.title": "發送",
+ "outbox.tabsAria": "發送分頁",
+ "outbox.tab.active": "進行中",
+ "outbox.tab.history": "歷史",
+ "outbox.empty": "尚無發送",
+ "outbox.activeEmpty": "進行中是空的",
+ "outbox.historyEmpty": "尚無歷史",
+ "outbox.historyN": "歷史({n})",
+ "outbox.backActive": "回進行中({n})",
+ "outbox.progress": "進度 {progress}",
+ "outbox.detail": "詳情",
+ "outbox.deleting": "刪除中…",
+ "outbox.confirmDelete": "刪除發送項目「{title}」?\n無法復原。",
+ "outbox.deleted": "已刪除「{title}」",
+ "outbox.deleteFail": "刪除失敗",
+ "outbox.status.scheduling": "排程中",
+ "outbox.status.active": "發送中",
+ "outbox.status.completed": "已完成",
+ "outbox.status.partial_failed": "部分失敗",
+ "outbox.status.cancelled": "已取消",
+ "outbox.detail.missingId": "缺少 id",
+ "outbox.detail.notFound": "找不到發送",
+ "outbox.detail.markAllOk": "標記全部成功",
+ "outbox.detail.markRootFail": "標記主貼失敗",
+ "outbox.detail.processing": "處理中…",
+ "outbox.detail.delete": "刪除這筆",
+ "outbox.detail.back": "返回列表",
+ "outbox.detail.root": "主貼",
+ "outbox.detail.replyN": "回覆 {n}",
+ "outbox.detail.retry": "重試",
+ "outbox.detail.opFail": "操作失敗",
+
+ "studio.title": "創作",
+ "studio.account": "帳號",
+ "studio.persona": "人設",
+ "studio.personaReady": "人設 ready",
+ "studio.personaNotReady": "人設未就緒",
+ "studio.tab.posts": "我的貼文",
+ "studio.tab.mentions": "提及 @",
+ "studio.tab.compose": "寫一則",
+ "studio.tab.plays": "互回方案",
+ "studio.tab.inspire": "靈感",
+ "studio.tab.insights": "成效",
+
+ "mentions.hint": "誰 @ 你。待回 {n} 則。每則可改帳號/人設(預設用頂部)。",
+ "mentions.scoutLink": "海巡外展",
+ "mentions.empty": "尚無提及",
+ "mentions.status.pending": "待回",
+ "mentions.status.replied": "已回",
+ "mentions.status.skipped": "略過",
+ "mentions.reply": "回覆",
+ "mentions.skip": "略過",
+ "mentions.draftLabel": "回覆草稿",
+ "mentions.repliedPrefix": "已回:{text}",
+ "mentions.needPersona": "請選 ready 人設再 AI 產文",
+ "mentions.fail": "失敗",
+ "mentions.marked": "已標記回覆(帳號 @{user})",
+ "mentions.withImages": " · 附圖 {n}",
+
+ "compose.hint": "單篇發文(非串場)。互回請用",
+ "compose.hintEnd": "。",
+ "compose.playsLink": "串場",
+ "compose.personaOff": "人設未 ready:仿寫/分析等 AI 工具停用。",
+ "compose.title": "標題(選填)",
+ "compose.titlePh": "方便在 Outbox 辨識",
+ "compose.body": "正文",
+ "compose.bodyPh": "寫下這則貼文…",
+ "compose.tool.mimic": "仿寫",
+ "compose.tool.viral": "爆紅分析",
+ "compose.tool.research": "上網補資料",
+ "compose.tool.image": "產圖",
+ "compose.mimic.title": "仿寫別人貼文",
+ "compose.mimic.source": "參考全文",
+ "compose.mimic.sourcePh": "貼上想仿寫的貼文…",
+ "compose.mimic.running": "仿寫中…",
+ "compose.mimic.run": "依人設仿寫到正文",
+ "compose.mimic.done": "仿寫完成,可再改",
+ "compose.viral.title": "爆紅分析",
+ "compose.viral.hint": "分析參考文或目前正文的鉤子/結構/可複製點。",
+ "compose.viral.source": "分析對象(可空=用正文)",
+ "compose.viral.running": "分析中…",
+ "compose.viral.run": "開始分析",
+ "compose.viral.result": "分析結果",
+ "compose.viral.done": "爆紅分析完成",
+ "compose.viral.needText": "請貼參考文或先寫正文",
+ "compose.research.title": "上網補專業資料",
+ "compose.research.q": "關鍵字",
+ "compose.research.qPh": "例如:無香洗劑 敏感肌",
+ "compose.research.running": "搜尋中…",
+ "compose.research.insert": "插入勾選內容到正文",
+ "compose.research.inserted": "已插入 {n} 條補充",
+ "compose.image.title": "產圖",
+ "compose.image.prompt": "畫面描述",
+ "compose.image.promptPh": "可空=從正文摘要",
+ "compose.image.running": "產圖中…",
+ "compose.image.run": "產生圖片",
+ "compose.image.done": "已產圖",
+ "compose.publish": "送出到 Outbox",
+ "compose.publishing": "送出中…",
+ "compose.publishFail": "送出失敗",
+ "compose.fail": "失敗",
+ "compose.attachN": "附圖 {n}",
+ "compose.personaStatus": "人設:{status}",
+ "compose.ready": "ready",
+ "compose.notReady": "未就緒",
+
+ "posts.sync": "重新同步 Threads",
+ "posts.syncing": "同步中…",
+ "posts.syncedAt": "同步 {time}",
+ "posts.notSynced": "未同步",
+ "posts.syncDone": "已同步 Threads 成效(讚/回/轉發/引用/瀏覽/分享)",
+ "posts.empty": "尚無貼文",
+ "posts.openThreads": "開 Threads",
+ "posts.insight": "分析洞察:{text}",
+ "posts.review": "覆盤:{text}",
+ "posts.formulaResult": "結構分析結果",
+ "posts.collapseReplies": "收合留言",
+ "posts.repliesBtn": "留言({total})· 未回 {pending}",
+ "posts.replyRoot": "回主貼",
+ "posts.analyzing": "分析中…",
+ "posts.reanalyze": "重新分析結構",
+ "posts.analyze": "分析結構",
+ "posts.mimicThis": "仿寫這則",
+ "posts.rootDraft": "回主貼草稿",
+ "posts.filter.pending": "未回覆({n})",
+ "posts.filter.replied": "已回覆({n})",
+ "posts.filter.all": "全部({n})",
+ "posts.noPending": "沒有未回覆留言",
+ "posts.noReplied": "還沒有已回覆留言",
+ "posts.noReplies": "尚無留言",
+ "posts.status.pending": "未回覆",
+ "posts.status.replied": "已回覆",
+ "posts.likesN": "讚 {n}",
+ "posts.childCount": "{n} 則子留言",
+ "posts.mine": "我方",
+ "posts.replyThis": "回這則",
+ "posts.replyAgain": "再回一則",
+ "posts.replyTo": "回 @{user}",
+ "posts.replyAgainTo": "再回 @{user}",
+ "posts.needPersona": "請先選 ready 人設再 AI 產文",
+ "posts.genFail": "生成失敗",
+ "posts.needText": "請先產生或輸入回覆",
+ "posts.sent": "已用 @{user} 發送",
+ "posts.sentImages": "(附圖 {n})",
+ "posts.accountFallback": "帳號",
+ "posts.sendFail": "發送失敗",
+ "posts.analyzeDone": "結構分析完成(手動觸發)",
+ "posts.analyzeFail": "分析失敗",
+
+ "wizard.newTitle": "編互回劇本",
+ "wizard.editTitle": "編輯互回劇本",
+ "wizard.prev": "上一步",
+ "wizard.next": "下一步",
+ "wizard.err.topic": "請填主題",
+ "wizard.err.lead": "請選擇主帳號",
+ "wizard.err.leadUnusable": "主帳號不可用",
+ "wizard.submitFail": "提交失敗",
+ "wizard.unnamedPlay": "未命名串場",
+ "wizard.step.topic": "聊什麼",
+ "wizard.step.crew": "誰出場",
+ "wizard.step.script": "誰說什麼",
+ "wizard.step.preview": "預覽",
+ "wizard.step.schedule": "何時發",
+ "wizard.step.submit": "送出",
+ "wizard.stepperAria": "wizard 步驟",
+ "wizard.topic.title": "1. 這串要聊什麼",
+ "wizard.topic.name": "標題(可選)",
+ "wizard.topic.namePh": "例如:週末咖啡",
+ "wizard.topic.topic": "主題一句話",
+ "wizard.topic.topicPh": "這串文想聊什麼?",
+ "wizard.topic.aiView": "AI 視角",
+ "wizard.topic.personaNotReady": "人設未就緒",
+ "wizard.topic.generating": "生成中…",
+ "wizard.topic.aiSuggest": "AI 幫我想",
+ "wizard.topic.quickFill": "快速填入",
+ "wizard.topic.sampleTitle": "週末咖啡話題",
+ "wizard.topic.sampleTopic": "週末想找間不踩雷的咖啡店,插座要多、能坐久。",
+ "wizard.topic.aiTitle": "AI 主題",
+ "wizard.topic.personaOpen": "{name}開場",
+ "wizard.crew.title": "2. 出場",
+ "wizard.crew.lead": "主帳",
+ "wizard.crew.noUsable": "尚無可用帳號",
+ "wizard.crew.unusable": "不可用",
+ "wizard.crew.cast": "配角",
+ "wizard.script.title": "3. 台詞",
+ "wizard.script.persona": "人設",
+ "wizard.script.personaNotReady": "人設未就緒",
+ "wizard.script.root": "主貼",
+ "wizard.script.replyN": "回覆 {n}",
+ "wizard.script.generating": "生成中…",
+ "wizard.script.ai": "AI 產文",
+ "wizard.script.account": "帳號:{name}",
+ "wizard.script.noLead": "(未選 lead)",
+ "wizard.script.speaker": "發言帳號",
+ "wizard.script.leadTag": "(lead)",
+ "wizard.script.text": "文案",
+ "wizard.script.rootPh": "主貼內容…",
+ "wizard.script.replyPh": "回覆內容…",
+ "wizard.script.addReply": "新增回覆步驟",
+ "wizard.preview.title": "4. 預覽這段對話",
+ "wizard.preview.unknown": "未知",
+ "wizard.preview.unknownAccount": "未知帳號",
+ "wizard.preview.root": "主貼",
+ "wizard.preview.leadTalk": "lead 接話",
+ "wizard.preview.empty": "(空白)",
+ "wizard.schedule.title": "5. 何時發出去",
+ "wizard.schedule.start": "第一則(主貼)時間",
+ "wizard.schedule.interval": "回覆間隔(分鐘)",
+ "wizard.schedule.intervalHint": "相對上一步",
+ "wizard.submit.title": "6. 送出排程",
+ "wizard.submit.body": "確認後會把「{title}」這串互回(共 {n} 步)送進 Outbox,依序用各帳號發出。",
+ "wizard.submit.unnamed": "未命名",
+ "wizard.submit.root": "主貼",
+ "wizard.submit.replyN": "回覆 {n}",
+ "wizard.submit.submitting": "提交中…",
+ "wizard.submit.run": "提交到 Outbox",
+
+ "reply.account": "用哪個帳號回",
+ "reply.persona": "用人設",
+ "reply.notReady": "此人設未 ready,無法 AI 產文(仍可手打後發送)。",
+ "reply.draft": "回覆草稿",
+ "reply.attach": "附圖",
+ "reply.generating": "生成中…",
+ "reply.ai": "AI 產文",
+ "reply.sending": "發送中…",
+ "reply.send": "發送",
+
+ "image.attach": "附圖",
+ "image.attachFail": "附圖失敗",
+ "image.attachedAria": "已附圖片",
+ "image.alt": "附圖",
+ "image.named": "附圖 {n}",
+ "image.remove": "移除圖片",
+ "image.full": "已滿 {max} 張",
+ "image.more": "再附圖({n}/{max})",
+
+ "metrics.aria": "貼文成效",
+ "metrics.like": "讚",
+ "metrics.reply": "回覆",
+ "metrics.repost": "轉發",
+ "metrics.quote": "引用",
+ "metrics.view": "瀏覽",
+ "metrics.share": "分享",
+ "metrics.type.quote": "引用貼",
+ "metrics.type.reply": "回覆貼",
+ "metrics.type.image": "圖片",
+ "metrics.type.video": "影片",
+ "metrics.type.carousel": "輪播",
+ "metrics.type.repost": "轉發",
+ "metrics.type.text": "文字",
+ "metrics.type.post": "貼文",
+
+ "jobs.title": "任務",
+ "jobs.startDemo": "啟動任務",
+ "jobs.empty": "尚無任務",
+ "jobs.total": "共 {n} 筆",
+ "jobs.showing": " · 顯示前 {n}",
+ "jobs.detail": "詳情",
+ "jobs.loadMore": "載入更多(還有 {n})",
+ "jobs.notFound": "找不到任務",
+ "jobs.progress": "進度 {n}%",
+ "jobs.updated": "更新 {time}",
+ "jobs.backList": "返回列表",
+
+ "plans.title": "變更方案",
+ "plans.current": "目前方案",
+ "plans.perMonth": "/月",
+ "plans.monthlyCredits": "每月 {n} 點",
+ "plans.usageLink": "用量",
+ "plans.inUse": "使用中",
+ "plans.recommended": "推薦",
+ "plans.creditsPerMonth": "每月 {n} 點",
+
+ "plan.cta.current": "目前方案",
+ "plan.cta.upgrade": "升級",
+ "plan.cta.downgrade": "降級",
+ "plan.cta.switch": "切換",
+
+ "plan.free.headline": "試用與個人輕量經營",
+ "plan.free.bullet1": "每月 {n} 點 AI/搜尋",
+ "plan.free.bullet2": "完整功能:創作、海巡、發送",
+ "plan.free.bullet3": "適合先熟悉流程",
+ "plan.free.bullet4": "可隨時升級",
+ "plan.free.right1": "可使用完整功能:帳號、創作、海巡、發送、任務與靈感。",
+ "plan.free.right2": "每月固定點數,用在文案、研究、搜尋與生圖。",
+ "plan.free.right3": "達上限後需等下月或升級(除非管理員開不擋額度)。",
+ "plan.free.quota1": "每月配給 {n} 點。",
+ "plan.free.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
+ "plan.free.note1": "無需付款,確認即切換。",
+ "plan.free.note2": "正式金流後的發票規則另訂。",
+
+ "plan.starter.headline": "小團隊日常發文與海巡",
+ "plan.starter.bullet1": "每月 {n} 點(約 6× Free)",
+ "plan.starter.bullet2": "穩定發文、回覆、海巡",
+ "plan.starter.bullet3": "適合 1~3 人節奏",
+ "plan.starter.bullet4": "付款成功立即生效",
+ "plan.starter.right1": "付款成功後本帳改為 Starter,當月依新額度計算。",
+ "plan.starter.right2": "點數支撐固定發文、回覆草稿與定期海巡。",
+ "plan.starter.right3": "功能與 Free 相同,差在能用多久。",
+ "plan.starter.quota1": "每月 {n} 點 · {price}。",
+ "plan.starter.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
+ "plan.starter.note1": "需付款成功才變更方案。",
+ "plan.starter.note2": "自然月重置,未用完點數不累積至下月。",
+
+ "plan.pro.headline": "多帳、重度 AI 與研究",
+ "plan.pro.bullet1": "每月 {n} 點(約 4× Starter)",
+ "plan.pro.bullet2": "高頻文案/研究/生圖",
+ "plan.pro.bullet3": "適合代理與多品牌",
+ "plan.pro.bullet4": "付款成功立即生效",
+ "plan.pro.right1": "付款成功後本帳改為 Pro,當月依 Pro 額度計算。",
+ "plan.pro.right2": "適合多帳、大量回覆與深研究,減少中途額度見底。",
+ "plan.pro.right3": "功能相同;買的是容量與節奏。",
+ "plan.pro.quota1": "每月 {n} 點 · {price}。",
+ "plan.pro.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
+ "plan.pro.note1": "付款失敗不會改方案。",
+ "plan.pro.note2": "付款完成後可於帳務紀錄查詢收據。",
+
+ "checkout.title": "確認方案",
+ "checkout.pickFirst": "請先選擇方案。",
+ "checkout.viewPlans": "看方案",
+ "checkout.cardLast4Required": "請填卡號末四碼",
+ "checkout.cardNameRequired": "請填持卡人",
+ "checkout.fail": "無法完成",
+ "checkout.confirmFree": "確認切換至 Free",
+ "checkout.payAndAction": "{action}並付款 {price}",
+ "checkout.subscribe": "訂閱",
+ "checkout.perMonth": "/月",
+ "checkout.monthlyCredits": "每月 {n} 點",
+ "checkout.youGet": "你會得到",
+ "checkout.quota": "額度",
+ "checkout.notes": "注意",
+ "checkout.cardholder": "持卡人",
+ "checkout.cardLast4": "卡號末四碼",
+ "checkout.amountDue": "應付金額",
+ "checkout.billedMonthly": "{name} · 按月計費",
+ "checkout.already": "已是此方案",
+ "checkout.processing": "處理中…",
+ "checkout.currentPlan": "目前方案",
+ "checkout.pickOther": "改選其他方案",
+ "checkout.cancel": "取消",
+
+ "usage.widget.titleUsed": "{name} · 已用 {used}/{cap} 點",
+ "usage.widget.titleUnlimited": "{name} · 不擋額度",
+ "usage.widget.ariaUsed": "已用 {used} 點,共 {cap} 點",
+ "usage.widget.dialog": "方案與用量",
+ "usage.widget.currentPlan": "目前方案",
+ "usage.widget.unlimited": "不擋額度",
+ "usage.widget.perMonth": "/月",
+ "usage.widget.monthUsage": "本月用量",
+ "usage.widget.remaining": "還剩 {n} 點",
+ "usage.widget.leftShort": "還剩 {n}",
+ "usage.widget.overShort": "已超額",
+ "usage.widget.upgradeShort": "升級",
+ "usage.widget.upgrade": "升級方案",
+ "usage.widget.includes": "這個方案包含",
+ "usage.widget.nudge": "本月額度快用完了,升級可立刻加大點數。",
+ "usage.widget.changePlan": "變更方案",
+ "usage.widget.usageDetail": "用量明細",
+
+ "usage.meter.ai_copy": "AI 文案",
+ "usage.meter.ai_research": "AI 研究",
+ "usage.meter.web_search": "搜尋",
+ "usage.meter.ai_image": "AI 生圖",
+ "usage.meter.barAria": "{label} {count} 次 {credits} 點,上限 {cap}",
+ "usage.ledger.costAria": "消耗 {n} 點",
+
+ "usage.chart.period": "區間",
+ "usage.chart.allocated": "配給",
+ "usage.chart.consumed": "消耗",
+ "usage.chart.pctTitle": "消耗佔配給比例",
+ "usage.chart.aria": "配給與消耗",
+ "usage.chart.colAria": "{label} 配給 {purchased} 消耗 {consumed}",
+
+ "settings.copyProvider": "文案 provider",
+ "settings.copyModel": "文案 model",
+ "settings.researchProvider": "研究 provider",
+ "settings.researchModel": "研究 model",
+ "settings.fetchModels": "取得模型",
+ "settings.fetchingModels": "讀取中…",
+ "settings.copyApiKey": "文案 API Key",
+ "settings.researchApiKey": "研究 API Key",
+ "settings.configured": "已設定",
+ "settings.notConfigured": "未設定",
+ "settings.modelsLoaded": "已取得 {provider} 模型清單",
+ "settings.aiSaved": "AI 設定已儲存",
+ "settings.searchSaved": "搜尋設定已儲存",
+ "settings.searchProvider": "Provider",
+ "settings.expand": "延伸",
+ "settings.braveKey": "Brave Key",
+ "settings.exaKey": "Exa Key",
+ "settings.devMode": "開發模式",
+
+ "forgot.fail": "送出失敗",
+ "forgot.mockHint": "正式環境會寄到信箱;此處直接給連結:",
+ "forgot.checkInbox": "請檢查信箱(含垃圾郵件)。若未註冊則不會寄出。",
+
+ "reset.mismatch": "兩次密碼不一致",
+ "reset.fail": "重設失敗",
+ "reset.cardTitle": "重設密碼",
+ "reset.redirecting": "即將前往登入頁…",
+ "reset.loginNow": "立即登入",
+ "reset.passwordPh": "至少 4 碼",
+ "reset.forgotLink": "忘記密碼",
+
+ "verify.sendFail": "寄送失敗",
+ "verify.fail": "驗證失敗",
+ "verify.success": "信箱已驗證,可以使用巡樓了。",
+ "verify.codePh": "6 位數字",
+ "verify.currentAccount": "目前帳號:{email}",
+
+ "login.brandTitle": "巡樓 · Lapras",
+
+
+ "common.listSep": "、",
+ "common.dash": "—",
+
+ "scout.title": "海巡",
+ "scout.today": "今日",
+ "scout.purposeValue": "痛點回覆",
+ "scout.purposeActivity": "活躍短回",
+ "scout.goal": "今日目標(則)",
+ "scout.progress": "進度 {done}/{goal}",
+ "scout.intent": "我想找/回應",
+ "scout.keyword": "關鍵字",
+ "scout.intentPh": "例:換季頭皮刺癢、真的無香、週末有插座",
+ "scout.keywordPh": "例:週末 咖啡 遠端",
+ "scout.productOptional": "產品(選填)",
+ "scout.noProduct": "不帶產品",
+ "scout.brandFallback": "品牌",
+ "scout.placement": "置入:{label}",
+ "scout.painPart": " · 痛點「{pain}」",
+ "scout.noProductsBefore": "尚無產品,可到",
+ "scout.noProductsAfter": "新增。",
+ "scout.start": "開始",
+ "scout.startMore": "再撈一批",
+ "scout.fetching": "撈取中…",
+ "scout.runs": "海巡批次",
+ "scout.runCount": "批次({n})",
+ "scout.runSelectAria": "切換海巡批次",
+ "scout.runPending": "待回 {n} · ",
+ "scout.runDone": "已清完 · ",
+ "scout.runTotal": "({n} 則)",
+ "scout.deleteRun": "刪除這一批",
+ "scout.deleting": "刪除中…",
+ "scout.now": "現在這一則",
+ "scout.emptyBatch": "這一批沒有待回",
+ "scout.draft": "回覆草稿",
+ "scout.draftPhActivity": "短回…",
+ "scout.draftPhValue": "共感 → 建議…",
+ "scout.sendAccount": "用哪個帳號送",
+ "scout.noAccount": "無可用帳號",
+ "scout.personaForRegen": "人設(再產用)",
+ "scout.notReady": "(未就緒)",
+ "scout.skip": "略過",
+ "scout.regen": "再產",
+ "scout.send": "發送",
+ "scout.sending": "發送中…",
+ "scout.needAccountBefore": "請先到",
+ "scout.needAccountAfter": "連線可用帳號。",
+ "scout.knowledge": "周邊知識",
+ "scout.knowledgeWithLabel": "周邊知識 · {label}",
+ "scout.knowledgeLearn": "周邊知識 · 可學",
+ "scout.collapseKnowledge": "收合知識",
+ "scout.expandLearn": "展開學習 · {n} 則",
+ "scout.expandKnowledge": "展開知識",
+ "scout.deleteKnowledgeRun": "刪除此批知識與命中",
+ "scout.loadingKnowledge": "正在整理周邊知識…",
+ "scout.notesCount": "{n} 則",
+ "scout.noKnowledge": "尚無周邊知識",
+ "scout.product": "產品",
+ "scout.painsSolved": "能解的痛",
+ "scout.focus": "焦點",
+ "scout.all": "全部",
+ "scout.noWebSummary": "尚無網頁摘要",
+ "scout.queue": "佇列 · {n}",
+ "scout.collapseQueue": "收合佇列",
+ "scout.expandQueue": "展開佇列",
+ "scout.noOtherPending": "沒有其他待回",
+ "scout.learnPoints": "學習重點",
+ "scout.replyHooks": "回帖可借",
+ "scout.badgeCore": "最貼主題",
+ "scout.unnamedRun": "未命名批次",
+ "scout.thisRun": "此批次",
+ "scout.confirmDeleteRun": "刪除海巡批次「{label}」?\\n會一併刪除這批命中與周邊知識,無法復原。",
+ "scout.deletedRun": "已刪除批次「{label}」",
+ "scout.deleteRunFail": "刪除批次失敗",
+ "scout.needKeyword": "先填關鍵字",
+ "scout.needIntent": "先寫這次要找什麼",
+ "scout.productMissing": "所選產品不在列表中,請重新選擇",
+ "scout.defaultLabel": "海巡",
+ "scout.newRunActivity": "新批次「{label}」· {n} 則待回",
+ "scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」",
+ "scout.knowledgeReady": "「{label}」周邊知識已備好 · {n} 則可學",
+ "scout.patrolFail": "這輪海巡失敗",
+ "scout.draftFail": "產草稿失敗",
+ "scout.skipped": "已略過",
+ "scout.noDraft": "沒有可發送的草稿",
+ "scout.accountFallback": "帳號",
+ "scout.sent": "已發送(@{who})· 今日 {done}/{goal}",
+ "scout.sendFail": "發送失敗",
+ "scout.confirmDeletePost": "刪除這則命中?",
+ "scout.stanceActivity": "短回 · 養活躍",
+ "scout.stanceProduct": "共感 · 可輕帶產品",
+ "scout.stanceRelation": "接話 · 建關係",
+ "scout.tier.core": "最貼主題",
+ "scout.tier.coreHint": "直接對準痛點/關鍵語,優先讀",
+ "scout.tier.adjacent": "相關周邊",
+ "scout.tier.adjacentHint": "鄰近情境,擴搜尋面",
+ "scout.tier.broad": "最廣泛",
+ "scout.tier.broadHint": "背景與對照,選讀即可",
+ "scout.relation.solves_pain": "對準痛點",
+ "scout.relation.nearby_scene": "鄰近場景",
+ "scout.relation.myth": "迷思澄清",
+ "scout.relation.contrast": "對照選購",
+ "scout.relation.background": "背景脈絡",
+
+ "brands.title": "品牌",
+ "brands.railAria": "品牌列表",
+ "brands.railLabel": "你的牌子",
+ "brands.add": "新增",
+ "brands.brandName": "品牌名稱",
+ "brands.brandNamePh": "例如:自家品牌",
+ "brands.creating": "建立中…",
+ "brands.createBrand": "建立品牌",
+ "brands.searchAria": "搜尋品牌",
+ "brands.searchPh": "搜尋品牌…",
+ "brands.empty": "尚無品牌",
+ "brands.noMatch": "無符合",
+ "brands.selectAria": "選擇品牌",
+ "brands.pickOne": "選一個品牌",
+ "brands.inUseHint": "使用中 · 海巡與創作會套用此牌",
+ "brands.inUse": "使用中",
+ "brands.tabInfo": "牌子資料",
+ "brands.tabProducts": "產品",
+ "brands.tabProductsN": "產品({n})",
+ "brands.displayName": "名稱",
+ "brands.brief": "摘要",
+ "brands.briefPh": "一句話說明這個牌子",
+ "brands.audience": "受眾",
+ "brands.audiencePh": "誰會在意、為什麼",
+ "brands.goals": "目標",
+ "brands.goalsPh": "想在 Threads 達成什麼",
+ "brands.saving": "儲存中…",
+ "brands.deleteBrand": "刪除品牌",
+ "brands.searchProductAria": "搜尋產品",
+ "brands.searchProductPh": "搜尋產品…",
+ "brands.addProduct": "新增產品",
+ "brands.noProducts": "尚無產品",
+ "brands.hasLink": "有連結",
+ "brands.painLabel": "痛點 ",
+ "brands.editProduct": "編輯產品",
+ "brands.newProduct": "新增產品",
+ "brands.importFromUrl": "從商品連結帶入",
+ "brands.fetching": "抓取中…",
+ "brands.fetch": "抓取",
+ "brands.pains": "痛點",
+ "brands.painsPh": "一列一個",
+ "brands.tags": "標籤",
+ "brands.tagsPh": "逗號分隔",
+ "brands.intro": "介紹",
+ "brands.link": "連結",
+ "brands.update": "更新",
+ "brands.createItem": "新增",
+ "brands.needName": "請輸入名稱",
+ "brands.created": "已建立「{name}」",
+ "brands.createFail": "建立失敗",
+ "brands.saved": "已儲存",
+ "brands.saveFail": "儲存失敗",
+ "brands.confirmDelete": "確定刪除「{name}」?",
+ "brands.deleted": "已刪除",
+ "brands.deleteFail": "刪除失敗",
+ "brands.fetchFail": "抓取失敗",
+ "brands.needLabelContext": "名稱與介紹為必填",
+ "brands.productUpdated": "已更新",
+ "brands.productAdded": "已新增",
+ "brands.confirmDeleteProduct": "刪除此產品?",
+
+ "insights.title": "帳號成效",
+ "insights.account": "帳號",
+ "insights.noAccount": "尚無帳號",
+ "insights.syncing": "同步中…",
+ "insights.syncPosts": "同步貼文",
+ "insights.myPosts": "我的貼文",
+ "insights.pickAccount": "選擇帳號",
+ "insights.goAccounts": "帳號",
+ "insights.kpiMonth": "本月指標",
+ "insights.monthViews": "本月瀏覽",
+ "insights.monthLikes": "本月讚",
+ "insights.monthReplies": "本月回覆",
+ "insights.engRate": "互動率",
+ "insights.vsPrev": "vs 上月",
+ "insights.avgNear": "近帖均 {rate}",
+ "insights.trendTitle": "趨勢與分析 · @{user}",
+ "insights.metricViews": "瀏覽",
+ "insights.metricLikes": "讚",
+ "insights.metricReplies": "回覆",
+ "insights.metricPosts": "貼文",
+ "insights.metricPostsFull": "貼文數",
+ "insights.chartMetrics": "圖表指標",
+ "insights.barsAria": "近月{metric},點柱查看該月分析",
+ "insights.barsLabel": "{metric} · 近 {n} 個月",
+ "insights.clickBar": " · 點柱看分析",
+ "insights.pickMonthAria": "選擇月份",
+ "insights.barTitle": "{label}:{value}{est} · 點看分析",
+ "insights.est": "(估)",
+ "insights.monthSuffix": "{m}月",
+ "insights.sparkAria": "趨勢折線,點節點可選月",
+ "insights.analysisOf": "{label} 分析",
+ "insights.producedAt": "產出於 {time}",
+ "insights.hasEstimate": " · 含估測數據",
+ "insights.viewsVsPrev": " · 瀏覽 vs 前月 {delta}",
+ "insights.statPosts": "貼文",
+ "insights.statViews": "瀏覽",
+ "insights.statLikes": "讚",
+ "insights.statReplies": "回",
+ "insights.conclusions": "結論",
+ "insights.recommendations": "建議",
+ "insights.highlights": "當月亮點",
+ "insights.findTopics": "找話題",
+ "insights.goScout": "去探查",
+ "insights.selectMonth": "選擇月份",
+ "insights.topPosts": "表現較佳貼文",
+ "insights.noPosts": "尚無貼文",
+ "insights.postStats": "瀏覽 {views} · 讚 {likes} · 回 {replies}",
+ "insights.openThreads": "開啟 Threads",
+ "insights.zeroPct": "0%",
+
+ "plays.tabOwn": "我的貼文",
+ "plays.tabLink": "Threads 連結",
+ "plays.noPosts": "尚無貼文",
+ "plays.targetPost": "目標貼文",
+ "plays.likesSuffix": " (讚{n})",
+ "plays.linkCard": "貼 Threads 連結",
+ "plays.postLink": "貼文連結",
+ "plays.resolving": "解析中…",
+ "plays.resolve": "解析連結",
+ "plays.resolveHint": "解析後可排自家帳號在該則下面回覆。",
+ "plays.targetOwn": "目標貼文(自己的)",
+ "plays.openThreads": "開 Threads",
+ "plays.external": "外站貼",
+ "plays.addScheme": "新增方案",
+ "plays.schemeCount": "此目標目前 {n} 個方案",
+ "plays.noSchemes": "尚無方案",
+ "plays.replyCount": "{n} 則留言",
+ "plays.editTitle": "編輯:{title}",
+ "plays.schemeName": "方案名稱",
+ "plays.schemeNamePh": "例如:方案 A · 溫和接話",
+ "plays.speakersOwn": "可出場帳號(貼主帳固定可回)",
+ "plays.speakers": "可出場帳號",
+ "plays.postOwner": "(貼文主帳)",
+ "plays.noAccounts": "沒有可用帳號,請先到設定連線 Threads。",
+ "plays.interval": "間隔(分)",
+ "plays.applyInterval": "套用間隔",
+ "plays.aiEmpty": "空白則 AI",
+ "plays.aiBusy": "產文中…",
+ "plays.replies": "留言({n})",
+ "plays.stepN": "第 {n} 則",
+ "plays.who": "誰留",
+ "plays.personaOpt": "人設(選填)",
+ "plays.brandOpt": "品牌(選填)",
+ "plays.reply": "留言",
+ "plays.attach": "附圖",
+ "plays.addOne": "加一則",
+ "plays.saving": "儲存中…",
+ "plays.saveScheme": "儲存方案",
+ "plays.submitting": "送出中…",
+ "plays.submitOutbox": "送出到 Outbox",
+ "plays.closeEdit": "關閉編輯",
+ "plays.noTarget": "還沒有目標貼文",
+ "plays.resolved": "已解析連結",
+ "plays.resolveFail": "解析失敗",
+ "plays.filled": "已產 {n} 則",
+ "plays.needTarget": "請先選定目標貼文",
+ "plays.saved": "方案已儲存",
+ "plays.saveFail": "儲存失敗",
+ "plays.submitted": "已送進 Outbox",
+ "plays.submitFail": "送出失敗",
+ "plays.confirmDelete": "刪除此方案?",
+ "plays.accountFallback": "帳號",
+
+ "inspire.loading": "載入中…",
+ "inspire.trendsAria": "最近 Threads 夯什麼",
+ "inspire.trendsLabel": "最近 Threads 夯",
+ "inspire.refresh": "刷新",
+ "inspire.clearChat": "清空對話",
+ "inspire.pinAsElement": "套用為元素",
+ "inspire.you": "你",
+ "inspire.ai": "AI",
+ "inspire.system": "系統",
+ "inspire.useDraft": "用這則寫",
+ "inspire.openPlay": "開串場",
+ "inspire.generating": "產文中…",
+ "inspire.thinking": "思考中…",
+ "inspire.pinnedAria": "本輪已套用",
+ "inspire.pinned": "已套用",
+ "inspire.pickRight": "右側點選元素",
+ "inspire.unpinTitle": "點擊取消套用",
+ "inspire.inputAria": "跟 AI 說",
+ "inspire.inputPh": "想寫什麼?或改短一點、更口語…",
+ "inspire.send": "送出",
+ "inspire.generate": "產文",
+ "inspire.library": "元素庫",
+ "inspire.addNew": "+ 新增",
+ "inspire.kind": "類型",
+ "inspire.kindRole": "角色指令",
+ "inspire.kindSnippet": "片段",
+ "inspire.kindTrendNote": "熱點備註",
+ "inspire.kindBrand": "品牌",
+ "inspire.kindTrend": "熱點",
+ "inspire.name": "名稱",
+ "inspire.namePh": "例如:專業 Threads 寫手",
+ "inspire.body": "內容(會進 prompt)",
+ "inspire.bodyPh": "你是一位…",
+ "inspire.saveElement": "存進元素庫",
+ "inspire.citeBrand": "引用品牌",
+ "inspire.applied": "已套用",
+ "inspire.clickApply": "點擊套用",
+ "inspire.noBrands": "尚無品牌",
+ "inspire.appliedToggle": "已套用 · 再點取消",
+ "inspire.deleteAria": "刪除",
+ "inspire.needInput": "先輸入一句,或直接產文",
+ "inspire.fail": "失敗",
+ "inspire.wantWrite": "想寫關於 {label}:{summary}",
+ "inspire.trendBody": "主題:{label}。{summary}",
+ "inspire.pinnedTrend": "已套用熱點 {label}",
+ "inspire.needTitleBody": "請填名稱與內容",
+ "inspire.added": "已加入元素庫",
+ "inspire.addFail": "新增失敗",
+ "inspire.confirmRemove": "從元素庫移除此項?",
+ "inspire.confirmClear": "清空對話?(元素庫保留)",
+ "inspire.genMessage": "請依已套用元素寫一則 Threads",
+
+ "persona.add": "新增人設",
+ "persona.empty": "尚無人設",
+ "persona.emptyDesc": "新增後做分析即可用於產文。",
+ "persona.statusReady": "ready",
+ "persona.statusAnalyzing": "分析中",
+ "persona.statusPending": "待分析",
+ "persona.default": "預設",
+ "persona.backList": "← 人設列表",
+ "persona.tabOverview": "概要",
+ "persona.tabAnalyze": "分析",
+ "persona.tabFingerprint": "指紋",
+ "persona.tabPreview": "試產",
+ "persona.name": "名稱",
+ "persona.brief": "定位 brief",
+ "persona.briefPh": "是誰、對誰說、核心訊息…",
+ "persona.avoid": "護欄 · 禁止詞(逗號分隔)",
+ "persona.guardChars": "{n} 字",
+ "persona.banAi": " · 禁 AI 腔",
+ "persona.notReadySuffix": " · 未就緒",
+ "persona.setDefault": "設為預設",
+ "persona.modeAccount": "公開帳號",
+ "persona.modeText": "貼文字",
+ "persona.username": "Threads username",
+ "persona.fromBound": "從已綁帳號帶入",
+ "persona.select": "選擇…",
+ "persona.crawlAnalyze": "爬公開貼文並分析",
+ "persona.crawlBusy": "爬取/分析中…",
+ "persona.refText": "參考文字(--- 分隔多篇)",
+ "persona.refTextPh": "第一段…\n\n---\n\n第二段…",
+ "persona.sourceLabel": "來源說明(選填)",
+ "persona.sourcePh": "自己的舊文",
+ "persona.analyzeText": "從文字分析",
+ "persona.analyzeBusy": "分析中…",
+ "persona.sampleMeta": "樣本 {n}",
+ "persona.sourceManual": "貼文",
+ "persona.analyzeHint": "完成分析後會顯示 8D 摘要。",
+ "persona.fingerprintHint": "產文主體。可改口頭禪、節奏、禁忌;儲存後 Studio/回覆會吃這份。",
+ "persona.fingerprint": "語言指紋",
+ "persona.fingerprintPh": "分析後自動填入…",
+ "persona.saveFingerprint": "儲存指紋",
+ "persona.tryGen": "試產主貼 + 回覆",
+ "persona.notReadyMsg": "人設未就緒",
+ "persona.rootPost": "主貼",
+ "persona.reply": "回覆",
+ "persona.hidePrompt": "隱藏 prompt block",
+ "persona.showPrompt": "顯示注入的 prompt",
+ "persona.promptBlock": "prompt block(post)",
+ "persona.pickOne": "選一個人設",
+ "persona.pickDesc": "或按新增開始分析。",
+ "persona.created": "已建立,請到「分析」完成帳號爬取或貼文字",
+ "persona.saved": "已儲存",
+ "persona.textDone": "文字分析完成 · {n} 段 → ready",
+ "persona.analyzeFail": "分析失敗",
+ "persona.reading": "正在讀取公開貼文…",
+ "persona.accountDone": "@{user} · {n} 則 → ready",
+ "persona.setDefaultMsg": "「{name}」已設為預設",
+ "persona.confirmDelete": "確定刪除人設「{name}」?",
+ "persona.deleted": "人設已刪除",
+ "persona.needReady": "請先完成分析(ready)",
+ "persona.dim.d1Tone": "D1 語氣人格",
+ "persona.dim.d2Structure": "D2 結構模板",
+ "persona.dim.d3Interaction": "D3 互動方式",
+ "persona.dim.d4Topics": "D4 主題分布",
+ "persona.dim.d5Rhythm": "D5 發文節奏",
+ "persona.dim.d6Visual": "D6 視覺語法",
+ "persona.dim.d7Conversion": "D7 轉換方式",
+ "persona.dim.d8Risk": "D8 風險紅線",
+
+ "admin.users.loadFail": "載入失敗",
+ "admin.users.created": "已新增島民「{name}」· 請複製下方密碼",
+ "admin.users.createFail": "新增失敗",
+ "admin.users.unlimitedOn": "「{name}」已設不擋額度(用量仍計算)",
+ "admin.users.unlimitedOff": "「{name}」已改回依方案擋額度",
+ "admin.users.updateFail": "更新失敗",
+ "admin.users.planSet": "「{name}」方案 → {plan}",
+ "admin.users.confirmSuspend": "確定停權「{name}」?\\n停權後無法登入。",
+ "admin.users.confirmUnsuspend": "確定復權「{name}」?\\n復權後可重新登入。",
+ "admin.users.didSuspend": "已停權「{name}」",
+ "admin.users.didUnsuspend": "已復權「{name}」",
+ "admin.users.suspendFail": "停權失敗",
+ "admin.users.unsuspendFail": "復權失敗",
+ "admin.users.markedVerified": "已將 {name} 標為信箱已驗證",
+ "admin.users.markedUnverified": "已將 {name} 標為未驗證",
+ "admin.users.rolesUpdated": "已更新 {name} 的權限:{roles}",
+ "admin.users.rolesFail": "權限更新失敗",
+ "admin.users.confirmReset": "確定幫「{name}」重設密碼?\\n臨時密碼會固定顯示直到你按關閉(可重整)。",
+ "admin.users.resetDone": "已重設 {name} 的密碼(下方可持續顯示,請複製後再關閉)",
+ "admin.users.resetFail": "重設失敗",
+ "admin.users.copied": "已複製到剪貼簿",
+ "admin.users.copyFail": "複製失敗,請手動選取密碼",
+ "admin.users.confirmDismissTemp": "關閉後此頁將不再顯示這組臨時密碼(若尚未複製請先複製)。確定關閉?",
+ "admin.users.tempPwNew": "新島民臨時密碼",
+ "admin.users.tempPw": "臨時密碼",
+ "admin.users.tempPwPersist": "(持續顯示 · 可重整)",
+ "admin.users.copyPw": "複製密碼",
+ "admin.users.close": "關閉",
+ "admin.users.createTitle": "新增島民",
+ "admin.users.memberName": "島民名稱",
+ "admin.users.displayNamePh": "顯示名稱",
+ "admin.users.email": "Email",
+ "admin.users.initPassword": "初始密碼(選填)",
+ "admin.users.initPasswordPh": "空白則自動產生臨時密碼",
+ "admin.users.markVerifiedCheck": "信箱標為已驗證(可直接使用)",
+ "admin.users.alsoAdmin": "同時設為管理員",
+ "admin.users.creating": "建立中…",
+ "admin.users.createSubmit": "建立島民",
+ "admin.users.clear": "清除",
+ "admin.users.searchActive": "搜尋「{query}」· 可匹配名稱、Email、uid",
+ "admin.users.noMatch": "無符合",
+ "admin.users.none": "尚無島民",
+ "admin.users.you": "這是你",
+ "admin.users.status": "狀態",
+ "admin.users.role": "角色",
+ "admin.users.emailVerify": "信箱驗證",
+ "admin.users.bio": "簡介",
+ "admin.users.timezone": "時區",
+ "admin.users.notifyEmail": "Email 通知",
+ "admin.users.on": "開",
+ "admin.users.off": "關",
+ "admin.users.createdAt": "建立",
+ "admin.users.updatedAt": "更新",
+ "admin.users.accountStatus": "帳號狀態",
+ "admin.users.updating": "更新中…",
+ "admin.users.usageTitle": "用量與方案",
+ "admin.users.plan": "方案",
+ "admin.users.planOption": "{name}({credits} 點/月)",
+ "admin.users.unlimited": "不擋額度",
+ "admin.users.byPlan": "依方案",
+ "admin.users.setUnlimited": "設為不擋額度",
+ "admin.users.setLimited": "改回擋額度",
+ "admin.users.unlimitedHint": "不擋額度:達方案上限仍可繼續用;AI/Search 次數與點數照樣計算。",
+ "admin.users.loadingUsage": "載入用量設定…",
+ "admin.users.assignRoles": "指派權限",
+ "admin.users.memberBase": "{role}(基底,不可關閉)",
+ "admin.users.adminDesc": "{role} — 可管理島民與系統",
+ "admin.users.saving": "儲存中…",
+ "admin.users.saveRoles": "儲存權限",
+ "admin.users.markUnverifiedBtn": "標為未驗證",
+ "admin.users.markVerifiedBtn": "標為已驗證",
+ "admin.users.resetting": "重設中…",
+ "admin.users.resetTemp": "重設密碼(產生臨時)",
+ "admin.users.customPw": "或指定新密碼(選填)",
+ "admin.users.customPwPh": "至少 4 碼",
+ "admin.users.resetWithCustom": "用指定密碼重設",
+
+ "usage.tabMine": "我的用量",
+ "usage.tabTenant": "全體用量",
+ "usage.currentPlan": "目前方案",
+ "usage.planMeta": "/月 · 每月 {n} 點額度",
+ "usage.changePlan": "變更方案",
+ "usage.usedThisMonth": "本月已用",
+ "usage.remainLabel": "剩餘",
+ "usage.ledgerToggle": "使用紀錄",
+ "usage.collapse": "收合",
+ "usage.eventsCount": "{n} 筆",
+ "usage.granularity": "粒度",
+ "usage.day": "日",
+ "usage.monthUnit": "月",
+ "usage.year": "年",
+ "usage.from": "起",
+ "usage.to": "迄",
+ "usage.callCounts": "呼叫次數",
+ "usage.noMembers": "尚無會員",
+ "usage.planAria": "{name} 方案",
+ "usage.unlimitedTitle": "不擋額度",
+ "usage.setLimited": "改回擋額度",
+ "usage.setUnlimited": "設為不擋額度",
+ "usage.limitShort": "擋",
+ "usage.subscribed": "已訂閱 {name}",
+ "usage.unlimitedSet": "已設不擋額度",
+ "usage.limitedSet": "已改回擋額度",
+ "usage.planUpdated": "已更新方案 {name}",
+ "usage.fail": "失敗",
+
+ "currency.TWD": "新台幣 (TWD)",
+ "currency.USD": "美元 (USD)",
+ "currency.JPY": "日圓 (JPY)",
+ "currency.EUR": "歐元 (EUR)",
+ "currency.HKD": "港幣 (HKD)",
+
+ "locale.zh-TW": "繁體中文",
+ "locale.en": "English",
+
+ "pager.nav": "分頁",
+ "pager.pageSize": "每頁筆數",
+ "pager.perPage": "{n}/頁",
+ "pager.prev": "上一頁",
+ "pager.next": "下一頁",
+
+ "plays.defaultTitle": "新方案",
+ "plays.topicOnPost": "掛在:{snippet}",
+ "plays.topicOnExternal": "掛在:{label} · {snippet}",
+ "plays.externalFallback": "外站貼",
+
+ "persona.newName": "新人設",
+ "persona.previewTopic": "週末想找能坐久的咖啡店",
+ "persona.previewReplySample": "大安那間還行但人很多",
+
+ "inspire.playTitle": "靈感串場",
+ "wizard.topic.fallbackTopic": "生活小題",
+
+ "play.err.needLead": "請選擇主帳號",
+ "play.err.needRoot": "請至少有一則主貼",
+ "play.err.firstMustRoot": "第一則必須是主貼",
+ "play.err.rootMustLead": "主貼必須使用主帳",
+ "play.err.rootEmpty": "主貼文案不可空白",
+ "play.err.replyAccount": "回覆只能用主帳或已選配角",
+ "play.err.replyEmpty": "回覆文案不可空白",
+ "play.err.needTarget": "請選擇自己的貼文,或貼上 Threads 連結",
+ "play.err.needReplies": "請至少排 1 則留言",
+ "play.err.needReplyAccounts": "請至少選一個可回覆帳號",
+ "play.err.stepAccount": "每則留言都要指定可用帳號",
+ "play.err.stepEmpty": "留言內容不可空白",
+ "play.err.notFound": "找不到互回方案",
+
+ "time.justNow": "剛剛",
+ "time.minAgo": "{n} 分前",
+ "time.hourAgo": "{n} 小時前",
+ "time.dayAgo": "{n} 天前",
+ "time.min": "{n} 分鐘",
+ "time.hour": "{n} 小時",
+ "time.day": "{n} 天",
+ "time.expired": "已過期 {span}",
+ "time.remaining": "剩餘 {span}",
+ "time.sessionUnknown": "Session 未記錄",
+ "time.sessionExpired": "Session 已過期 · {absolute}",
+ "time.sessionSoon": "Session 即將到期 · {relative}({absolute})",
+ "time.sessionOk": "Session 有效 · {relative}({absolute})",
+};
+
+export const en: MessageDict = {
+ "app.name": "Lapras",
+ "app.nameEn": "Lapras",
+ "app.nameZh": "巡樓",
+ "app.tagline": "Patrol Threads with ease",
+ "app.taglineEn": "Patrol Threads with ease",
+ "nav.today": "Today",
+ "nav.crew": "Accounts",
+ "nav.studio": "Studio",
+ "nav.scout": "Patrol",
+ "nav.outbox": "Outbox",
+ "nav.jobs": "Jobs",
+ "nav.brands": "Brands",
+ "nav.more": "More",
+ "nav.moreTitle": "More",
+ "nav.users": "Islanders",
+ "nav.usage": "Usage & plans",
+ "nav.profile": "Profile",
+ "nav.settings": "Settings",
+ "nav.logout": "Log out",
+ "nav.navigate": "Navigate",
+
+ "common.save": "Save",
+ "common.cancel": "Cancel",
+ "common.loading": "Loading…",
+ "common.back": "Back",
+ "common.delete": "Delete",
+ "common.edit": "Edit",
+ "common.search": "Search",
+ "common.confirm": "Confirm",
+ "common.optional": "Optional",
+ "common.success": "Saved",
+ "common.error": "Something went wrong",
+ "common.yes": "Yes",
+ "common.no": "No",
+
+ "topbar.notifications": "Notifications",
+ "topbar.unread": "{n} unread",
+ "topbar.markAllRead": "Mark all read",
+ "topbar.noNotifications": "No notifications",
+ "topbar.jobsCenter": "Job center",
+ "topbar.moreOlder": "{n} older",
+ "topbar.account": "Account menu",
+
+ "role.admin": "Admin",
+ "role.member": "Member",
+ "role.verified": "Verified",
+ "role.unverified": "Unverified",
+
+ "login.title": "Sign in to Lapras",
+ "login.email": "Email",
+ "login.password": "Password",
+ "login.submit": "Sign in",
+ "login.submitting": "Signing in…",
+ "login.forgot": "Forgot password?",
+ "login.mockHint": "Admin demo@harbor.local / demo · member alice@harbor.local / alice",
+
+ "forgot.title": "Forgot password",
+ "forgot.submit": "Send reset link",
+ "forgot.submitting": "Sending…",
+ "forgot.back": "Back to sign in",
+ "forgot.hint": "Enter your registered email. We'll send a reset link.",
+ "forgot.mockMail": "Reset email",
+ "forgot.openReset": "Open reset page",
+ "forgot.retry": "Try again",
+
+ "reset.title": "Set new password",
+ "reset.newPassword": "New password",
+ "reset.confirm": "Confirm password",
+ "reset.submit": "Update password",
+ "reset.submitting": "Updating…",
+ "reset.missingToken": "Missing token. Please request a new reset link.",
+
+ "verify.title": "Verify email",
+ "verify.pending": "Not verified",
+ "verify.body": "You're signed in, but features stay locked until you verify your email with the 6-digit code.",
+ "verify.code": "Verification code",
+ "verify.submit": "Verify",
+ "verify.submitting": "Verifying…",
+ "verify.resend": "Resend code",
+ "verify.sending": "Sending…",
+ "verify.logout": "Log out",
+ "verify.mockMail": "Verification email",
+ "verify.mockHint": "Enter the code:",
+ "verify.after": "After verification you can use Today, Studio, Patrol, and more.",
+
+ "settings.title": "Settings",
+ "settings.localeCurrency": "Language & currency",
+ "settings.locale": "Interface language",
+ "settings.currency": "Display currency",
+ "settings.currencyHint": "",
+ "settings.localeSaved": "Language and currency updated",
+ "settings.appearance": "Appearance",
+ "settings.theme": "Theme",
+ "settings.themeLight": "Light",
+ "settings.themeDark": "Dark",
+ "settings.themeSystem": "System",
+ "settings.themeHint": "",
+ "settings.themeSaved": "Theme updated",
+ "settings.themeToLight": "Switch to light",
+ "settings.themeToDark": "Switch to dark",
+ "settings.dataSource": "Data source",
+ "settings.ai": "AI",
+ "settings.search": "Search",
+ "settings.member": "Account & sign-in",
+ "settings.usageCard": "AI / search quota",
+ "settings.usageCardHint": "",
+ "settings.viewUsage": "View usage",
+ "settings.editProfile": "Edit profile",
+ "settings.mockLogin": "demo@harbor.local / demo",
+ "settings.memberHint": "",
+
+ "usage.title": "Usage & plans",
+ "usage.desc": "",
+ "usage.creditsUsed": "Credits used this month",
+ "usage.remaining": "{n} left",
+ "usage.percentUsed": "{n}% used",
+ "usage.breakdown": "Breakdown",
+ "usage.plans": "Plans",
+ "usage.current": "Current",
+ "usage.inUse": "Active",
+ "usage.switchMock": "Switch plan",
+ "usage.perMonth": "credits / mo",
+ "usage.ledger": "Recent usage",
+ "usage.emptyTitle": "No usage this month",
+ "usage.emptyDesc": "",
+ "usage.planNote": "",
+ "usage.switched": "Switched to {name}",
+ "usage.meter.times": "{count} runs · {credits} credits",
+ "usage.meter.cap": "Soft cap {count}/{cap} runs",
+ "usage.plan.free.blurb": "Trial and light personal use",
+ "usage.plan.starter.blurb": "Small teams posting and patrolling daily",
+ "usage.plan.pro.blurb": "Multi-account, heavy AI and research",
+
+ "profile.title": "Profile",
+ "profile.desc": "",
+ "profile.accountStatus": "Account status",
+ "profile.basic": "Basics",
+ "profile.avatar": "Avatar",
+ "profile.avatarUpload": "Upload avatar",
+ "profile.avatarRemove": "Remove avatar",
+ "profile.avatarHint": "JPG / PNG / WebP, up to 5MB. Preview only until you save profile.",
+ "profile.avatarSaved": "Avatar updated",
+ "profile.avatarCleared": "Avatar removed",
+ "profile.avatarFail": "Could not read image",
+ "profile.displayName": "Display name",
+ "profile.bio": "Bio (optional)",
+ "profile.timezone": "Timezone",
+ "profile.notifyEmail": "Email notifications",
+ "profile.password": "Change password (optional)",
+ "profile.currentPassword": "Current password",
+ "profile.newPassword": "New password",
+ "profile.confirmPassword": "Confirm new password",
+ "profile.emailVerified": "Email verified",
+ "profile.emailUnverified": "Email not verified",
+ "profile.roleAdmin": "Admin",
+ "profile.roleMember": "Member",
+ "profile.loginEmail": "Sign-in email: {email}",
+ "profile.verifiedAt": " · verified {time}",
+ "profile.goVerify": "Verify email",
+ "profile.roleTags": "Role tags: {labels}",
+ "profile.roleNote": "Roles are assigned by the system; re-verify after changing email.",
+ "profile.saveBasic": "Save profile",
+ "profile.updatePassword": "Update password",
+ "profile.saved": "Profile saved",
+ "profile.savedUnverified": "Saved. Verify your email before using features.",
+ "profile.saveFail": "Could not save",
+ "profile.needNewPassword": "Enter a new password",
+ "profile.passwordMismatch": "New passwords do not match",
+ "profile.needCurrentPassword": "Enter your current password",
+ "profile.passwordUpdated": "Password updated",
+ "profile.passwordFail": "Could not change password",
+ "profile.listJoin": ", ",
+ "profile.tenantUid": " · tenant {tenant} · uid {uid}",
+
+ "admin.users.title": "Islanders",
+ "admin.users.desc": "",
+ "admin.users.needAdmin": "Admin access required",
+ "admin.users.list": "Islanders · {n}",
+ "admin.users.detail": "Islander detail",
+ "admin.users.pick": "Select an islander",
+ "admin.users.search": "Search name / uid",
+ "admin.users.searchPh": "Name, email, or uid",
+ "admin.users.create": "Add islander",
+ "admin.users.suspend": "Suspend",
+ "admin.users.unsuspend": "Restore",
+ "admin.users.suspended": "Suspended",
+ "admin.users.active": "Active",
+
+ "crew.title": "Accounts",
+ "crew.tab.accounts": "Accounts",
+ "crew.tab.personas": "Personas",
+ "crew.connect": "Connect account",
+ "crew.connecting": "Adding…",
+ "crew.refreshAll": "Refresh all sessions",
+ "crew.refreshing": "Refreshing…",
+ "crew.empty": "No accounts yet",
+ "crew.unusable": "Unavailable",
+ "crew.expires": "Expires {time}",
+ "crew.lastRefresh": "Last refresh {time}",
+ "crew.refreshSession": "Refresh session",
+ "crew.session.ok": "Session valid",
+ "crew.session.soon": "Expiring soon",
+ "crew.session.expired": "Session expired",
+ "crew.session.unknown": "Session unknown",
+ "crew.msg.refreshed": "@{user} session refreshed (~30 days)",
+ "crew.msg.refreshedAll": "Refreshed {n} account sessions",
+ "crew.msg.refreshFail": "Refresh failed",
+ "crew.confirmDelete": "Delete account @{user}?\nIt can no longer be used as lead / cast.",
+
+ "today.findTopic": "Find topics",
+ "today.pendingReplies": "Pending replies",
+ "today.pendingRepliesN": "Pending · {n}",
+ "today.newThread": "New thread",
+ "today.metricsAria": "Today metrics",
+ "today.metric.pending": "Pending",
+ "today.metric.pendingHint": "Patrol queue",
+ "today.metric.doneGoal": "Done / goal",
+ "today.metric.sentToday": "Sent today",
+ "today.metric.running": "{n} in progress",
+ "today.metric.sentDone": "Completed sends",
+ "today.metric.failed": "Send issues",
+ "today.metric.needAction": "Needs action",
+ "today.metric.ok": "All good",
+ "today.pending.title": "Pending replies · {n}",
+ "today.pending.empty": "Nothing pending",
+ "today.goScout": "Go patrol",
+ "today.pending.more": "{n} more →",
+ "today.pending.handle": "Handle in patrol",
+ "today.pending.start": "Start handling",
+ "today.topics.title": "Find topics",
+ "today.topics.empty": "No topics yet",
+ "today.goStudio": "Go to Studio",
+ "today.heat": "Heat {n}",
+ "today.topicAngle": "Good opening angle",
+ "today.moreInspire": "More inspiration",
+ "today.useTopic": "Start from topic",
+ "today.outbox.title": "Today's outbox",
+ "today.outbox.empty": "No sends today. Try",
+ "today.outbox.emptyMid": ", then check",
+ "today.outbox.emptyEnd": ".",
+ "today.outbox.summary": "Done {sent} · In progress {running} · Issues {failed}",
+ "today.badge.failed": "Failed",
+ "today.badge.scheduling": "Scheduled",
+ "today.badge.sending": "Sending",
+ "today.openOutbox": "Open outbox",
+ "today.accounts.title": "Account performance",
+ "today.accounts.empty": "No stats yet",
+ "today.postsCount": "{n} posts",
+ "today.views": "Views",
+ "today.likes": "Likes",
+ "today.repliesShort": "Replies",
+ "today.fullInsights": "Full insights · MoM charts",
+ "today.viewPosts": "View posts",
+ "today.manageAccounts": "Manage accounts",
+
+ "outbox.title": "Outbox",
+ "outbox.tabsAria": "Outbox tabs",
+ "outbox.tab.active": "Active",
+ "outbox.tab.history": "History",
+ "outbox.empty": "No outbox items",
+ "outbox.activeEmpty": "Nothing in progress",
+ "outbox.historyEmpty": "No history yet",
+ "outbox.historyN": "History ({n})",
+ "outbox.backActive": "Back to active ({n})",
+ "outbox.progress": "Progress {progress}",
+ "outbox.detail": "Details",
+ "outbox.deleting": "Deleting…",
+ "outbox.confirmDelete": "Delete outbox item “{title}”?\nThis cannot be undone.",
+ "outbox.deleted": "Deleted “{title}”",
+ "outbox.deleteFail": "Delete failed",
+ "outbox.status.scheduling": "Scheduling",
+ "outbox.status.active": "Sending",
+ "outbox.status.completed": "Completed",
+ "outbox.status.partial_failed": "Partial failure",
+ "outbox.status.cancelled": "Cancelled",
+ "outbox.detail.missingId": "Missing id",
+ "outbox.detail.notFound": "Outbox item not found",
+ "outbox.detail.markAllOk": "Mark all success",
+ "outbox.detail.markRootFail": "Mark root failed",
+ "outbox.detail.processing": "Working…",
+ "outbox.detail.delete": "Delete this",
+ "outbox.detail.back": "Back to list",
+ "outbox.detail.root": "Root post",
+ "outbox.detail.replyN": "Reply {n}",
+ "outbox.detail.retry": "Retry",
+ "outbox.detail.opFail": "Action failed",
+
+ "studio.title": "Studio",
+ "studio.account": "Account",
+ "studio.persona": "Persona",
+ "studio.personaReady": "Persona ready",
+ "studio.personaNotReady": "Persona not ready",
+ "studio.tab.posts": "My posts",
+ "studio.tab.mentions": "Mentions @",
+ "studio.tab.compose": "Compose",
+ "studio.tab.plays": "Plays",
+ "studio.tab.inspire": "Inspire",
+ "studio.tab.insights": "Insights",
+
+ "mentions.hint": "Who @ you. {n} pending. You can change account/persona per item (defaults from top bar).",
+ "mentions.scoutLink": "Patrol outreach",
+ "mentions.empty": "No mentions",
+ "mentions.status.pending": "Pending",
+ "mentions.status.replied": "Replied",
+ "mentions.status.skipped": "Skipped",
+ "mentions.reply": "Reply",
+ "mentions.skip": "Skip",
+ "mentions.draftLabel": "Reply draft",
+ "mentions.repliedPrefix": "Replied: {text}",
+ "mentions.needPersona": "Pick a ready persona before AI draft",
+ "mentions.fail": "Failed",
+ "mentions.marked": "Marked replied (@{user})",
+ "mentions.withImages": " · {n} images",
+
+ "compose.hint": "Single post (not a play). For multi-account threads use",
+ "compose.hintEnd": ".",
+ "compose.playsLink": "Plays",
+ "compose.personaOff": "Persona not ready: AI tools (mimic / analyze) disabled.",
+ "compose.title": "Title (optional)",
+ "compose.titlePh": "Helps identify in Outbox",
+ "compose.body": "Body",
+ "compose.bodyPh": "Write your post…",
+ "compose.tool.mimic": "Mimic",
+ "compose.tool.viral": "Viral analysis",
+ "compose.tool.research": "Research",
+ "compose.tool.image": "Image",
+ "compose.mimic.title": "Mimic another post",
+ "compose.mimic.source": "Source text",
+ "compose.mimic.sourcePh": "Paste the post to mimic…",
+ "compose.mimic.running": "Mimicking…",
+ "compose.mimic.run": "Mimic into body",
+ "compose.mimic.done": "Mimic done — edit as needed",
+ "compose.viral.title": "Viral analysis",
+ "compose.viral.hint": "Hooks, structure, and copyable patterns from source or body.",
+ "compose.viral.source": "Target (empty = use body)",
+ "compose.viral.running": "Analyzing…",
+ "compose.viral.run": "Analyze",
+ "compose.viral.result": "Analysis",
+ "compose.viral.done": "Viral analysis done",
+ "compose.viral.needText": "Paste a reference or write the body first",
+ "compose.research.title": "Research notes",
+ "compose.research.q": "Keywords",
+ "compose.research.qPh": "e.g. fragrance-free detergent sensitive skin",
+ "compose.research.running": "Searching…",
+ "compose.research.insert": "Insert selected into body",
+ "compose.research.inserted": "Inserted {n} notes",
+ "compose.image.title": "Generate image",
+ "compose.image.prompt": "Scene description",
+ "compose.image.promptPh": "Empty = summarize from body",
+ "compose.image.running": "Generating…",
+ "compose.image.run": "Generate image",
+ "compose.image.done": "Image added",
+ "compose.publish": "Send to Outbox",
+ "compose.publishing": "Sending…",
+ "compose.publishFail": "Send failed",
+ "compose.fail": "Failed",
+ "compose.attachN": "{n} images",
+ "compose.personaStatus": "Persona: {status}",
+ "compose.ready": "ready",
+ "compose.notReady": "not ready",
+
+ "posts.sync": "Resync Threads",
+ "posts.syncing": "Syncing…",
+ "posts.syncedAt": "Synced {time}",
+ "posts.notSynced": "Not synced",
+ "posts.syncDone": "Synced Threads metrics (likes / replies / reposts / quotes / views / shares)",
+ "posts.empty": "No posts yet",
+ "posts.openThreads": "Open Threads",
+ "posts.insight": "Insight: {text}",
+ "posts.review": "Review: {text}",
+ "posts.formulaResult": "Structure analysis",
+ "posts.collapseReplies": "Collapse replies",
+ "posts.repliesBtn": "Replies ({total}) · pending {pending}",
+ "posts.replyRoot": "Reply to post",
+ "posts.analyzing": "Analyzing…",
+ "posts.reanalyze": "Re-analyze structure",
+ "posts.analyze": "Analyze structure",
+ "posts.mimicThis": "Mimic this",
+ "posts.rootDraft": "Root reply draft",
+ "posts.filter.pending": "Pending ({n})",
+ "posts.filter.replied": "Replied ({n})",
+ "posts.filter.all": "All ({n})",
+ "posts.noPending": "No pending replies",
+ "posts.noReplied": "No replied items yet",
+ "posts.noReplies": "No replies yet",
+ "posts.status.pending": "Pending",
+ "posts.status.replied": "Replied",
+ "posts.likesN": "{n} likes",
+ "posts.childCount": "{n} child replies",
+ "posts.mine": "Ours",
+ "posts.replyThis": "Reply",
+ "posts.replyAgain": "Reply again",
+ "posts.replyTo": "Reply to @{user}",
+ "posts.replyAgainTo": "Reply again to @{user}",
+ "posts.needPersona": "Pick a ready persona before AI draft",
+ "posts.genFail": "Generate failed",
+ "posts.needText": "Generate or type a reply first",
+ "posts.sent": "Sent as @{user}",
+ "posts.sentImages": " ({n} images)",
+ "posts.accountFallback": "account",
+ "posts.sendFail": "Send failed",
+ "posts.analyzeDone": "Structure analysis done (manual)",
+ "posts.analyzeFail": "Analyze failed",
+
+ "wizard.newTitle": "New play",
+ "wizard.editTitle": "Edit play",
+ "wizard.prev": "Back",
+ "wizard.next": "Next",
+ "wizard.err.topic": "Enter a topic",
+ "wizard.err.lead": "Select a lead account",
+ "wizard.err.leadUnusable": "Lead account unavailable",
+ "wizard.submitFail": "Submit failed",
+ "wizard.unnamedPlay": "Untitled play",
+ "wizard.step.topic": "Topic",
+ "wizard.step.crew": "Cast",
+ "wizard.step.script": "Script",
+ "wizard.step.preview": "Preview",
+ "wizard.step.schedule": "Schedule",
+ "wizard.step.submit": "Submit",
+ "wizard.stepperAria": "Wizard steps",
+ "wizard.topic.title": "1. What is this thread about?",
+ "wizard.topic.name": "Title (optional)",
+ "wizard.topic.namePh": "e.g. Weekend coffee",
+ "wizard.topic.topic": "One-line topic",
+ "wizard.topic.topicPh": "What should this thread discuss?",
+ "wizard.topic.aiView": "AI persona view",
+ "wizard.topic.personaNotReady": "Persona not ready",
+ "wizard.topic.generating": "Generating…",
+ "wizard.topic.aiSuggest": "AI suggest",
+ "wizard.topic.quickFill": "Quick fill",
+ "wizard.topic.sampleTitle": "Weekend coffee chat",
+ "wizard.topic.sampleTopic": "Looking for a reliable café this weekend — lots of outlets, stay-all-day friendly.",
+ "wizard.topic.aiTitle": "AI topic",
+ "wizard.topic.personaOpen": "{name} opens",
+ "wizard.crew.title": "2. Cast",
+ "wizard.crew.lead": "Lead",
+ "wizard.crew.noUsable": "No usable accounts",
+ "wizard.crew.unusable": "Unavailable",
+ "wizard.crew.cast": "Supporting",
+ "wizard.script.title": "3. Lines",
+ "wizard.script.persona": "Persona",
+ "wizard.script.personaNotReady": "Persona not ready",
+ "wizard.script.root": "Root post",
+ "wizard.script.replyN": "Reply {n}",
+ "wizard.script.generating": "Generating…",
+ "wizard.script.ai": "AI draft",
+ "wizard.script.account": "Account: {name}",
+ "wizard.script.noLead": "(no lead)",
+ "wizard.script.speaker": "Speaker",
+ "wizard.script.leadTag": "(lead)",
+ "wizard.script.text": "Copy",
+ "wizard.script.rootPh": "Root post text…",
+ "wizard.script.replyPh": "Reply text…",
+ "wizard.script.addReply": "Add reply step",
+ "wizard.preview.title": "4. Preview the thread",
+ "wizard.preview.unknown": "Unknown",
+ "wizard.preview.unknownAccount": "Unknown account",
+ "wizard.preview.root": "Root",
+ "wizard.preview.leadTalk": "lead reply",
+ "wizard.preview.empty": "(empty)",
+ "wizard.schedule.title": "5. When to send",
+ "wizard.schedule.start": "First post (root) time",
+ "wizard.schedule.interval": "Reply interval (minutes)",
+ "wizard.schedule.intervalHint": "Relative to previous step",
+ "wizard.submit.title": "6. Submit schedule",
+ "wizard.submit.body": "Confirm to send “{title}” ({n} steps) to Outbox and publish in order.",
+ "wizard.submit.unnamed": "Untitled",
+ "wizard.submit.root": "Root",
+ "wizard.submit.replyN": "Reply {n}",
+ "wizard.submit.submitting": "Submitting…",
+ "wizard.submit.run": "Submit to Outbox",
+
+ "reply.account": "Reply as",
+ "reply.persona": "Persona",
+ "reply.notReady": "This persona is not ready for AI draft (you can still type and send).",
+ "reply.draft": "Reply draft",
+ "reply.attach": "Attach",
+ "reply.generating": "Generating…",
+ "reply.ai": "AI draft",
+ "reply.sending": "Sending…",
+ "reply.send": "Send",
+
+ "image.attach": "Attach",
+ "image.attachFail": "Attach failed",
+ "image.attachedAria": "Attached images",
+ "image.alt": "Attachment",
+ "image.named": "Image {n}",
+ "image.remove": "Remove image",
+ "image.full": "Full ({max})",
+ "image.more": "Add more ({n}/{max})",
+
+ "metrics.aria": "Post metrics",
+ "metrics.like": "Likes",
+ "metrics.reply": "Replies",
+ "metrics.repost": "Reposts",
+ "metrics.quote": "Quotes",
+ "metrics.view": "Views",
+ "metrics.share": "Shares",
+ "metrics.type.quote": "Quote",
+ "metrics.type.reply": "Reply",
+ "metrics.type.image": "Image",
+ "metrics.type.video": "Video",
+ "metrics.type.carousel": "Carousel",
+ "metrics.type.repost": "Repost",
+ "metrics.type.text": "Text",
+ "metrics.type.post": "Post",
+
+ "jobs.title": "Jobs",
+ "jobs.startDemo": "Start demo job",
+ "jobs.empty": "No jobs yet",
+ "jobs.total": "{n} total",
+ "jobs.showing": " · showing first {n}",
+ "jobs.detail": "Details",
+ "jobs.loadMore": "Load more ({n} more)",
+ "jobs.notFound": "Job not found",
+ "jobs.progress": "Progress {n}%",
+ "jobs.updated": "Updated {time}",
+ "jobs.backList": "Back to list",
+
+ "plans.title": "Change plan",
+ "plans.current": "Current plan",
+ "plans.perMonth": "/mo",
+ "plans.monthlyCredits": "{n} credits / month",
+ "plans.usageLink": "Usage",
+ "plans.inUse": "Active",
+ "plans.recommended": "Recommended",
+ "plans.creditsPerMonth": "{n} credits / month",
+
+ "plan.cta.current": "Current plan",
+ "plan.cta.upgrade": "Upgrade",
+ "plan.cta.downgrade": "Downgrade",
+ "plan.cta.switch": "Switch",
+
+ "plan.free.headline": "Trial and light personal use",
+ "plan.free.bullet1": "{n} AI/search credits per month",
+ "plan.free.bullet2": "Full product: Studio, Patrol, Outbox",
+ "plan.free.bullet3": "Good for learning the flow",
+ "plan.free.bullet4": "Upgrade anytime",
+ "plan.free.right1": "Full access to accounts, Studio, Patrol, Outbox, jobs, and inspiration.",
+ "plan.free.right2": "Fixed monthly credits for copy, research, search, and images.",
+ "plan.free.right3": "After the cap, wait for next month or upgrade (unless an admin turns off limits).",
+ "plan.free.quota1": "{n} credits allocated each month.",
+ "plan.free.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
+ "plan.free.note1": "No payment required; confirms immediately.",
+ "plan.free.note2": "Invoice rules will be defined when billing goes live.",
+
+ "plan.starter.headline": "Small teams posting and patrolling daily",
+ "plan.starter.bullet1": "{n} credits / month (about 6× Free)",
+ "plan.starter.bullet2": "Steady posting, replies, and patrol",
+ "plan.starter.bullet3": "Fits a 1–3 person cadence",
+ "plan.starter.bullet4": "Takes effect right after payment",
+ "plan.starter.right1": "After payment this account becomes Starter; the month uses the new quota.",
+ "plan.starter.right2": "Credits support regular posts, reply drafts, and scheduled patrol.",
+ "plan.starter.right3": "Same features as Free; you buy more headroom.",
+ "plan.starter.quota1": "{n} credits / month · {price}.",
+ "plan.starter.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
+ "plan.starter.note1": "Plan changes only after successful payment.",
+ "plan.starter.note2": "Resets on calendar month; unused credits do not roll over.",
+
+ "plan.pro.headline": "Multi-account, heavy AI and research",
+ "plan.pro.bullet1": "{n} credits / month (about 4× Starter)",
+ "plan.pro.bullet2": "High-volume copy, research, and images",
+ "plan.pro.bullet3": "Built for agencies and multi-brand work",
+ "plan.pro.bullet4": "Takes effect right after payment",
+ "plan.pro.right1": "After payment this account becomes Pro; the month uses Pro quota.",
+ "plan.pro.right2": "Fits multi-account replies and deep research with less risk of running out mid-month.",
+ "plan.pro.right3": "Same features; you buy capacity and pace.",
+ "plan.pro.quota1": "{n} credits / month · {price}.",
+ "plan.pro.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
+ "plan.pro.note1": "Failed payment does not change your plan.",
+ "plan.pro.note2": "After payment you can find receipts in billing history.",
+
+ "checkout.title": "Confirm plan",
+ "checkout.pickFirst": "Please choose a plan first.",
+ "checkout.viewPlans": "View plans",
+ "checkout.cardLast4Required": "Enter the last 4 digits of your card",
+ "checkout.cardNameRequired": "Enter the cardholder name",
+ "checkout.fail": "Could not complete",
+ "checkout.confirmFree": "Confirm switch to Free",
+ "checkout.payAndAction": "{action} and pay {price}",
+ "checkout.subscribe": "Subscription",
+ "checkout.perMonth": "/mo",
+ "checkout.monthlyCredits": "{n} credits per month",
+ "checkout.youGet": "What you get",
+ "checkout.quota": "Quota",
+ "checkout.notes": "Notes",
+ "checkout.cardholder": "Cardholder",
+ "checkout.cardLast4": "Last 4 digits",
+ "checkout.amountDue": "Amount due",
+ "checkout.billedMonthly": "{name} · billed monthly",
+ "checkout.already": "Already on this plan",
+ "checkout.processing": "Processing…",
+ "checkout.currentPlan": "Current plan",
+ "checkout.pickOther": "Choose another plan",
+ "checkout.cancel": "Cancel",
+
+ "usage.widget.titleUsed": "{name} · used {used}/{cap} credits",
+ "usage.widget.titleUnlimited": "{name} · unlimited",
+ "usage.widget.ariaUsed": "Used {used} of {cap} credits",
+ "usage.widget.dialog": "Plan and usage",
+ "usage.widget.currentPlan": "Current plan",
+ "usage.widget.unlimited": "Unlimited",
+ "usage.widget.perMonth": "/mo",
+ "usage.widget.monthUsage": "This month",
+ "usage.widget.remaining": "{n} credits left",
+ "usage.widget.leftShort": "{n} left",
+ "usage.widget.overShort": "Over",
+ "usage.widget.upgradeShort": "Upgrade",
+ "usage.widget.upgrade": "Upgrade",
+ "usage.widget.includes": "This plan includes",
+ "usage.widget.nudge": "You're running low this month. Upgrade for more credits right away.",
+ "usage.widget.changePlan": "Change plan",
+ "usage.widget.usageDetail": "Usage details",
+
+ "usage.meter.ai_copy": "AI copy",
+ "usage.meter.ai_research": "AI research",
+ "usage.meter.web_search": "Search",
+ "usage.meter.ai_image": "AI image",
+ "usage.meter.barAria": "{label} {count} runs, {credits} credits, cap {cap}",
+ "usage.ledger.costAria": "Used {n} credits",
+
+ "usage.chart.period": "Period",
+ "usage.chart.allocated": "Allocated",
+ "usage.chart.consumed": "Used",
+ "usage.chart.pctTitle": "Usage as share of allocation",
+ "usage.chart.aria": "Allocation and usage",
+ "usage.chart.colAria": "{label}: allocated {purchased}, used {consumed}",
+
+ "settings.copyProvider": "Copy provider",
+ "settings.copyModel": "Copy model",
+ "settings.researchProvider": "Research provider",
+ "settings.researchModel": "Research model",
+ "settings.fetchModels": "Fetch models",
+ "settings.fetchingModels": "Loading…",
+ "settings.copyApiKey": "Copy API key",
+ "settings.researchApiKey": "Research API key",
+ "settings.configured": "Configured",
+ "settings.notConfigured": "Not set",
+ "settings.modelsLoaded": "Loaded models for {provider}",
+ "settings.aiSaved": "AI settings saved",
+ "settings.searchSaved": "Search settings saved",
+ "settings.searchProvider": "Provider",
+ "settings.expand": "Expand",
+ "settings.braveKey": "Brave key",
+ "settings.exaKey": "Exa key",
+ "settings.devMode": "Developer mode",
+
+ "forgot.fail": "Request failed",
+ "forgot.mockHint": "In production this goes to email; here is a direct link:",
+ "forgot.checkInbox": "Check your inbox (and spam). No email is sent if the address is not registered.",
+
+ "reset.mismatch": "Passwords do not match",
+ "reset.fail": "Reset failed",
+ "reset.cardTitle": "Reset password",
+ "reset.redirecting": "Redirecting to sign in…",
+ "reset.loginNow": "Sign in now",
+ "reset.passwordPh": "At least 4 characters",
+ "reset.forgotLink": "Forgot password",
+
+ "verify.sendFail": "Could not send",
+ "verify.fail": "Verification failed",
+ "verify.success": "Email verified. You can use Lapras now.",
+ "verify.codePh": "6-digit code",
+ "verify.currentAccount": "Account: {email}",
+
+ "login.brandTitle": "巡樓 · Lapras",
+
+
+ "common.listSep": ", ",
+ "common.dash": "—",
+
+ "scout.title": "Patrol",
+ "scout.today": "Today",
+ "scout.purposeValue": "Pain-point replies",
+ "scout.purposeActivity": "Activity short replies",
+ "scout.goal": "Daily goal (posts)",
+ "scout.progress": "Progress {done}/{goal}",
+ "scout.intent": "What to find / reply to",
+ "scout.keyword": "Keywords",
+ "scout.intentPh": "e.g. seasonal scalp itch, truly fragrance-free, outlets on weekends",
+ "scout.keywordPh": "e.g. weekend coffee remote",
+ "scout.productOptional": "Product (optional)",
+ "scout.noProduct": "No product",
+ "scout.brandFallback": "Brand",
+ "scout.placement": "Placement: {label}",
+ "scout.painPart": " · pain “{pain}”",
+ "scout.noProductsBefore": "No products yet. Add some under",
+ "scout.noProductsAfter": ".",
+ "scout.start": "Start",
+ "scout.startMore": "Fetch more",
+ "scout.fetching": "Fetching…",
+ "scout.runs": "Patrol batches",
+ "scout.runCount": "Batch ({n})",
+ "scout.runSelectAria": "Switch patrol batch",
+ "scout.runPending": "{n} pending · ",
+ "scout.runDone": "Cleared · ",
+ "scout.runTotal": " ({n} posts)",
+ "scout.deleteRun": "Delete this batch",
+ "scout.deleting": "Deleting…",
+ "scout.now": "Now this post",
+ "scout.emptyBatch": "Nothing pending in this batch",
+ "scout.draft": "Reply draft",
+ "scout.draftPhActivity": "Short reply…",
+ "scout.draftPhValue": "Empathize → suggest…",
+ "scout.sendAccount": "Send from account",
+ "scout.noAccount": "No usable accounts",
+ "scout.personaForRegen": "Persona (for regen)",
+ "scout.notReady": " (not ready)",
+ "scout.skip": "Skip",
+ "scout.regen": "Regen",
+ "scout.send": "Send",
+ "scout.sending": "Sending…",
+ "scout.needAccountBefore": "Connect a usable account under",
+ "scout.needAccountAfter": "first.",
+ "scout.knowledge": "Related knowledge",
+ "scout.knowledgeWithLabel": "Related · {label}",
+ "scout.knowledgeLearn": "Related · to learn",
+ "scout.collapseKnowledge": "Collapse knowledge",
+ "scout.expandLearn": "Expand · {n} notes",
+ "scout.expandKnowledge": "Expand knowledge",
+ "scout.deleteKnowledgeRun": "Delete this batch's knowledge & hits",
+ "scout.loadingKnowledge": "Preparing related knowledge…",
+ "scout.notesCount": "{n} notes",
+ "scout.noKnowledge": "No related knowledge yet",
+ "scout.product": "Product",
+ "scout.painsSolved": "Pains we solve",
+ "scout.focus": "Focus",
+ "scout.all": "All",
+ "scout.noWebSummary": "No web summaries yet",
+ "scout.queue": "Queue · {n}",
+ "scout.collapseQueue": "Collapse queue",
+ "scout.expandQueue": "Expand queue",
+ "scout.noOtherPending": "No other pending",
+ "scout.learnPoints": "Key takeaways",
+ "scout.replyHooks": "Reply hooks",
+ "scout.badgeCore": "Closest to topic",
+ "scout.unnamedRun": "Unnamed batch",
+ "scout.thisRun": "this batch",
+ "scout.confirmDeleteRun": "Delete patrol batch “{label}”?\\nHits and related knowledge will be removed. This cannot be undone.",
+ "scout.deletedRun": "Deleted batch “{label}”",
+ "scout.deleteRunFail": "Failed to delete batch",
+ "scout.needKeyword": "Enter keywords first",
+ "scout.needIntent": "Write what you're looking for",
+ "scout.productMissing": "Selected product is not in the list. Please reselect.",
+ "scout.defaultLabel": "Patrol",
+ "scout.newRunActivity": "New batch “{label}” · {n} pending",
+ "scout.newRunValue": "New batch “{label}” · {n} posts · handle “Now this post”",
+ "scout.knowledgeReady": "“{label}” knowledge ready · {n} notes",
+ "scout.patrolFail": "This patrol failed",
+ "scout.draftFail": "Draft failed",
+ "scout.skipped": "Skipped",
+ "scout.noDraft": "No draft to send",
+ "scout.accountFallback": "account",
+ "scout.sent": "Sent (@{who}) · today {done}/{goal}",
+ "scout.sendFail": "Send failed",
+ "scout.confirmDeletePost": "Delete this hit?",
+ "scout.stanceActivity": "Short reply · activity",
+ "scout.stanceProduct": "Empathy · soft product",
+ "scout.stanceRelation": "Engage · build rapport",
+ "scout.tier.core": "Closest",
+ "scout.tier.coreHint": "Directly on the pain/keywords — read first",
+ "scout.tier.adjacent": "Adjacent",
+ "scout.tier.adjacentHint": "Nearby context, broaden search",
+ "scout.tier.broad": "Broad",
+ "scout.tier.broadHint": "Background & contrast — optional",
+ "scout.relation.solves_pain": "Hits the pain",
+ "scout.relation.nearby_scene": "Nearby scene",
+ "scout.relation.myth": "Myth-busting",
+ "scout.relation.contrast": "Contrast shopping",
+ "scout.relation.background": "Background",
+
+ "brands.title": "Brands",
+ "brands.railAria": "Brand list",
+ "brands.railLabel": "Your brands",
+ "brands.add": "Add",
+ "brands.brandName": "Brand name",
+ "brands.brandNamePh": "e.g. your brand",
+ "brands.creating": "Creating…",
+ "brands.createBrand": "Create brand",
+ "brands.searchAria": "Search brands",
+ "brands.searchPh": "Search brands…",
+ "brands.empty": "No brands yet",
+ "brands.noMatch": "No matches",
+ "brands.selectAria": "Select brand",
+ "brands.pickOne": "Pick a brand",
+ "brands.inUseHint": "Active · used for patrol and studio",
+ "brands.inUse": "Active",
+ "brands.tabInfo": "Brand info",
+ "brands.tabProducts": "Products",
+ "brands.tabProductsN": "Products ({n})",
+ "brands.displayName": "Name",
+ "brands.brief": "Summary",
+ "brands.briefPh": "One line about this brand",
+ "brands.audience": "Audience",
+ "brands.audiencePh": "Who cares and why",
+ "brands.goals": "Goals",
+ "brands.goalsPh": "What you want on Threads",
+ "brands.saving": "Saving…",
+ "brands.deleteBrand": "Delete brand",
+ "brands.searchProductAria": "Search products",
+ "brands.searchProductPh": "Search products…",
+ "brands.addProduct": "Add product",
+ "brands.noProducts": "No products yet",
+ "brands.hasLink": "Has link",
+ "brands.painLabel": "Pains ",
+ "brands.editProduct": "Edit product",
+ "brands.newProduct": "New product",
+ "brands.importFromUrl": "Import from product URL",
+ "brands.fetching": "Fetching…",
+ "brands.fetch": "Fetch",
+ "brands.pains": "Pain points",
+ "brands.painsPh": "One per line",
+ "brands.tags": "Tags",
+ "brands.tagsPh": "Comma-separated",
+ "brands.intro": "Description",
+ "brands.link": "Link",
+ "brands.update": "Update",
+ "brands.createItem": "Add",
+ "brands.needName": "Enter a name",
+ "brands.created": "Created “{name}”",
+ "brands.createFail": "Create failed",
+ "brands.saved": "Saved",
+ "brands.saveFail": "Save failed",
+ "brands.confirmDelete": "Delete “{name}”?",
+ "brands.deleted": "Deleted",
+ "brands.deleteFail": "Delete failed",
+ "brands.fetchFail": "Fetch failed",
+ "brands.needLabelContext": "Name and description are required",
+ "brands.productUpdated": "Updated",
+ "brands.productAdded": "Added",
+ "brands.confirmDeleteProduct": "Delete this product?",
+
+ "insights.title": "Account performance",
+ "insights.account": "Account",
+ "insights.noAccount": "No accounts",
+ "insights.syncing": "Syncing…",
+ "insights.syncPosts": "Sync posts",
+ "insights.myPosts": "My posts",
+ "insights.pickAccount": "Select an account",
+ "insights.goAccounts": "Accounts",
+ "insights.kpiMonth": "This month",
+ "insights.monthViews": "Views this month",
+ "insights.monthLikes": "Likes this month",
+ "insights.monthReplies": "Replies this month",
+ "insights.engRate": "Engagement",
+ "insights.vsPrev": "vs last month",
+ "insights.avgNear": "Recent avg {rate}",
+ "insights.trendTitle": "Trends & analysis · @{user}",
+ "insights.metricViews": "Views",
+ "insights.metricLikes": "Likes",
+ "insights.metricReplies": "Replies",
+ "insights.metricPosts": "Posts",
+ "insights.metricPostsFull": "Posts",
+ "insights.chartMetrics": "Chart metric",
+ "insights.barsAria": "Recent months {metric}; click a bar for analysis",
+ "insights.barsLabel": "{metric} · last {n} months",
+ "insights.clickBar": " · click bar for analysis",
+ "insights.pickMonthAria": "Select month",
+ "insights.barTitle": "{label}: {value}{est} · click for analysis",
+ "insights.est": " (est.)",
+ "insights.monthSuffix": "{m}",
+ "insights.sparkAria": "Trend line; click a node to select month",
+ "insights.analysisOf": "{label} analysis",
+ "insights.producedAt": "Generated {time}",
+ "insights.hasEstimate": " · includes estimates",
+ "insights.viewsVsPrev": " · views vs prev {delta}",
+ "insights.statPosts": "Posts",
+ "insights.statViews": "Views",
+ "insights.statLikes": "Likes",
+ "insights.statReplies": "Replies",
+ "insights.conclusions": "Takeaways",
+ "insights.recommendations": "Recommendations",
+ "insights.highlights": "Month highlights",
+ "insights.findTopics": "Find topics",
+ "insights.goScout": "Go patrol",
+ "insights.selectMonth": "Select a month",
+ "insights.topPosts": "Top posts",
+ "insights.noPosts": "No posts yet",
+ "insights.postStats": "Views {views} · likes {likes} · replies {replies}",
+ "insights.openThreads": "Open Threads",
+ "insights.zeroPct": "0%",
+
+ "plays.tabOwn": "My posts",
+ "plays.tabLink": "Threads link",
+ "plays.noPosts": "No posts yet",
+ "plays.targetPost": "Target post",
+ "plays.likesSuffix": " ({n} likes)",
+ "plays.linkCard": "Paste Threads link",
+ "plays.postLink": "Post URL",
+ "plays.resolving": "Resolving…",
+ "plays.resolve": "Resolve link",
+ "plays.resolveHint": "After resolve, schedule your accounts to reply under that post.",
+ "plays.targetOwn": "Target post (yours)",
+ "plays.openThreads": "Open Threads",
+ "plays.external": "External post",
+ "plays.addScheme": "New scheme",
+ "plays.schemeCount": "{n} schemes for this target",
+ "plays.noSchemes": "No schemes yet",
+ "plays.replyCount": "{n} replies",
+ "plays.editTitle": "Edit: {title}",
+ "plays.schemeName": "Scheme name",
+ "plays.schemeNamePh": "e.g. Scheme A · soft engage",
+ "plays.speakersOwn": "Accounts (post owner always included)",
+ "plays.speakers": "Accounts",
+ "plays.postOwner": " (post owner)",
+ "plays.noAccounts": "No usable accounts. Connect Threads first.",
+ "plays.interval": "Interval (min)",
+ "plays.applyInterval": "Apply interval",
+ "plays.aiEmpty": "AI fill empty",
+ "plays.aiBusy": "Generating…",
+ "plays.replies": "Replies ({n})",
+ "plays.stepN": "Reply {n}",
+ "plays.who": "Who",
+ "plays.personaOpt": "Persona (optional)",
+ "plays.brandOpt": "Brand (optional)",
+ "plays.reply": "Reply",
+ "plays.attach": "Images",
+ "plays.addOne": "Add one",
+ "plays.saving": "Saving…",
+ "plays.saveScheme": "Save scheme",
+ "plays.submitting": "Submitting…",
+ "plays.submitOutbox": "Submit to Outbox",
+ "plays.closeEdit": "Close editor",
+ "plays.noTarget": "No target post yet",
+ "plays.resolved": "Link resolved",
+ "plays.resolveFail": "Resolve failed",
+ "plays.filled": "Generated {n}",
+ "plays.needTarget": "Select a target post first",
+ "plays.saved": "Scheme saved",
+ "plays.saveFail": "Save failed",
+ "plays.submitted": "Sent to Outbox",
+ "plays.submitFail": "Submit failed",
+ "plays.confirmDelete": "Delete this scheme?",
+ "plays.accountFallback": "account",
+
+ "inspire.loading": "Loading…",
+ "inspire.trendsAria": "What's hot on Threads",
+ "inspire.trendsLabel": "Hot on Threads",
+ "inspire.refresh": "Refresh",
+ "inspire.clearChat": "Clear chat",
+ "inspire.pinAsElement": "Apply as element",
+ "inspire.you": "You",
+ "inspire.ai": "AI",
+ "inspire.system": "System",
+ "inspire.useDraft": "Use this draft",
+ "inspire.openPlay": "Open play",
+ "inspire.generating": "Generating…",
+ "inspire.thinking": "Thinking…",
+ "inspire.pinnedAria": "Applied this round",
+ "inspire.pinned": "Applied",
+ "inspire.pickRight": "Pick elements on the right",
+ "inspire.unpinTitle": "Click to unapply",
+ "inspire.inputAria": "Talk to AI",
+ "inspire.inputPh": "What to write? Or shorter, more casual…",
+ "inspire.send": "Send",
+ "inspire.generate": "Generate",
+ "inspire.library": "Element library",
+ "inspire.addNew": "+ Add",
+ "inspire.kind": "Type",
+ "inspire.kindRole": "Role prompt",
+ "inspire.kindSnippet": "Snippet",
+ "inspire.kindTrendNote": "Trend note",
+ "inspire.kindBrand": "Brand",
+ "inspire.kindTrend": "Trend",
+ "inspire.name": "Name",
+ "inspire.namePh": "e.g. pro Threads writer",
+ "inspire.body": "Content (goes into prompt)",
+ "inspire.bodyPh": "You are a…",
+ "inspire.saveElement": "Save to library",
+ "inspire.citeBrand": "Cite brand",
+ "inspire.applied": "Applied",
+ "inspire.clickApply": "Click to apply",
+ "inspire.noBrands": "No brands yet",
+ "inspire.appliedToggle": "Applied · click again to remove",
+ "inspire.deleteAria": "Delete",
+ "inspire.needInput": "Type a line first, or generate directly",
+ "inspire.fail": "Failed",
+ "inspire.wantWrite": "Want to write about {label}: {summary}",
+ "inspire.trendBody": "Topic: {label}. {summary}",
+ "inspire.pinnedTrend": "Applied trend {label}",
+ "inspire.needTitleBody": "Name and content required",
+ "inspire.added": "Added to library",
+ "inspire.addFail": "Add failed",
+ "inspire.confirmRemove": "Remove this from the library?",
+ "inspire.confirmClear": "Clear chat? (library kept)",
+ "inspire.genMessage": "Write a Threads post from the applied elements",
+
+ "persona.add": "New persona",
+ "persona.empty": "No personas yet",
+ "persona.emptyDesc": "Add one and analyze it for drafting.",
+ "persona.statusReady": "ready",
+ "persona.statusAnalyzing": "analyzing",
+ "persona.statusPending": "pending",
+ "persona.default": "Default",
+ "persona.backList": "← Personas",
+ "persona.tabOverview": "Overview",
+ "persona.tabAnalyze": "Analyze",
+ "persona.tabFingerprint": "Fingerprint",
+ "persona.tabPreview": "Preview",
+ "persona.name": "Name",
+ "persona.brief": "Brief",
+ "persona.briefPh": "Who, for whom, core message…",
+ "persona.avoid": "Guardrails · banned words (comma-separated)",
+ "persona.guardChars": "{n} chars",
+ "persona.banAi": " · ban AI tone",
+ "persona.notReadySuffix": " · not ready",
+ "persona.setDefault": "Set as default",
+ "persona.modeAccount": "Public account",
+ "persona.modeText": "Paste text",
+ "persona.username": "Threads username",
+ "persona.fromBound": "From linked account",
+ "persona.select": "Select…",
+ "persona.crawlAnalyze": "Crawl public posts & analyze",
+ "persona.crawlBusy": "Crawling / analyzing…",
+ "persona.refText": "Reference text (--- separates posts)",
+ "persona.refTextPh": "First post…\n\n---\n\nSecond post…",
+ "persona.sourceLabel": "Source note (optional)",
+ "persona.sourcePh": "My old posts",
+ "persona.analyzeText": "Analyze from text",
+ "persona.analyzeBusy": "Analyzing…",
+ "persona.sampleMeta": "Samples {n}",
+ "persona.sourceManual": "Pasted",
+ "persona.analyzeHint": "After analysis, 8D summaries appear here.",
+ "persona.fingerprintHint": "Main voice for drafting. Edit catchphrases, rhythm, bans; Studio/replies use this.",
+ "persona.fingerprint": "Language fingerprint",
+ "persona.fingerprintPh": "Filled after analysis…",
+ "persona.saveFingerprint": "Save fingerprint",
+ "persona.tryGen": "Preview root + reply",
+ "persona.notReadyMsg": "Persona not ready",
+ "persona.rootPost": "Root post",
+ "persona.reply": "Reply",
+ "persona.hidePrompt": "Hide prompt block",
+ "persona.showPrompt": "Show injected prompt",
+ "persona.promptBlock": "prompt block (post)",
+ "persona.pickOne": "Pick a persona",
+ "persona.pickDesc": "Or add one to start analyzing.",
+ "persona.created": "Created — finish account crawl or paste text under Analyze",
+ "persona.saved": "Saved",
+ "persona.textDone": "Text analysis done · {n} segments → ready",
+ "persona.analyzeFail": "Analysis failed",
+ "persona.reading": "Reading public posts…",
+ "persona.accountDone": "@{user} · {n} posts → ready",
+ "persona.setDefaultMsg": "“{name}” set as default",
+ "persona.confirmDelete": "Delete persona “{name}”?",
+ "persona.deleted": "Persona deleted",
+ "persona.needReady": "Finish analysis first (ready)",
+ "persona.dim.d1Tone": "D1 Tone",
+ "persona.dim.d2Structure": "D2 Structure",
+ "persona.dim.d3Interaction": "D3 Interaction",
+ "persona.dim.d4Topics": "D4 Topics",
+ "persona.dim.d5Rhythm": "D5 Rhythm",
+ "persona.dim.d6Visual": "D6 Visual",
+ "persona.dim.d7Conversion": "D7 Conversion",
+ "persona.dim.d8Risk": "D8 Risk",
+
+ "admin.users.loadFail": "Load failed",
+ "admin.users.created": "Added islander “{name}” · copy the password below",
+ "admin.users.createFail": "Create failed",
+ "admin.users.unlimitedOn": "“{name}” set to unlimited (usage still counted)",
+ "admin.users.unlimitedOff": "“{name}” back to plan limits",
+ "admin.users.updateFail": "Update failed",
+ "admin.users.planSet": "“{name}” plan → {plan}",
+ "admin.users.confirmSuspend": "Suspend “{name}”?\\nThey will not be able to sign in.",
+ "admin.users.confirmUnsuspend": "Restore “{name}”?\\nThey can sign in again.",
+ "admin.users.didSuspend": "Suspended “{name}”",
+ "admin.users.didUnsuspend": "Restored “{name}”",
+ "admin.users.suspendFail": "Suspend failed",
+ "admin.users.unsuspendFail": "Restore failed",
+ "admin.users.markedVerified": "Marked {name} as email verified",
+ "admin.users.markedUnverified": "Marked {name} as unverified",
+ "admin.users.rolesUpdated": "Updated roles for {name}: {roles}",
+ "admin.users.rolesFail": "Role update failed",
+ "admin.users.confirmReset": "Reset password for “{name}”?\\nTemp password stays visible until you close it (survives refresh).",
+ "admin.users.resetDone": "Reset password for {name} (shown below — copy then close)",
+ "admin.users.resetFail": "Reset failed",
+ "admin.users.copied": "Copied to clipboard",
+ "admin.users.copyFail": "Copy failed — select the password manually",
+ "admin.users.confirmDismissTemp": "After close, this temp password won't show again (copy first if needed). Close?",
+ "admin.users.tempPwNew": "New islander temp password",
+ "admin.users.tempPw": "Temp password",
+ "admin.users.tempPwPersist": "(stays visible · survives refresh)",
+ "admin.users.copyPw": "Copy password",
+ "admin.users.close": "Close",
+ "admin.users.createTitle": "Add islander",
+ "admin.users.memberName": "Display name",
+ "admin.users.displayNamePh": "Display name",
+ "admin.users.email": "Email",
+ "admin.users.initPassword": "Initial password (optional)",
+ "admin.users.initPasswordPh": "Leave blank to auto-generate",
+ "admin.users.markVerifiedCheck": "Mark email verified (usable immediately)",
+ "admin.users.alsoAdmin": "Also make admin",
+ "admin.users.creating": "Creating…",
+ "admin.users.createSubmit": "Create islander",
+ "admin.users.clear": "Clear",
+ "admin.users.searchActive": "Search “{query}” · matches name, email, uid",
+ "admin.users.noMatch": "No matches",
+ "admin.users.none": "No islanders yet",
+ "admin.users.you": "You",
+ "admin.users.status": "Status",
+ "admin.users.role": "Roles",
+ "admin.users.emailVerify": "Email verification",
+ "admin.users.bio": "Bio",
+ "admin.users.timezone": "Timezone",
+ "admin.users.notifyEmail": "Email notifications",
+ "admin.users.on": "On",
+ "admin.users.off": "Off",
+ "admin.users.createdAt": "Created",
+ "admin.users.updatedAt": "Updated",
+ "admin.users.accountStatus": "Account status",
+ "admin.users.updating": "Updating…",
+ "admin.users.usageTitle": "Usage & plan",
+ "admin.users.plan": "Plan",
+ "admin.users.planOption": "{name} ({credits} credits / mo)",
+ "admin.users.unlimited": "Unlimited",
+ "admin.users.byPlan": "By plan",
+ "admin.users.setUnlimited": "Set unlimited",
+ "admin.users.setLimited": "Enforce plan limits",
+ "admin.users.unlimitedHint": "Unlimited: can keep using past plan cap; AI/Search counts and credits still track.",
+ "admin.users.loadingUsage": "Loading usage prefs…",
+ "admin.users.assignRoles": "Assign roles",
+ "admin.users.memberBase": "{role} (base, always on)",
+ "admin.users.adminDesc": "{role} — manage islanders and system",
+ "admin.users.saving": "Saving…",
+ "admin.users.saveRoles": "Save roles",
+ "admin.users.markUnverifiedBtn": "Mark unverified",
+ "admin.users.markVerifiedBtn": "Mark verified",
+ "admin.users.resetting": "Resetting…",
+ "admin.users.resetTemp": "Reset password (temp)",
+ "admin.users.customPw": "Or set a password (optional)",
+ "admin.users.customPwPh": "At least 4 characters",
+ "admin.users.resetWithCustom": "Reset with this password",
+
+ "usage.tabMine": "My usage",
+ "usage.tabTenant": "All usage",
+ "usage.currentPlan": "Current plan",
+ "usage.planMeta": "/ mo · {n} credits monthly",
+ "usage.changePlan": "Change plan",
+ "usage.usedThisMonth": "Used this month",
+ "usage.remainLabel": "Remaining",
+ "usage.ledgerToggle": "Usage log",
+ "usage.collapse": "Collapse",
+ "usage.eventsCount": "{n} events",
+ "usage.granularity": "Granularity",
+ "usage.day": "Day",
+ "usage.monthUnit": "Month",
+ "usage.year": "Year",
+ "usage.from": "From",
+ "usage.to": "To",
+ "usage.callCounts": "Call counts",
+ "usage.noMembers": "No members yet",
+ "usage.planAria": "{name} plan",
+ "usage.unlimitedTitle": "Unlimited",
+ "usage.setLimited": "Enforce limits",
+ "usage.setUnlimited": "Set unlimited",
+ "usage.limitShort": "Cap",
+ "usage.subscribed": "Subscribed to {name}",
+ "usage.unlimitedSet": "Set unlimited",
+ "usage.limitedSet": "Limits enforced",
+ "usage.planUpdated": "Plan updated to {name}",
+ "usage.fail": "Failed",
+
+ "currency.TWD": "New Taiwan Dollar (TWD)",
+ "currency.USD": "US Dollar (USD)",
+ "currency.JPY": "Japanese Yen (JPY)",
+ "currency.EUR": "Euro (EUR)",
+ "currency.HKD": "Hong Kong Dollar (HKD)",
+
+ "locale.zh-TW": "繁體中文",
+ "locale.en": "English",
+
+ "pager.nav": "Pagination",
+ "pager.pageSize": "Items per page",
+ "pager.perPage": "{n}/page",
+ "pager.prev": "Previous",
+ "pager.next": "Next",
+
+ "plays.defaultTitle": "New play",
+ "plays.topicOnPost": "On: {snippet}",
+ "plays.topicOnExternal": "On: {label} · {snippet}",
+ "plays.externalFallback": "External post",
+
+ "persona.newName": "New persona",
+ "persona.previewTopic": "Looking for a café I can sit in for hours",
+ "persona.previewReplySample": "The one in Da’an is fine but crowded",
+
+ "inspire.playTitle": "Inspired thread",
+ "wizard.topic.fallbackTopic": "Everyday topic",
+
+ "play.err.needLead": "Pick a lead account",
+ "play.err.needRoot": "Add at least one root post",
+ "play.err.firstMustRoot": "The first step must be the root post",
+ "play.err.rootMustLead": "Root post must use the lead account",
+ "play.err.rootEmpty": "Root post text cannot be empty",
+ "play.err.replyAccount": "Replies can only use lead or selected cast accounts",
+ "play.err.replyEmpty": "Reply text cannot be empty",
+ "play.err.needTarget": "Pick one of your posts, or paste a Threads link",
+ "play.err.needReplies": "Add at least one reply",
+ "play.err.needReplyAccounts": "Pick at least one reply account",
+ "play.err.stepAccount": "Every reply needs a usable account",
+ "play.err.stepEmpty": "Reply text cannot be empty",
+ "play.err.notFound": "Play not found",
+
+ "time.justNow": "Just now",
+ "time.minAgo": "{n}m ago",
+ "time.hourAgo": "{n}h ago",
+ "time.dayAgo": "{n}d ago",
+ "time.min": "{n} min",
+ "time.hour": "{n} hr",
+ "time.day": "{n} day",
+ "time.expired": "Expired {span}",
+ "time.remaining": "{span} left",
+ "time.sessionUnknown": "Session not recorded",
+ "time.sessionExpired": "Session expired · {absolute}",
+ "time.sessionSoon": "Session expiring · {relative} ({absolute})",
+ "time.sessionOk": "Session OK · {relative} ({absolute})",
+};
+
+const catalogs: Record = {
+ "zh-TW": zhTW,
+ en,
+};
+
+export function getCatalog(locale: AppLocale): MessageDict {
+ return catalogs[locale] || zhTW;
+}
+
+export function translate(
+ locale: AppLocale,
+ key: string,
+ params?: Record,
+): string {
+ const dict = getCatalog(locale);
+ let s = dict[key] ?? zhTW[key] ?? key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ s = s.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
+ }
+ }
+ return s;
+}
diff --git a/apps/web/src/lib/i18n/prefs.ts b/apps/web/src/lib/i18n/prefs.ts
new file mode 100644
index 0000000..07587e0
--- /dev/null
+++ b/apps/web/src/lib/i18n/prefs.ts
@@ -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 | 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): 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;
+}
diff --git a/apps/web/src/lib/i18n/types.ts b/apps/web/src/lib/i18n/types.ts
new file mode 100644
index 0000000..62a7cd1
--- /dev/null
+++ b/apps/web/src/lib/i18n/types.ts
@@ -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);
+}
diff --git a/apps/web/src/lib/id.ts b/apps/web/src/lib/id.ts
new file mode 100644
index 0000000..e473587
--- /dev/null
+++ b/apps/web/src/lib/id.ts
@@ -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)}`;
+}
diff --git a/apps/web/src/lib/memberAvatar.ts b/apps/web/src/lib/memberAvatar.ts
new file mode 100644
index 0000000..690a972
--- /dev/null
+++ b/apps/web/src/lib/memberAvatar.ts
@@ -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 {
+ 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 {
+ 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;
+}
diff --git a/apps/web/src/lib/memberRole.ts b/apps/web/src/lib/memberRole.ts
new file mode 100644
index 0000000..0888425
--- /dev/null
+++ b/apps/web/src/lib/memberRole.ts
@@ -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 | 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,
+ };
+}
diff --git a/apps/web/src/lib/mockAi.ts b/apps/web/src/lib/mockAi.ts
new file mode 100644
index 0000000..bdb5bae
--- /dev/null
+++ b/apps/web/src/lib/mockAi.ts
@@ -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 {
+ 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 ||
+ "先痛點、再經驗、最後輕帶觀點,避免一句廣告。",
+ };
+}
diff --git a/apps/web/src/lib/mockImage.ts b/apps/web/src/lib/mockImage.ts
new file mode 100644
index 0000000..b6bce99
--- /dev/null
+++ b/apps/web/src/lib/mockImage.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/apps/web/src/lib/mockInspireAngles.ts b/apps/web/src/lib/mockInspireAngles.ts
new file mode 100644
index 0000000..e2a671d
--- /dev/null
+++ b/apps/web/src/lib/mockInspireAngles.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/apps/web/src/lib/mockInspireChat.ts b/apps/web/src/lib/mockInspireChat.ts
new file mode 100644
index 0000000..6c07cae
--- /dev/null
+++ b/apps/web/src/lib/mockInspireChat.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/apps/web/src/lib/mockProductImport.ts b/apps/web/src/lib/mockProductImport.ts
new file mode 100644
index 0000000..5ac32de
--- /dev/null
+++ b/apps/web/src/lib/mockProductImport.ts
@@ -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 {
+ 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: "已填入,可再改",
+ };
+}
diff --git a/apps/web/src/lib/mockResearch.ts b/apps/web/src/lib/mockResearch.ts
new file mode 100644
index 0000000..a83a24a
--- /dev/null
+++ b/apps/web/src/lib/mockResearch.ts
@@ -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): 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): string[] {
+ return (n.reply_hooks || []).filter(Boolean).slice(0, 3);
+}
+
+/**
+ * mock 上網研究:標題 + 學習重點 + 回帖鉤子 + 摘要 + URL
+ * 前幾則 core,後則 adjacent
+ */
+export async function mockWebResearch(query: string): Promise {
+ await mockDelay(650);
+ const q = query.trim() || "主題";
+ const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic";
+
+ const pages: Array> = [
+ {
+ 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 {
+ 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 = {
+ 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] }));
+}
diff --git a/apps/web/src/lib/mockScoutExpand.ts b/apps/web/src/lib/mockScoutExpand.ts
new file mode 100644
index 0000000..8dfb040
--- /dev/null
+++ b/apps/web/src/lib/mockScoutExpand.ts
@@ -0,0 +1,242 @@
+import type { BrandProduct, ScoutRunBrief, ScoutScanContext } from "../domain/types";
+
+/** 簡單相關詞表:有命中才擴,避免假圖譜 */
+const RELATED: Record = {
+ 無香: ["香精過敏", "刺鼻", "低敏洗劑"],
+ 敏感肌: ["換季刺癢", "成分表", "溫和"],
+ 香精: ["無香", "刺鼻", "過敏"],
+ 刺鼻: ["無香", "香精"],
+ 洗衣精: ["洗淨力", "殘留", "寶寶衣物"],
+ 寶寶: ["嬰幼兒", "溫和", "洗不乾淨"],
+ 插座: ["不限時", "筆電", "久坐"],
+ 咖啡廳: ["第三空間", "安靜", "插座"],
+ 筆電: ["插座", "工作座位"],
+ 頭皮: ["換季", "無香", "刺癢"],
+ 刺癢: ["敏感肌", "換季", "成分"],
+ 第三空間: ["咖啡廳", "插座", "久坐"],
+ 週末: ["去哪", "人少", "不限時"],
+};
+
+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();
+ 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}」,你們會先問什麼?`,
+ ];
+}
diff --git a/apps/web/src/lib/mockStyleAnalyze.ts b/apps/web/src/lib/mockStyleAnalyze.ts
new file mode 100644
index 0000000..67477d2
--- /dev/null
+++ b/apps/web/src/lib/mockStyleAnalyze.ts
@@ -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 {
+ 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> = {
+ 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 {
+ 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(),
+ };
+}
diff --git a/apps/web/src/lib/mockThreadLink.ts b/apps/web/src/lib/mockThreadLink.ts
new file mode 100644
index 0000000..78b64ee
--- /dev/null
+++ b/apps/web/src/lib/mockThreadLink.ts
@@ -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 {
+ 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 | string): string {
+ const url = typeof target === "string" ? target : target.url;
+ return normalizeThreadUrl(url);
+}
diff --git a/apps/web/src/lib/mockTrends.ts b/apps/web/src/lib/mockTrends.ts
new file mode 100644
index 0000000..33f1d31
--- /dev/null
+++ b/apps/web/src/lib/mockTrends.ts
@@ -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[] = [
+ {
+ 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 {
+ 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 {
+ 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}`;
+}
diff --git a/apps/web/src/lib/mockViral.ts b/apps/web/src/lib/mockViral.ts
new file mode 100644
index 0000000..a574d6c
--- /dev/null
+++ b/apps/web/src/lib/mockViral.ts
@@ -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 {
+ 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 {
+ 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");
+}
diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts
new file mode 100644
index 0000000..31555d1
--- /dev/null
+++ b/apps/web/src/lib/nav.ts
@@ -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";
+}
diff --git a/apps/web/src/lib/pagination.ts b/apps/web/src/lib/pagination.ts
new file mode 100644
index 0000000..a04b1c2
--- /dev/null
+++ b/apps/web/src/lib/pagination.ts
@@ -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(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;
diff --git a/apps/web/src/lib/personaPrompt.ts b/apps/web/src/lib/personaPrompt.ts
new file mode 100644
index 0000000..50cadb6
--- /dev/null
+++ b/apps/web/src/lib/personaPrompt.ts
@@ -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 = {
+ 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 & { 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): Persona {
+ const r = raw as Partial & { 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];
+}
diff --git a/apps/web/src/lib/planPurchase.ts b/apps/web/src/lib/planPurchase.ts
new file mode 100644
index 0000000..b9b4d5c
--- /dev/null
+++ b/apps/web/src/lib/planPurchase.ts
@@ -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;
+}
diff --git a/apps/web/src/lib/planRights.ts b/apps/web/src/lib/planRights.ts
new file mode 100644
index 0000000..e9a81ee
--- /dev/null
+++ b/apps/web/src/lib/planRights.ts
@@ -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;
+
+/** 購買/比價用文案(用量首頁不堆字)— 由 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");
+}
diff --git a/apps/web/src/lib/productMatch.ts b/apps/web/src/lib/productMatch.ts
new file mode 100644
index 0000000..c2532a8
--- /dev/null
+++ b/apps/web/src/lib/productMatch.ts
@@ -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}`;
+}
diff --git a/apps/web/src/lib/scoutToday.ts b/apps/web/src/lib/scoutToday.ts
new file mode 100644
index 0000000..eb37a95
--- /dev/null
+++ b/apps/web/src/lib/scoutToday.ts
@@ -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(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;
+}
diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts
new file mode 100644
index 0000000..be329c5
--- /dev/null
+++ b/apps/web/src/lib/storage.ts
@@ -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(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));
+}
diff --git a/apps/web/src/lib/tenantUsers.ts b/apps/web/src/lib/tenantUsers.ts
new file mode 100644
index 0000000..dd37edc
--- /dev/null
+++ b/apps/web/src/lib/tenantUsers.ts
@@ -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 | Pick | 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();
+ 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 | 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,
+ });
+ }
+}
diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts
new file mode 100644
index 0000000..399f920
--- /dev/null
+++ b/apps/web/src/lib/theme.ts
@@ -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";
+}
+
+/** 寫入 與 color-scheme */
+export function applyResolvedTheme(resolved: ResolvedTheme): void {
+ const root = document.documentElement;
+ root.setAttribute("data-theme", resolved);
+ root.style.colorScheme = resolved;
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
new file mode 100644
index 0000000..0a4e77f
--- /dev/null
+++ b/apps/web/src/lib/time.ts
@@ -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 {
+ 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())}`;
+}
diff --git a/apps/web/src/lib/usageMeter.ts b/apps/web/src/lib/usageMeter.ts
new file mode 100644
index 0000000..b3e0dc6
--- /dev/null
+++ b/apps/web/src/lib/usageMeter.ts
@@ -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;
+};
+
+/** 每人方案/是否不擋額度(用量一律計算) */
+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 = {
+ 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 = {
+ 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;
+ 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;
+ 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;
+
+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(KEYS.usagePlanId, "free");
+ return { plan_id: normalizePlanId(legacy), unlimited: false };
+}
+
+export function setMemberPrefs(
+ uid: string,
+ patch: Partial,
+): 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 = {
+ 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;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index bef5202..be3a96e 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -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(
,
-)
+);
diff --git a/apps/web/src/pages/AdminUsersPage.tsx b/apps/web/src/pages/AdminUsersPage.tsx
new file mode 100644
index 0000000..d13a47e
--- /dev/null
+++ b/apps/web/src/pages/AdminUsersPage.tsx
@@ -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(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([]);
+ 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(null);
+ const [roleDraft, setRoleDraft] = useState(["member"]);
+ const [busy, setBusy] = useState("");
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ const [revealed, setRevealed] = useState(() => 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(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 ;
+ if (!isAdmin) {
+ return (
+ <>
+
+
+
+
+ }
+ />
+ >
+ );
+ }
+
+ 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 (
+ <>
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {revealed ? (
+
+
+
+ {revealed.reason === "create" ? t("admin.users.tempPwNew") : t("admin.users.tempPw")}
+ {t("admin.users.tempPwPersist")}
+
+
+ {revealed.display_name} · {revealed.email}
+ {" · "}
+ {formatLocalDateTime(revealed.created_at)}
+
+
+
+
+ {revealed.password}
+
+
+
+
+
+
+ {copyHint ? (
+
+ {copyHint}
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+ {showCreate ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ setQueryDraft(e.target.value)}
+ placeholder={t("admin.users.searchPh")}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ applySearch();
+ }
+ }}
+ />
+
+ {query ? (
+
+ ) : null}
+
+ {query ? (
+
+ {t("admin.users.searchActive", { query })}
+
+ ) : null}
+
+ {users.length === 0 ? (
+
+ ) : (
+
+ {users.map((u) => {
+ const role = memberRoleSummary(u, t);
+ const active = selected?.uid === u.uid;
+ const suspended = u.status === "suspended";
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ {!selected ? (
+
+ {t("admin.users.pick")}
+
+ ) : (
+
+
+
+
{selected.display_name}
+
+ {selected.email}
+
+
+
+ {selected.uid === member.uid ? {t("admin.users.you")} : null}
+ {isSuspended ? (
+ {t("admin.users.suspended")}
+ ) : (
+ {t("admin.users.active")}
+ )}
+
+
+
+
+
+
- {t("admin.users.status")}
+ - {isSuspended ? t("admin.users.suspended") : t("admin.users.active")}
+
+
+
- {t("admin.users.role")}
+ - {selected.roles.map((r) => roleLabel(r, t)).join(t("common.listSep"))}
+
+
+
- {t("admin.users.emailVerify")}
+ -
+ {selected.email_verified ? t("role.verified") : t("role.unverified")}
+
+
+
+
- {t("admin.users.bio")}
+ - {selected.bio?.trim() || t("common.dash")}
+
+
+
- {t("admin.users.timezone")}
+ - {selected.timezone || t("common.dash")}
+
+
+
- {t("admin.users.notifyEmail")}
+ - {selected.notify_email ? t("admin.users.on") : t("admin.users.off")}
+
+
+
- uid
+ -
+
{selected.uid}
+
+
+
+
- {t("admin.users.createdAt")}
+ - {formatLocalDateTime(selected.created_at)}
+
+
+
- {t("admin.users.updatedAt")}
+ - {formatLocalDateTime(selected.updated_at)}
+
+
+
+ {/* 停權 */}
+
+
+ {t("admin.users.accountStatus")}
+
+
+
+
+ {/* 用量:方案 + 不擋額度 */}
+
+
+ {t("admin.users.usageTitle")}
+
+ {usagePrefs ? (
+ <>
+
+
+ {usagePrefs.unlimited ? (
+ {t("admin.users.unlimited")}
+ ) : (
+ {t("admin.users.byPlan")}
+ )}
+
+
+
+ {t("admin.users.unlimitedHint")}
+
+ >
+ ) : (
+
+ {t("admin.users.loadingUsage")}
+
+ )}
+
+
+ {/* 權限 */}
+
+
+ {t("admin.users.assignRoles")}
+
+
+
+
+
+
+
+
+
+
+
+
+ setCustomPw(e.target.value)}
+ placeholder={t("admin.users.customPwPh")}
+ disabled={isSuspended}
+ />
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/BrandsPage.tsx b/apps/web/src/pages/BrandsPage.tsx
new file mode 100644
index 0000000..d4a9c2b
--- /dev/null
+++ b/apps/web/src/pages/BrandsPage.tsx
@@ -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([]);
+ const [products, setProducts] = useState([]);
+ const [brandId, setBrandId] = useState("");
+ const [detailTab, setDetailTab] = useState("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(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 (
+ <>
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+
+ {/* 選牌區:手機橫向 chip,桌面左側列表 */}
+
+
+ {/* 焦點編輯 */}
+
+ {!selected ? (
+
+ ) : (
+ <>
+
+
+
+ {(selected.display_name.trim()[0] || "?").toUpperCase()}
+
+
+
{selected.display_name}
+
{t("brands.inUseHint")}
+
+
+ {t("brands.inUse")}
+
+
+
+
+
+
+
+ {detailTab === "brand" ? (
+
+ ) : null}
+
+ {detailTab === "products" ? (
+
+ {!showProductForm ? (
+ <>
+
+ {products.length > 0 ? (
+
+ setProductQuery(e.target.value)}
+ placeholder={t("brands.searchProductPh")}
+ />
+
+ ) : (
+
+ )}
+
+
+
+ {products.length === 0 ? (
+
+ ) : filteredProducts.length === 0 ? (
+
+ ) : (
+ <>
+
+ {pagedProducts.map((p) => (
+ -
+
+ {p.label}
+ {p.placement_url ? {t("brands.hasLink")} : null}
+
+ {p.pain_points.length ? (
+
+ {t("brands.painLabel")}
+ {p.pain_points.join(" · ")}
+
+ ) : null}
+ {p.product_context}
+ {p.match_tags.length ? (
+
+ {p.match_tags.map((t) => (
+
+ {t}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+ ) : null}
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx
new file mode 100644
index 0000000..1ab4895
--- /dev/null
+++ b/apps/web/src/pages/CrewPage.tsx
@@ -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("accounts");
+ const [accounts, setAccounts] = useState([]);
+ 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 (
+ <>
+
+
+
+
+
+
+
+ {tab === "accounts" ? (
+
+
+
+
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ {accounts.length === 0 ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/pages/ForgotPasswordPage.tsx b/apps/web/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..58b8b6f
--- /dev/null
+++ b/apps/web/src/pages/ForgotPasswordPage.tsx
@@ -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 (
+
+
+ {done ? (
+
+
+ {done.message}
+
+ {done.mockPath ? (
+
+
+ {t("forgot.mockMail")}
+
+
+ {t("forgot.mockHint")}
+
+
+ {t("forgot.openReset")}
+
+
+ ) : (
+
+ {t("forgot.checkInbox")}
+
+ )}
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/pages/InsightsPage.tsx b/apps/web/src/pages/InsightsPage.tsx
new file mode 100644
index 0000000..7b70372
--- /dev/null
+++ b/apps/web/src/pages/InsightsPage.tsx
@@ -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 {t("common.dash")};
+ if (value > 0) return {fmtDelta(value)};
+ if (value < 0) return {fmtDelta(value)};
+ return {t("insights.zeroPct")};
+}
+
+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 = {
+ 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 (
+
+
+ {t("insights.barsLabel", { metric: labels[metric], n: months.length })}
+
+ {t("insights.clickBar")}
+
+
+
+ {months.map((m) => {
+ const h = Math.round((m[metric] / max) * 100);
+ const selected = m.key === selectedKey;
+ const valStr = m[metric].toLocaleString(loc);
+ return (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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
([]);
+ const [accountId, setAccountId] = useState("");
+ const [posts, setPosts] = useState([]);
+ const [metric, setMetric] = useState<"views" | "likes" | "replies" | "posts">("views");
+ const [busy, setBusy] = useState(false);
+ /** 分析歷史:選中的月份 */
+ const [analysisMonth, setAnalysisMonth] = useState(monthKeyFromDate());
+ const [history, setHistory] = useState([]);
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ {!accountId || !report ? (
+
+
+
+ }
+ />
+ ) : (
+
+ {/* 本月總覽 */}
+
+
+ {t("insights.monthViews")}
+
+ {report.current.views.toLocaleString(numLoc)}
+
+
+ {t("insights.vsPrev")}
+
+
+
+ {t("insights.monthLikes")}
+ {report.current.likes}
+
+ {t("insights.vsPrev")}
+
+
+
+ {t("insights.monthReplies")}
+ {report.current.replies}
+
+ {t("insights.vsPrev")}
+
+
+
+ {t("insights.engRate")}
+
+ {fmtEngRate(report.current.engagementRate)}
+
+
+ {t("insights.avgNear", { rate: fmtEngRate(report.avgEngagementRate) })} ·{" "}
+
+
+
+
+
+
+
+
+
+ {(["views", "likes", "replies", "posts"] as const).map((m) => (
+
+ ))}
+
+
+ {selectedSnap ? (
+
+
+
{t("insights.analysisOf", { label: selectedSnap.month_label })}
+
+ {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) })
+ : ""}
+
+
+
+ {t("insights.statPosts")}