feat init

This commit is contained in:
王性驊 2026-06-21 20:50:31 +08:00
commit 813276d78a
338 changed files with 44334 additions and 0 deletions

64
.env.example Normal file
View File

@ -0,0 +1,64 @@
# AI Providers也可在設定頁填入設定頁優先
XAI_API_KEY=
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
OPENCODE_GO_API_KEY=
# Playwright可選
PLAYWRIGHT_HEADLESS=true
# 瀏覽器海巡平行數(預設 2、最高 2遇到限流會立即停止
THREADS_BROWSER_CONCURRENCY=2
# 真人操作延遲倍率(預設 1若帳號較新或曾被限流可調成 1.52
THREADS_HUMAN_DELAY_MULTIPLIER=1
# 每次最多 4 個搜尋/帳號任務、每個任務最多 12 篇
CRAWLER_MAX_TASKS_PER_SCAN=4
CRAWLER_MAX_POSTS_PER_TASK=12
# 每帳號每日最多開啟 40 個 Threads 搜尋頁
CRAWLER_DAILY_PAGE_LIMIT=40
# 偵測到 403429checkpoint 後暫停瀏覽器爬蟲(分鐘)
CRAWLER_BLOCK_COOLDOWN_MINUTES=180
# 設為 true 強制開啟瀏覽器 debug也可在設定頁開關
# THREADS_DEBUG=true
# PLAYWRIGHT_SLOW_MO=250
# ── 搜尋 Provider僅 Threads API / Brave / 爬蟲)──
# Threads API主力
THREADS_API_ENABLED=true
THREADS_ACCESS_TOKEN=
THREADS_API_BASE_URL=
THREADS_QUERY_LIMIT_PER_DAY=2200
# THREADS_SEARCH_CACHE_TTL=15m
# Brave Search APIMVP 過渡high priority fallback
# https://api-dashboard.search.brave.com/
BRAVE_SEARCH_ENABLED=true
BRAVE_SEARCH_API_KEY=
BRAVE_SEARCH_BASE_URL=https://api.search.brave.com/res/v1/web/search
BRAVE_DAILY_LIMIT=30
BRAVE_RESULT_LIMIT=10
BRAVE_CACHE_TTL=4h
# 單次海巡 Brave 查詢上限(預設 8
# SCAN_BRAVE_MAX_QUERIES=8
# 瀏覽器爬蟲(補漏/詳細內容)
CRAWLER_ENABLED=true
# CRAWLER_RATE_LIMIT=
CRAWLER_CACHE_TTL=1h
# 以下舊搜尋 API 已停用,請勿再設定:
# SERPAPI_API_KEY, SERPER_API_KEY, GOOGLE_SEARCH_API_KEY,
# GOOGLE_CSE_API_KEY, GOOGLE_CSE_CX, BING_SEARCH_API_KEY,
# TAVILY_API_KEY, EXA_API_KEY, SEARXNG_BASE_URL, DUCKDUCKGO_ENABLED
# DB
DATABASE_URL="file:./dev.db"
# Threads 官方 API僅在 .env 設定,網頁不提供填寫)
# 在 Meta 開發者後台建立 Threads App並加入 Redirect URI
# {APP_URL}/api/threads/oauth/callback
THREADS_APP_ID=
THREADS_APP_SECRET=
# 本機開發若要用 API 發布含圖貼文,需設成 Meta 可存取的公開網址(如 ngrok
APP_URL=http://localhost:3000

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env*.local
# database
/prisma/dev.db
/prisma/dev.db-journal
*.db
*.db-journal
# playwright
/playwright/.auth/
/storage/
/data/
# typescript
next-env.d.ts

95
Makefile Normal file
View File

@ -0,0 +1,95 @@
# 巡樓 Haixun — 快速啟動與程序管理
# 需求Node.js 20+、npm、pm2npm i -g pm2
SHELL := /bin/bash
ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
PORT ?= 3000
PM2 := $(shell command -v pm2 2>/dev/null)
.PHONY: help init db-init install build up start dev stop restart status logs down \
playwright-setup save
help:
@echo "巡樓 Haixun — 常用指令"
@echo ""
@echo " 初始化"
@echo " make init 首次設定(安裝依賴 + 建立 .env + 初始化 DB"
@echo " make db-init 僅同步 Prisma schema 到資料庫"
@echo " make install 僅 npm install"
@echo ""
@echo " 啟動PM2"
@echo " make start build 後以 PM2 啟動 web + worker生產"
@echo " make up 不 build直接 PM2 啟動 web + worker"
@echo " make dev PM2 啟動開發伺服器next dev不含 worker"
@echo " make stop 停止所有 Haixun 程序"
@echo " make restart 重啟所有 Haixun 程序"
@echo " make down 停止並移除 PM2 程序"
@echo " make status 查看 PM2 狀態"
@echo " make logs 查看 PM2 日誌web + worker"
@echo " make save 儲存 PM2 程序列表(開機自動啟動用)"
@echo ""
@echo " 其他"
@echo " make build next build"
@echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴"
@echo ""
@echo " 環境變數PORT=$(PORT)(預設 3000"
init:
@bash "$(ROOT)/scripts/init.sh"
db-init:
@test -f "$(ROOT)/.env" || (cp "$(ROOT)/.env.example" "$(ROOT)/.env" && echo "已建立 .env")
@cd "$(ROOT)" && npm run db:generate && npm run db:push
@echo "資料庫 schema 已同步"
install:
@cd "$(ROOT)" && npm install
build:
@cd "$(ROOT)" && npm run build
check-pm2:
ifndef PM2
@echo "錯誤:找不到 pm2請執行npm install -g pm2" >&2
@exit 1
endif
up: check-pm2
@chmod +x "$(ROOT)/scripts/"pm2-*.sh "$(ROOT)/scripts/init.sh"
@cd "$(ROOT)" && PORT=$(PORT) pm2 start ecosystem.config.cjs --only haixun-web,haixun-worker
@echo "已啟動haixun-web (http://localhost:$(PORT))、haixun-worker"
@echo "查看狀態make status"
start: build up
dev: check-pm2
@chmod +x "$(ROOT)/scripts/"pm2-*.sh
@cd "$(ROOT)" && PORT=$(PORT) pm2 start ecosystem.config.cjs --only haixun-web-dev
@echo "開發伺服器http://localhost:$(PORT)"
@echo "查看日誌pm2 logs haixun-web-dev"
stop: check-pm2
@cd "$(ROOT)" && pm2 stop haixun-web haixun-worker haixun-web-dev 2>/dev/null || true
@echo "已停止"
restart: check-pm2
@cd "$(ROOT)" && pm2 restart haixun-web haixun-worker 2>/dev/null || $(MAKE) up
@echo "已重啟"
down: check-pm2
@cd "$(ROOT)" && pm2 delete haixun-web haixun-worker haixun-web-dev 2>/dev/null || true
@echo "已移除 PM2 程序"
status: check-pm2
@pm2 status haixun-web haixun-worker haixun-web-dev 2>/dev/null || pm2 status
logs: check-pm2
@pm2 logs haixun-web haixun-worker --lines 100
save: check-pm2
@pm2 save
@echo "PM2 程序列表已儲存(搭配 pm2 startup 可開機自啟)"
playwright-setup:
@cd "$(ROOT)" && npm run playwright:setup

207
README.md Normal file
View File

@ -0,0 +1,207 @@
# 巡樓Haixun Master— Threads AI 經營與獲客工作台
用 AI 在 Threads上經營帳號支援多帳號、混合資料來源官方 API / 瀏覽器爬蟲)與自動化。
## 兩條核心流程
| 流程 | 說明 | 現階段建議 |
|------|------|------------|
| **流程 A — 風格複製發文** | 海巡爆文與留言 → AI 學風格 → 草稿 → 審核 → 發文 | Chrome 同步 + 爬蟲 |
| **流程 B — 產品獲客** | 找潛在客群貼文 → 生成獲客留言 → 回覆自己貼文 → 追成效 | 需 Meta 官方 API |
## 技術棧
- Next.js 15 + TypeScript + Tailwind CSS
- SQLite + Prisma
- Playwright瀏覽器海巡、爬留言、發文
- Meta Threads 官方 API發文、獲客留言、成效需 OAuth
- Chrome 擴充(從本機 Chrome 同步 session 到遠端 server
- Vercel AI SDKOpenCode Go / Grok / OpenAI / Claude / Gemini
## 快速開始
```bash
npm install
npm run playwright:setup # Chromium + Linux 依賴
cp .env.example .env
# 填入 OPENCODE_GO_API_KEY 等
npm run db:push
npm run dev
```
開啟 http://localhost:3000
遠端 Linux server 部署時另需:
```bash
PLAYWRIGHT_HEADLESS=true npm run start
npm run worker # 若要自動化排程
```
## 三層設定分離
| 層級 | 在哪裡設定 | 範圍 | 內容 |
|------|-----------|------|------|
| **巡樓使用者** | 設定 `/settings` | 登入者是誰就是誰 | AI API Key、預設模型、產文偏好 |
| **巡樓使用者** | 設定 `/settings` | 全 Threads 帳號共用 | Meta App ID/Secret |
| **Threads 經營帳號** | 連線設定 `/connections` | 每帳號各一份 | 連線預設、Chrome 同步、OAuth token |
| **Threads 經營帳號** | 帳號策略 `/accounts` | 每帳號各一份 | 人設、受眾、定位策略 |
> AI Key 跟你有幾個 Threads 帳號無關 — 切換經營帳號不會換 AI Key。
## 設定頁:連線與資料流程
每帳號的連線預設與 Chrome 同步在 **連線設定**`/connections`AI Key 等在 **設定**`/settings`)。
### 連線預設(三種常用模式)
| 預設 | 海巡 | 留言 | 發文 | 適用情境 |
|------|------|------|------|----------|
| **Chrome 同步** | 瀏覽器 | 瀏覽器爬取 | 瀏覽器 | 現階段全爬蟲、要留言學風格 |
| **API Key 優先** | Meta API | 不爬 | Meta API | 已申請官方 API、流程 B |
| **混合模式** | Meta API | 瀏覽器爬取 | 瀏覽器 | API 海巡 + 留言素材 |
設定頁會即時顯示「目前流程預覽」,說明海巡、留言、發文各走哪條路。
### 底層開關(進階自訂)
| 開關 | 作用 |
|------|------|
| `searchViaApi` | 海巡優先 Meta keyword search |
| `publishViaApi` | 發文優先 Meta API |
| `devMode` | 允許 Playwright 瀏覽器海巡與爬留言 |
| `scrapeReplies` | 是否抓他人貼文留言(需 devMode |
| `repliesPerPost` | 每篇熱門文抓幾則留言 |
| `publishHeaded` | 發文時是否顯示瀏覽器視窗 |
| `playwrightDebug` | 保留 Playwright 除錯截圖 |
### Chrome Session 同步(遠端 server 必備)
服務跑在 Linux 無頭 server 時,**無法**在 server 上直接登入 Threads。
改用 Chrome 擴充(在**連線設定**頁操作):
1. Chrome → `chrome://extensions` → 開發人員模式 → 載入 `extension/haixun-threads-sync`
2. 擴充選項填入 server 網址(例如 `https://your-server.com`
3. 在 Chrome 登入 threads.com
4. 巡樓側欄切換到目標帳號
5. 連線設定頁按「從 Chrome 同步到目前帳號」
擴充會讀取 Chrome 的 Threads/Instagram cookies轉成 Playwright `storageState` 寫入 server DB。
> 不要同時在本機 Chrome 與 server Playwright 登入同一 Threads 帳號,會互相踢出。
### Meta App 憑證(非 AI Key
在設定頁填 **Threads App ID** + **App Secret**(全帳號共用),再到連線設定頁或側欄為**每個帳號**各做一次 OAuth 綁定。
完整申請步驟見 [docs/threads-api-setup.md](docs/threads-api-setup.md)。
## 使用流程
### 流程 A現階段Chrome 同步)
1. **連線設定** `/connections` → 安裝擴充並同步 Chrome session
2. **設定** `/settings` → 填 AI Key
3. **海巡** `/matrix` → 開始海巡 → 生成草稿 → 審核 → 發布
4. **成效紀錄** `/published` → 查看發布結果
### 流程 BAPI 獲客)
1. **連線設定** `/connections` → 綁定 Threads API OAuth
2. **設定** `/settings` → 填 AI Key
3. **找 TA** `/outreach` → 挖掘受眾 → 生成留言 → 發布
4. **互動經營** `/engagement` → 同步留言 → 生成回覆 → 發布
5. **成效紀錄** `/published` → 追蹤成效
## 多帳號模型
每個「經營帳號」各自有策略、主題、草稿、session 與 API token
- **瀏覽器 session**Chrome 擴充同步到 `Account.storageState`(每帳號各一次)
- **官方 API token**:側欄 OAuth 授權(每帳號各一把,共用一組 App ID/Secret
側欄可「新增經營帳號」→ 切換帳號 → 對該帳號同步 Chrome session。
## 資料抓取邏輯(程式行為)
```
海巡
├─ searchViaApi + 帳號有 OAuth → Meta keyword search
├─ 失敗或未開 → devMode 開 → Playwright 搜尋
└─ 記錄 Scan.searchSource = "api" | "browser"
留言top 12 篇)
├─ scrapeReplies + devMode + 有 session → Playwright 爬留言
└─ 否則略過API 模式無法讀他人留言)
發文
├─ publishViaApi + OAuth → Meta API
└─ 否則 / 失敗 → Playwright + storageState
```
## 自動化
**自動化** 頁設定定時任務(需 `npm run worker`
| 任務 | 流程 |
|------|------|
| 自動海巡 | A + B |
| 自動生成草稿 | A |
| 自動發文 | A |
| 自動獲客留言 | B需 Meta API |
| 自動回覆留言 | B需 Meta API |
## 環境變數
| 變數 | 說明 |
|------|------|
| `OPENCODE_GO_API_KEY` | OpenCode Go預設 AI |
| `XAI_API_KEY` / `OPENAI_API_KEY` 等 | 其他 AI provider可選 |
| `PLAYWRIGHT_HEADLESS` | `true`server 預設)或 `false`(本機除錯) |
| `DATABASE_URL` | SQLite 路徑,預設 `file:./dev.db` |
| `THREADS_APP_ID` / `THREADS_APP_SECRET` | 也可在設定頁填,不必寫 .env |
| `APP_URL` | 對外網址OAuth 與含圖 API 發文用 |
## 專案結構
```
app/ # Next.js 頁面與 API
(dashboard)/matrix/ # 海巡 — 內容矩陣與草稿審核
(dashboard)/outreach/ # 找 TA — 獲客留言
(dashboard)/engagement/ # 互動經營 — 留言回覆
(dashboard)/connections/ # 連線設定 — Chrome 同步、OAuth、搜尋來源
(dashboard)/automation/ # 自動化排程
(dashboard)/published/ # 成效紀錄
(dashboard)/settings/ # 設定 — AI Key、模型、產文偏好
extension/haixun-threads-sync/ # Chrome 擴充:同步 session
lib/
threads-api/ # Meta 官方 API
threads-browser/ # Playwright 爬蟲
services/scan.ts # 海巡編排API / 瀏覽器)
automation/ # 自動化引擎
worker/ # cron 排程器
docs/threads-api-setup.md # Meta API 申請指南
```
## 路線圖
| 階段 | 讀資料 | 寫資料 |
|------|--------|--------|
| **現在** | Playwright 爬蟲 + Chrome 同步 | Playwright 發文 |
| **之後** | Apify留言+ Meta API海巡 | Meta API獲客/回覆/成效) |
爬蟲模組會保留作 fallback。
## 風險與注意事項
- **瀏覽器爬蟲違反 Meta ToS**,有封號風險;建議用測試帳號
- **官方 API 較合規**,但讀不了他人貼文留言
- `storageState` 與 token 等同密碼,勿 commit `.env``*.db`
- Threads 貼文 ≤ 500 字
## License
MIT — 使用風險自負。

View File

@ -0,0 +1,646 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import {
Loader2,
ScanText,
MessageCircle,
Plus,
Send,
Sparkles,
UserRound,
WandSparkles,
X,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/layout/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/layout/page-header";
import { useJobs } from "@/components/layout/jobs-provider";
import { JobProgressPanel } from "@/components/job-progress-panel";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Textarea } from "@/components/ui/textarea";
import { useActionFeedback } from "@/lib/use-action-feedback";
import { parseFetchJson } from "@/lib/utils";
import {
createEmptyStyle8DProfile,
parseStyle8DProfile,
STYLE_8D_KEYS,
STYLE_8D_LABELS,
type StoredStyle8DProfile,
type Style8DKey,
} from "@/lib/types/style-profile";
interface Account {
id: string;
username?: string | null;
displayName?: string | null;
persona?: string | null;
styleProfile?: string | null;
styleBenchmark?: string | null;
brief?: string | null;
productBrief?: string | null;
targetAudience?: string | null;
goals?: string | null;
}
interface AssistantMessage {
role: "user" | "assistant";
content: string;
}
type Style8DResult = StoredStyle8DProfile;
export default function AccountsPage() {
const [account, setAccount] = useState<Account | null>(null);
const [draft, setDraft] = useState<Account | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const [assistantOpen, setAssistantOpen] = useState(true);
const [assistantInput, setAssistantInput] = useState("");
const [assistantBusy, setAssistantBusy] = useState(false);
const [benchmarkUsername, setBenchmarkUsername] = useState("");
const [styleResult, setStyleResult] = useState<Style8DResult | null>(null);
const assistantInputRef = useRef<HTMLInputElement>(null);
const { activeJobs } = useJobs();
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
const [assistantMessages, setAssistantMessages] = useState<AssistantMessage[]>([
{
role: "assistant",
content: "跟我說你想經營的方向,我會把這頁策略欄位先幫你補好。",
},
]);
const styleJob = activeJobs.find(
(job) => job.type === "style-8d" && (!draft?.id || job.accountId === draft.id)
);
const visibleStyle =
styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
const load = useCallback(async () => {
setLoading(true);
const res = await fetch("/api/accounts");
const data = await parseFetchJson<{ accounts?: Account[]; activeAccountId?: string }>(res);
const rows = (data.accounts ?? []) as Account[];
const active = rows.find((row) => row.id === data.activeAccountId) ?? rows[0] ?? null;
setAccount(active);
setDraft(active);
setStyleResult(parseStyle8DProfile(active?.styleProfile));
setBenchmarkUsername(active?.styleBenchmark ?? "");
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
function onJobCompleted(event: Event) {
const { job } = (event as CustomEvent).detail as {
job: { type: string; accountId?: string | null; status: string };
};
if (job.type === "style-8d" && job.accountId === account?.id && job.status === "completed") {
void load();
}
}
window.addEventListener("job-completed", onJobCompleted);
return () => window.removeEventListener("job-completed", onJobCompleted);
}, [account?.id, load]);
async function createAccount() {
if (!newName.trim()) return;
clearFeedback();
setBusy("create");
const res = await fetch("/api/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: newName.trim() }),
});
let data: { error?: string } = {};
try {
data = await parseFetchJson(res);
} catch (err) {
setBusy(null);
showError(err instanceof Error ? err.message : "伺服器回應異常", "建立失敗");
return;
}
setBusy(null);
if (!res.ok) {
showError(data.error ?? "無法建立帳號", "建立失敗");
return;
}
setNewName("");
showSuccess("帳號策略已建立");
load();
}
async function save() {
if (!draft) return;
clearFeedback();
setBusy("save");
const res = await fetch(`/api/accounts/${draft.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName: draft.displayName,
username: draft.username,
persona: draft.persona,
styleProfile: draft.styleProfile,
styleBenchmark: draft.styleBenchmark,
brief: draft.brief,
productBrief: draft.productBrief,
targetAudience: draft.targetAudience,
goals: draft.goals,
}),
});
let data: { error?: string } = {};
try {
data = await parseFetchJson(res);
} catch (err) {
setBusy(null);
showError(err instanceof Error ? err.message : "伺服器回應異常", "儲存失敗");
return;
}
setBusy(null);
if (!res.ok) {
showError(data.error ?? "無法儲存策略", "儲存失敗");
return;
}
showSuccess("帳號策略已儲存");
load();
}
function updateDraft(patch: Partial<Account>) {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}
async function analyzeBenchmark() {
if (!draft || !benchmarkUsername.trim()) return;
clearFeedback();
setBusy("8d");
try {
const res = await fetch(`/api/accounts/${draft.id}/style-analysis`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ benchmarkUsername }),
});
const data = await parseFetchJson<{ error?: string; jobId?: string; message?: string }>(res);
if (!res.ok) {
showError(data.error ?? "無法完成 8D 分析", "8D 分析失敗");
return;
}
showSuccess(data.message ?? "8D 分析已在背景執行,可自由切換頁面");
window.dispatchEvent(new Event("haixun:jobs-updated"));
} catch (error) {
showError(error instanceof Error ? error.message : "8D 分析失敗", "8D 分析失敗");
} finally {
setBusy(null);
}
}
function updateStyleDimension(key: Style8DKey, summary: string) {
const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
const next: Style8DResult = {
...base,
analysis: {
...base.analysis,
[key]: { ...base.analysis[key], summary },
},
};
setStyleResult(next);
updateDraft({ styleProfile: JSON.stringify(next) });
}
const clearAssistantInput = useCallback(() => {
setAssistantInput("");
if (assistantInputRef.current) {
assistantInputRef.current.value = "";
}
}, []);
async function askAssistant(preset?: string) {
if (!draft) return;
const instruction = (preset ?? assistantInput).trim();
if (!instruction || assistantBusy) return;
flushSync(() => {
if (!preset) clearAssistantInput();
setAssistantMessages((prev) => [...prev, { role: "user", content: instruction }]);
});
setAssistantBusy(true);
const res = await fetch("/api/accounts/strategy-assistant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction,
current: {
displayName: draft.displayName,
username: draft.username,
brief: draft.brief,
persona: draft.persona,
targetAudience: draft.targetAudience,
productBrief: draft.productBrief,
goals: draft.goals,
style8D: Object.fromEntries(
STYLE_8D_KEYS.map((key) => [key, visibleStyle.analysis[key]?.summary ?? null])
),
},
}),
});
let data: {
error?: string;
fields?: Partial<Account> & { style8D?: Partial<Record<Style8DKey, string | null>> };
message?: string;
} = {};
try {
data = await parseFetchJson(res);
} catch (err) {
setAssistantBusy(false);
const message = err instanceof Error ? err.message : "伺服器回應異常";
setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]);
return;
}
setAssistantBusy(false);
if (!res.ok) {
const message = data.error ?? "小幫手暫時無法產生內容";
setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]);
return;
}
const fields = data.fields ?? {};
const { style8D, ...accountFields } = fields;
let nextStyleResult = styleResult;
if (style8D && Object.values(style8D).some((value) => value?.trim())) {
const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim());
nextStyleResult = {
...base,
analysis: { ...base.analysis },
};
for (const key of STYLE_8D_KEYS) {
const summary = style8D[key]?.trim();
if (summary) {
nextStyleResult.analysis[key] = { ...base.analysis[key], summary };
}
}
setStyleResult(nextStyleResult);
}
setDraft((prev) =>
prev
? {
...prev,
...Object.fromEntries(
Object.entries(accountFields).filter(([, value]) => value !== null && value !== undefined)
),
...(nextStyleResult ? { styleProfile: JSON.stringify(nextStyleResult) } : {}),
}
: prev
);
setAssistantMessages((prev) => [
...prev,
{
role: "assistant",
content: data.message ?? "我已經依照你的方向補上這頁欄位,你可以再微調後儲存。",
},
]);
}
return (
<div>
<PageHeader
title="帳號策略"
description="人設與受眾定位"
action={
draft && (
<Button onClick={save} disabled={busy === "save"}>
{busy === "save" && <Loader2 className="h-4 w-4 animate-spin" />}
</Button>
)
}
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
{loading ? (
<div className="space-y-4">
<div className="skeleton h-72 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
</div>
) : !draft ? (
<EmptyState
icon={UserRound}
title="先建立一個經營帳號"
description="側欄新增帳號後設定策略"
action={
<div className="flex w-full max-w-md flex-col gap-2 sm:flex-row">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="例如:個人品牌、產品號"
/>
<Button onClick={createAccount} disabled={busy === "create"}>
<Plus className="h-4 w-4" />
</Button>
</div>
}
/>
) : (
<div className="space-y-5">
<Card>
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>{account?.displayName ?? account?.username ?? "未命名帳號"}</CardTitle>
</div>
<Badge variant="success"></Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label> / </Label>
<Input
value={draft.displayName ?? ""}
onChange={(e) => updateDraft({ displayName: e.target.value })}
placeholder="給自己看的名稱"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={draft.username ?? ""}
onChange={(e) => updateDraft({ username: e.target.value })}
placeholder="@username"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
rows={3}
value={draft.brief ?? ""}
onChange={(e) => updateDraft({ brief: e.target.value })}
placeholder="這個帳號幫誰,用什麼觀點,解決什麼問題"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
rows={5}
value={draft.persona ?? ""}
onChange={(e) => updateDraft({ persona: e.target.value })}
placeholder="像誰、怎麼說話、常用語氣、哪些話不要說"
/>
</div>
</CardContent>
</Card>
<Card id="style-8d" className="scroll-mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2"><ScanText className="h-4 w-4 text-primary" /> 8D </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row">
<Input value={benchmarkUsername} onChange={(event) => setBenchmarkUsername(event.target.value)} placeholder="@對標帳號" />
<Button onClick={analyzeBenchmark} disabled={busy === "8d" || !!styleJob || !benchmarkUsername.trim()} className="sm:shrink-0">
{busy === "8d" || styleJob ? <Loader2 className="h-4 w-4 animate-spin" /> : <ScanText className="h-4 w-4" />}
{styleJob ? "8D 任務進行中" : busy === "8d" ? "建立任務中…" : "開始 8D 分析"}
</Button>
</div>
{styleJob && (
<div className="rounded-xl border border-primary/20 bg-primary/[0.035] p-3">
<p className="mb-2 text-xs font-medium"></p>
<JobProgressPanel
summary={styleJob.progress}
progressDetailRaw={styleJob.progressDetail}
jobType="style-8d"
/>
</div>
)}
{!styleResult && !styleJob && (
<InlineAlert
type="info"
title="尚未建立 8D 風格策略"
message="八個欄位已列在下方。你可以先手動填寫,或輸入對標帳號交給 AI 分析。"
/>
)}
{styleResult && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-4">
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 text-lg font-semibold">{styleResult.postCount} </p></div>
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.medianInteractions}</p></div>
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.averageInteractions}</p></div>
<div className="rounded-xl bg-muted/70 p-3"><p className="text-xs text-muted-foreground"> {styleResult.engagement.threshold} </p><p className="mt-1 text-lg font-semibold">{styleResult.engagement.postsAboveThreshold} </p></div>
</div>
<InlineAlert
type={styleResult.engagement.verdict === "strong" ? "success" : "warning"}
title={styleResult.engagement.verdict === "strong" ? "近期互動穩定,適合當對標" : styleResult.engagement.verdict === "unknown" ? "公開互動資料不足" : "可參考風格,但互動門檻不高"}
message="判斷採用近期貼文的讚 + 回覆×2公開頁沒有可靠瀏覽數時不會捏造。"
/>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="success"></Badge>
<span className="text-xs text-muted-foreground"> @{styleResult.username} · 調</span>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{STYLE_8D_KEYS.map((key) => {
const value = visibleStyle.analysis[key];
return (
<div key={key} className="rounded-xl border border-border bg-muted/40 p-4">
<Label className="text-xs font-semibold tracking-wider text-primary">{STYLE_8D_LABELS[key]}</Label>
<Textarea
rows={3}
className="mt-2 bg-card"
value={value?.summary ?? ""}
onChange={(event) => updateStyleDimension(key, event.target.value)}
placeholder={`等待 AI 產生${STYLE_8D_LABELS[key]},或先手動填寫`}
/>
{value?.evidence?.length ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{value.evidence.join("、")}</p> : null}
</div>
);
})}
</div>
{styleResult && (
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => updateDraft({ persona: styleResult.personaDraft })}></Button>
<p className="text-xs text-muted-foreground">8D 使</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Textarea
rows={5}
value={draft.targetAudience ?? ""}
onChange={(e) => updateDraft({ targetAudience: e.target.value })}
placeholder="想吸引誰、他們的痛點、渴望、常用語言"
/>
</div>
<div className="space-y-2">
<Label> / </Label>
<Textarea
rows={5}
value={draft.productBrief ?? ""}
onChange={(e) => updateDraft({ productBrief: e.target.value })}
placeholder="你提供什麼、解決什麼、自然導向哪個行動"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
rows={3}
value={draft.goals ?? ""}
onChange={(e) => updateDraft({ goals: e.target.value })}
placeholder="例如:提高留言、導流私訊、測試內容市場、建立專家感"
/>
</div>
</CardContent>
</Card>
</div>
)}
{draft && (
<div className="mobile-floating fixed right-4 z-30 flex max-w-[calc(100vw-2rem)] flex-col items-end gap-3 lg:right-5">
{assistantOpen && (
<Card className="w-[360px] max-w-full border-foreground/10 shadow-2xl">
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-foreground text-background shadow-sm">
<Sparkles className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 h-3 w-3 rounded-full border-2 border-card bg-success" />
</div>
<div>
<CardTitle className="text-[15px]"></CardTitle>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setAssistantOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 p-3">
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
{assistantMessages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={
message.role === "user"
? "ml-8 rounded-lg bg-foreground px-3 py-2 text-xs leading-relaxed text-background"
: "mr-8 rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed text-foreground"
}
>
{message.content}
</div>
))}
{assistantBusy && (
<div className="mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={assistantBusy}
onClick={() => askAssistant("我想經營醫療或健康相關帳號,請幫我補成合規、專業但親切的人設策略。")}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={assistantBusy}
onClick={() => askAssistant("請根據目前欄位,把語氣變得更像真人、更適合 Threads。")}
>
</Button>
</div>
<div className="flex gap-2">
<Input
ref={assistantInputRef}
value={assistantInput}
onChange={(event) => setAssistantInput(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
askAssistant();
}
}}
placeholder="例如:我想經營醫療衛教,受眾是上班族"
disabled={assistantBusy}
/>
<Button
type="button"
size="icon"
onClick={() => askAssistant()}
disabled={assistantBusy}
>
{assistantBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
8D
</p>
</CardContent>
</Card>
)}
<Button
className="h-12 gap-2 rounded-full px-5 shadow-xl"
onClick={() => setAssistantOpen((open) => !open)}
>
{assistantOpen ? <MessageCircle className="h-4 w-4" /> : <WandSparkles className="h-4 w-4" />}
{assistantOpen ? "收起小幫手" : "策略小幫手"}
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,507 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
AlertTriangle,
Bot,
Clock,
Loader2,
Play,
Power,
RefreshCw,
Square,
Zap,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { EmptyState } from "@/components/layout/empty-state";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/layout/page-header";
import { Switch } from "@/components/ui/switch";
import { useCapabilities } from "@/lib/capabilities/context";
import { useActionFeedback } from "@/lib/use-action-feedback";
import {
AUTOMATION_TASK_TYPES,
AUTOMATION_TASK_META,
OUTBOUND_TASKS,
type AutomationTaskType,
} from "@/lib/automation/types";
import { cn } from "@/lib/utils";
interface AutomationRule {
id: string;
taskType: string;
mode: string;
dailyCap: number;
schedule: string;
enabled: boolean;
lastRunAt?: string | null;
}
interface RulesData {
accountId: string;
accountName: string;
automationEnabled: boolean;
rules: AutomationRule[];
}
interface ActionLog {
id: string;
taskType: string;
action: string;
mode: string;
status: string;
detail?: string | null;
error?: string | null;
createdAt: string;
}
const flowLabels: Record<string, string> = {
A: "流程 A",
B: "流程 B",
both: "流程 A + B",
};
export default function AutomationPage() {
const [rulesData, setRulesData] = useState<RulesData | null>(null);
const [logs, setLogs] = useState<ActionLog[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [killOpen, setKillOpen] = useState(false);
const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback();
const { isReady } = useCapabilities();
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const [rulesRes, logsRes] = await Promise.all([
fetch("/api/automation/rules"),
fetch("/api/automation/logs"),
]);
const rulesData = await rulesRes.json();
const logsData = await logsRes.json();
if (rulesRes.ok) setRulesData(rulesData);
if (logsRes.ok) setLogs(logsData.logs ?? []);
} catch {
// ignore
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function toggleMaster(enabled: boolean) {
setBusy("master");
try {
const res = await fetch("/api/automation/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ automationEnabled: enabled }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "無法切換總開關", "操作失敗");
return;
}
setRulesData((prev) => (prev ? { ...prev, automationEnabled: enabled } : prev));
showSuccess(enabled ? "自動化已開啟" : "自動化已關閉");
} finally {
setBusy(null);
}
}
async function updateRule(taskType: AutomationTaskType, patch: Partial<AutomationRule>) {
setBusy(`rule-${taskType}`);
try {
const res = await fetch("/api/automation/rules", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskType, ...patch }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "無法更新規則", "儲存失敗");
return;
}
setRulesData((prev) => {
if (!prev) return prev;
const existing = prev.rules.find((r) => r.taskType === taskType);
const updated = data.rule ?? { ...existing, ...patch };
const others = prev.rules.filter((r) => r.taskType !== taskType);
return { ...prev, rules: [...others, updated] };
});
} finally {
setBusy(null);
}
}
async function runNow(taskType: AutomationTaskType) {
setBusy(`run-${taskType}`);
try {
const res = await fetch("/api/automation/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskType }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "執行失敗", "立即執行失敗");
return;
}
const result = data.result;
if (result?.ok) {
showSuccess(result.summary ?? "執行完成");
} else {
showWarning(result?.summary ?? "執行未完成", "執行結果");
}
load(true);
} finally {
setBusy(null);
}
}
async function killAll() {
setBusy("kill");
try {
const res = await fetch("/api/automation/kill", { method: "POST" });
const data = await res.json();
if (!res.ok) {
showError(data.error ?? "殺停失敗", "操作失敗");
return;
}
setRulesData((prev) => (prev ? { ...prev, automationEnabled: false } : prev));
showSuccess(`已緊急殺停 ${data.disabledAccounts ?? 0} 個帳號的自動化`);
} finally {
setBusy(null);
}
}
if (loading) {
return (
<div>
<PageHeader title="自動化排程" description="設定定時海巡、生成、發文與互動任務。" />
<div className="space-y-4">
<div className="skeleton h-32 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
</div>
</div>
);
}
if (!rulesData) {
return (
<div>
<PageHeader title="自動化排程" description="設定定時海巡、生成、發文與互動任務。" />
<EmptyState
icon={Bot}
title="無法載入自動化設定"
description="請確認已建立經營帳號後重試。"
action={<Button variant="outline" onClick={() => load()}></Button>}
/>
</div>
);
}
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="自動化排程"
description="設定定時海巡、生成、發文與互動任務。需另啟 workernpm run worker才會按排程執行。"
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="space-y-6">
{/* 總開關 + 緊急殺停 */}
<Card className={cn(rulesData.automationEnabled && "border-primary/30")}>
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-11 w-11 items-center justify-center rounded-xl",
rulesData.automationEnabled
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}
>
<Power className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">
<span className="ml-2 text-sm font-normal text-muted-foreground">
{rulesData.accountName}
</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">
{rulesData.automationEnabled
? "已開啟 — 符合排程的規則會自動執行"
: "已關閉 — 所有自動化任務暫停"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
checked={rulesData.automationEnabled}
onCheckedChange={(checked) => toggleMaster(checked)}
disabled={busy === "master"}
/>
<Button
variant="destructive"
size="sm"
onClick={() => setKillOpen(true)}
disabled={!rulesData.automationEnabled}
>
<Square className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
{/* 規則清單 */}
<div className="space-y-4">
{AUTOMATION_TASK_TYPES.map((taskType) => {
const meta = AUTOMATION_TASK_META[taskType];
const rule = rulesData.rules.find((r) => r.taskType === taskType);
const isOutbound = OUTBOUND_TASKS.includes(taskType);
const isAutoMode = rule?.mode === "auto";
const capReady =
taskType === "scan"
? isReady("scan")
: taskType === "generate"
? isReady("generate")
: taskType === "publish"
? isReady("publish")
: taskType === "outreach"
? isReady("outreach")
: isReady("ai");
return (
<Card key={taskType}>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
{meta.label}
<Badge variant="outline">{flowLabels[meta.flow]}</Badge>
{isOutbound && isAutoMode && rule?.enabled && (
<Badge variant="warning">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
)}
</CardTitle>
<CardDescription className="mt-1">{meta.description}</CardDescription>
</div>
<Switch
checked={rule?.enabled ?? false}
onCheckedChange={(checked) =>
updateRule(taskType, { enabled: checked })
}
disabled={busy === `rule-${taskType}`}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<div className="flex rounded-lg border border-border p-0.5">
<button
type="button"
onClick={() => updateRule(taskType, { mode: "manual" })}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
rule?.mode !== "auto"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
</button>
<button
type="button"
onClick={() => updateRule(taskType, { mode: "auto" })}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
rule?.mode === "auto"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
</button>
</div>
{isOutbound && isAutoMode && (
<p className="text-[11px] text-warning">
/
</p>
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
min={0}
max={500}
value={rule?.dailyCap ?? 0}
onChange={(e) =>
updateRule(taskType, {
dailyCap: Math.max(0, Math.min(500, parseInt(e.target.value, 10) || 0)),
})
}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>cron</Label>
<Input
value={rule?.schedule ?? ""}
onChange={(e) =>
updateRule(taskType, { schedule: e.target.value })
}
placeholder="0 9 * * *"
className="font-mono"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{rule?.lastRunAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(rule.lastRunAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
{!capReady && rule?.enabled && (
<span className="text-warning"></span>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => runNow(taskType)}
disabled={busy === `run-${taskType}` || !capReady}
>
{busy === `run-${taskType}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 執行日誌 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4" />
</CardTitle>
<CardDescription> 60 </CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => load(true)}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
{logs.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"></p>
) : (
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2 text-[13px]"
>
<Badge
variant={
log.status === "success"
? "success"
: log.status === "failed"
? "destructive"
: "secondary"
}
>
{log.status}
</Badge>
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="font-medium">
{AUTOMATION_TASK_META[log.taskType as AutomationTaskType]?.label ?? log.taskType}
</span>
<span className="ml-2 text-muted-foreground">{log.action}</span>
<span className="ml-2 text-muted-foreground">· {log.mode}</span>
</p>
{log.detail && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{log.detail}</p>
)}
{log.error && (
<p className="mt-0.5 text-xs text-destructive">{log.error}</p>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={killOpen}
onOpenChange={setKillOpen}
title="緊急殺停所有自動化"
description="這會立即關閉所有帳號的自動化總開關,所有排程任務都會暫停。"
confirmText="確認殺停"
danger
onConfirm={killAll}
/>
</div>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Loader2, PlugZap } from "lucide-react";
import { AccountConnectionCard } from "@/components/accounts/account-connection-card";
import { DeleteAccountCard } from "@/components/accounts/delete-account-card";
import { ExtensionSyncCard } from "@/components/settings/extension-sync-card";
import {
ThreadsConnectionSettings,
type ThreadsConnectionSettingsData,
} from "@/components/settings/threads-connection-settings";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { notify } from "@/lib/notifications/store";
import { parseFetchJson } from "@/lib/utils";
interface ConnectionPageData {
accountId: string | null;
accountName: string | null;
connection: ThreadsConnectionSettingsData | null;
}
export default function ConnectionsPage() {
const [data, setData] = useState<ConnectionPageData | null>(null);
const [draft, setDraft] = useState<ThreadsConnectionSettingsData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/accounts/connection");
const json = await parseFetchJson<ConnectionPageData & { error?: string }>(res);
if (!res.ok) {
throw new Error(json.error ?? "無法載入連線設定");
}
setData(json);
setDraft(json.connection ?? null);
} catch (error) {
setData(null);
setDraft(null);
notify({
type: "error",
title: "無法載入連線設定",
message: error instanceof Error ? error.message : "請稍後再試",
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
function patchConnection(patch: Partial<ThreadsConnectionSettingsData>) {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}
async function handleSave() {
if (!draft || !data?.accountId) return;
setSaving(true);
try {
const res = await fetch("/api/accounts/connection", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
});
const json = await parseFetchJson<{
connection?: ThreadsConnectionSettingsData;
error?: string;
}>(res);
if (!res.ok) {
throw new Error(json.error ?? "儲存失敗");
}
setData((prev) =>
prev
? {
...prev,
connection: json.connection ?? null,
}
: prev
);
setDraft(json.connection ?? null);
notify({ type: "success", title: "連線設定已儲存" });
} catch (error) {
notify({
type: "error",
title: "儲存失敗",
message: error instanceof Error ? error.message : "請稍後再試",
});
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div>
<PageHeader title="連線設定" description="管理 Threads 連線、Chrome 同步與資料來源。" />
<div className="space-y-4">
<div className="skeleton h-12 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
<div className="skeleton h-64 animate-pulse" />
</div>
</div>
);
}
if (!data) {
return (
<div>
<PageHeader title="連線設定" description="管理 Threads 連線、Chrome 同步與資料來源。" />
<EmptyState
icon={PlugZap}
title="無法載入連線設定"
description="請確認服務正常運作後重試。"
action={<Button variant="outline" onClick={load}></Button>}
/>
</div>
);
}
if (!data.accountId || !draft) {
return (
<div>
<PageHeader title="連線設定" description="管理 Threads 連線、Chrome 同步與資料來源。" />
<EmptyState
icon={PlugZap}
title="請先建立經營帳號"
description="在側欄的帳號切換器新增帳號後,即可設定連線。"
/>
</div>
);
}
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="連線設定"
description="管理 Threads 連線、Chrome Extension 同步與資料來源。依側欄帳號各自設定。"
action={
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <PlugZap className="h-4 w-4" />}
</Button>
}
/>
<div className="mb-5 flex items-center gap-2">
<Badge variant="success"></Badge>
<span className="text-sm font-medium">{data.accountName}</span>
</div>
<div className="space-y-5">
<ExtensionSyncCard />
<ThreadsConnectionSettings
settings={draft}
accountName={data.accountName}
onChange={patchConnection}
/>
<AccountConnectionCard />
<DeleteAccountCard
accountId={data.accountId}
accountName={data.accountName ?? "目前帳號"}
onDeleted={load}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { Bug, ExternalLink, RefreshCw } from "lucide-react";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface DebugRunSummary {
id: string;
label: string;
startedAt: string;
stepCount: number;
lastStep?: string | null;
}
interface DebugRunDetail {
run: {
id: string;
label: string;
startedAt: string;
steps: Array<{
step: string;
at: string;
url?: string;
screenshot?: string;
}>;
};
screenshots: string[];
}
export default function DebugPage() {
const [runs, setRuns] = useState<DebugRunSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<DebugRunDetail | null>(null);
const [loading, setLoading] = useState(true);
async function loadRuns() {
setLoading(true);
const res = await fetch("/api/debug/runs");
const data = await res.json();
const nextRuns = (data.runs ?? []) as DebugRunSummary[];
setRuns(nextRuns);
setSelectedId((current) => current ?? nextRuns[0]?.id ?? null);
setLoading(false);
}
useEffect(() => {
loadRuns();
}, []);
useEffect(() => {
if (!selectedId) {
setDetail(null);
return;
}
fetch(`/api/debug/runs/${selectedId}`)
.then((r) => r.json())
.then((d) => setDetail(d))
.catch(() => setDetail(null));
}, [selectedId]);
return (
<div>
<PageHeader
title="瀏覽器 Debug"
description="檢視 Threads 瀏覽器自動化的執行紀錄與截圖,用於排查發布或海巡問題。"
action={
<Button variant="outline" size="sm" onClick={loadRuns}>
<RefreshCw className="h-4 w-4" />
</Button>
}
/>
{loading ? (
<div className="skeleton h-48 animate-pulse" />
) : runs.length === 0 ? (
<EmptyState
icon={Bug}
title="尚無 Debug 紀錄"
description="設定開啟 Debug 後重試發布"
/>
) : (
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> 30 </CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
type="button"
onClick={() => setSelectedId(run.id)}
className={`w-full rounded-lg border px-3 py-2.5 text-left text-sm transition-colors ${
selectedId === run.id
? "border-foreground bg-muted"
: "border-border hover:bg-muted/60"
}`}
>
<p className="font-medium">{run.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{new Date(run.startedAt).toLocaleString("zh-TW")} · {run.stepCount}
</p>
{run.lastStep && (
<p className="mt-1 truncate text-xs text-muted-foreground">{run.lastStep}</p>
)}
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
{selectedId && (
<CardDescription className="font-mono text-xs">{selectedId}</CardDescription>
)}
</CardHeader>
<CardContent>
{!detail ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
{detail.run.steps.map((step) => (
<div key={`${step.at}-${step.step}`} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">{step.step}</p>
<p className="text-xs text-muted-foreground">
{new Date(step.at).toLocaleTimeString("zh-TW")}
</p>
</div>
{step.url && (
<a
href={step.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground underline"
>
<ExternalLink className="h-3 w-3" />
{step.url}
</a>
)}
{step.screenshot && (
<Image
src={`/api/debug/runs/${selectedId}/${step.screenshot}`}
alt={step.step}
width={1200}
height={800}
unoptimized
className="mt-3 max-h-[420px] w-full rounded-md border object-contain bg-background"
/>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,441 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
ExternalLink,
Loader2,
MessageSquare,
RefreshCw,
Send,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/notifications/store";
import { useCapabilities } from "@/lib/capabilities/context";
import { useActionFeedback } from "@/lib/use-action-feedback";
import { THREADS_MAX_CHARS } from "@/lib/utils";
interface ReplyDraft {
id: string;
text: string;
rationale?: string | null;
status: string;
publishedAt?: string | null;
createdAt: string;
}
interface InboundReply {
id: string;
text: string;
authorName?: string | null;
permalink?: string | null;
postedAt?: string | null;
likeCount?: number | null;
sentiment?: string | null;
intent?: string | null;
status: string;
createdAt: string;
published: {
id: string;
text: string;
permalink?: string | null;
publishedAt?: string | null;
} | null;
replyDrafts: ReplyDraft[];
}
const statusLabels: Record<string, { label: string; variant: "warning" | "secondary" | "success" }> = {
NEW: { label: "待處理", variant: "warning" },
DRAFTED: { label: "已生成草稿", variant: "secondary" },
REPLIED: { label: "已回覆", variant: "success" },
};
const sentimentLabels: Record<string, string> = {
positive: "正面",
neutral: "中性",
negative: "負面",
question: "提問",
lead: "潛在客戶",
};
export default function EngagementPage() {
const [replies, setReplies] = useState<InboundReply[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [draftTexts, setDraftTexts] = useState<Record<string, string>>({});
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
const { isReady } = useCapabilities();
const threadsApiReady = isReady("threadsApi");
const aiReady = isReady("ai");
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const res = await fetch("/api/engagement/replies");
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法載入留言", "載入失敗");
setReplies([]);
return;
}
setReplies(data.replies ?? []);
setDraftTexts(
Object.fromEntries(
(data.replies ?? []).flatMap((reply: InboundReply) =>
reply.replyDrafts.map((draft) => [draft.id, draft.text])
)
)
);
} catch {
showError("網路連線異常,請稍後再試", "載入失敗");
setReplies([]);
} finally {
if (!silent) setLoading(false);
}
}, [showError]);
useEffect(() => {
load();
}, [load]);
async function syncReplies() {
setBusy("sync");
try {
const res = await fetch("/api/engagement/replies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sync: true }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "同步失敗", "同步留言失敗");
return;
}
showSuccess("已從 Threads 同步留言");
load(true);
} catch {
showError("網路連線異常,請稍後再試", "同步留言失敗");
} finally {
setBusy(null);
}
}
async function generateDraft(replyId: string) {
setBusy(`gen-${replyId}`);
try {
const res = await fetch(`/api/engagement/replies/${replyId}/generate`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "生成回覆失敗", message: data.error });
return;
}
notify({ type: "success", title: "已生成回覆草稿" });
load(true);
} catch {
notify({ type: "error", title: "生成回覆失敗", message: "網路連線異常,請稍後再試" });
} finally {
setBusy(null);
}
}
async function saveDraft(draftId: string) {
setBusy(`save-${draftId}`);
try {
const res = await fetch(`/api/engagement/reply-drafts/${draftId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: draftTexts[draftId], status: "EDITED" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法儲存草稿", "儲存失敗");
return;
}
showSuccess("草稿已儲存");
load(true);
} catch {
showError("網路連線異常,請稍後再試", "儲存失敗");
} finally {
setBusy(null);
}
}
async function publishDraft(draftId: string) {
setBusy(`publish-${draftId}`);
try {
const res = await fetch(`/api/engagement/reply-drafts/${draftId}/publish`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "發布回覆失敗", message: data.error });
return;
}
notify({ type: "success", title: "回覆已發布到 Threads" });
load(true);
} catch {
notify({ type: "error", title: "發布回覆失敗", message: "網路連線異常,請稍後再試" });
} finally {
setBusy(null);
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
showSuccess("已複製回覆");
} catch {
showError("無法複製,請手動選取文字", "複製失敗");
}
}
const pendingCount = replies.filter((r) => r.status === "NEW").length;
return (
<div>
<PageHeader
eyebrow="03 / ENGAGEMENT"
title="互動經營"
description="同步 Threads 貼文底下的留言,用 AI 分析情緒並生成回覆草稿,再一鍵發布回覆。"
action={
<Button
size="lg"
onClick={syncReplies}
disabled={busy === "sync" || !threadsApiReady}
title={!threadsApiReady ? "需先在連線設定綁定 Threads API" : undefined}
>
{busy === "sync" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
}
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
{!threadsApiReady && (
<InlineAlert
type="info"
title="Threads API 尚未連線"
message="同步留言與發布回覆需要 Threads 官方 API。可先綁定後再使用或手動複製回覆至 Threads。"
className="mb-4"
/>
)}
{loading ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div key={i} className="skeleton h-56 animate-pulse" />
))}
</div>
) : replies.length === 0 ? (
<EmptyState
icon={MessageSquare}
title="尚無留言紀錄"
description={
threadsApiReady
? "點上方「同步留言」從 Threads 拉取最新留言。"
: "請先到連線設定綁定 Threads API再同步留言。"
}
action={
threadsApiReady ? (
<Button onClick={syncReplies} disabled={busy === "sync"}>
{busy === "sync" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
) : (
<Button asChild variant="outline">
<a href="/connections"></a>
</Button>
)
}
/>
) : (
<div className="space-y-4">
{pendingCount > 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="warning">{pendingCount} </Badge>
</div>
)}
{replies.map((reply) => {
const statusInfo = statusLabels[reply.status] ?? {
label: reply.status,
variant: "secondary" as const,
};
return (
<Card key={reply.id}>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2">
@{reply.authorName ?? "匿名"}
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
{reply.sentiment && (
<Badge variant="outline">
{sentimentLabels[reply.sentiment] ?? reply.sentiment}
</Badge>
)}
</CardTitle>
<CardDescription>
{reply.postedAt
? new Date(reply.postedAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: ""}
{reply.likeCount != null && ` · ${reply.likeCount}`}
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{reply.status === "NEW" && (
<Button
size="sm"
variant="outline"
onClick={() => generateDraft(reply.id)}
disabled={busy === `gen-${reply.id}` || !aiReady}
title={!aiReady ? "需先設定 AI API Key" : undefined}
>
{busy === `gen-${reply.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
</Button>
)}
{reply.permalink && (
<Button size="sm" variant="outline" asChild>
<a href={reply.permalink} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border bg-muted/50 p-3">
<p className="text-[13px] leading-relaxed">{reply.text}</p>
{reply.intent && (
<p className="mt-2 text-xs text-muted-foreground">{reply.intent}</p>
)}
</div>
{reply.published && (
<div className="rounded-lg border border-dashed border-border p-3">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
</p>
<p className="line-clamp-2 text-[13px] leading-relaxed text-muted-foreground">
{reply.published.text}
</p>
{reply.published.permalink && (
<a
href={reply.published.permalink}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
{reply.replyDrafts.map((draft) => {
const value = draftTexts[draft.id] ?? draft.text;
const isPublished = draft.status === "PUBLISHED";
return (
<div key={draft.id} className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap gap-1.5">
<Badge variant={isPublished ? "success" : "warning"}>
{isPublished ? "已發布" : "待發布"}
</Badge>
</div>
<span className="font-mono text-xs text-muted-foreground">
{value.length}/{THREADS_MAX_CHARS}
</span>
</div>
<Textarea
value={value}
rows={3}
onChange={(e) =>
setDraftTexts((prev) => ({ ...prev, [draft.id]: e.target.value }))
}
/>
{draft.rationale && (
<p className="text-xs text-muted-foreground">{draft.rationale}</p>
)}
<div className="flex flex-wrap gap-2">
{!isPublished && (
<>
<Button
size="sm"
variant="outline"
onClick={() => saveDraft(draft.id)}
disabled={busy === `save-${draft.id}`}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => copyText(value)}
>
</Button>
<Button
size="sm"
onClick={() => publishDraft(draft.id)}
disabled={
busy === `publish-${draft.id}` || !threadsApiReady
}
title={
!threadsApiReady
? "需先在連線設定綁定 Threads API"
: undefined
}
>
{busy === `publish-${draft.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
</>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,5 @@
import { DashboardShell } from "@/components/layout/dashboard-shell";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return <DashboardShell>{children}</DashboardShell>;
}

View File

@ -0,0 +1,324 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Puzzle, Radar, Sparkles, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PageHeader } from "@/components/layout/page-header";
import { DraftCard } from "@/components/draft-card";
import { notify } from "@/lib/notifications/store";
interface MatrixRow {
id: string;
sortOrder: number | null;
searchTag: string | null;
angle: string | null;
hook: string | null;
text: string;
referenceNotes: string | null;
sources: string | null;
imageBrief?: string | null;
imagePath?: string | null;
imagePaths?: string | null;
draftType?: string | null;
rationale?: string | null;
status: string;
createdAt: string;
}
interface AccountStatus {
id: string;
sessionSynced?: boolean;
browserConnected?: boolean;
}
const PAGE_SIZE = 10;
export default function MatrixPage() {
const [rows, setRows] = useState<MatrixRow[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [sessionSynced, setSessionSynced] = useState(false);
const [hasAccount, setHasAccount] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const load = useCallback(async (targetPage = page) => {
setLoading(true);
try {
const [matrixRes, accountsRes] = await Promise.all([
fetch(`/api/matrix?page=${targetPage}&limit=${PAGE_SIZE}`),
fetch("/api/accounts"),
]);
const [data, accountData] = await Promise.all([
matrixRes.json().catch(() => ({})),
accountsRes.json().catch(() => ({})),
]);
if (!matrixRes.ok) {
notify({
type: "error",
title: "載入草稿失敗",
message: data.error ?? "請稍後再試",
});
setRows([]);
setTotal(0);
setTotalPages(1);
return;
}
const accounts = (accountData.accounts ?? []) as AccountStatus[];
const active = accounts.find((account) => account.id === accountData.activeAccountId) ?? accounts[0];
const fetchedRows = data.rows ?? [];
const fetchedTotal = data.total ?? 0;
const fetchedTotalPages = data.totalPages ?? 1;
setRows(fetchedRows);
setTotal(fetchedTotal);
setTotalPages(fetchedTotalPages);
setHasAccount(Boolean(active));
setSessionSynced(Boolean(active?.sessionSynced || active?.browserConnected));
if (fetchedRows.length === 0 && targetPage > 1) {
setPage(1);
} else if (targetPage > fetchedTotalPages) {
setPage(fetchedTotalPages);
}
} catch {
notify({
type: "error",
title: "載入草稿失敗",
message: "網路連線異常,請稍後再試",
});
setRows([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
void load(page);
}, [page, load]);
useEffect(() => {
setSelectedIds(new Set());
}, [page]);
function goToPage(p: number) {
if (p < 1 || p > totalPages || p === page) return;
setPage(p);
}
function exitSelectMode() {
setSelectMode(false);
setSelectedIds(new Set());
}
function toggleDraftSelection(id: string, selected: boolean) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleSelectAllOnPage() {
const pageIds = rows.map((row) => row.id);
const allSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
setSelectedIds((prev) => {
const next = new Set(prev);
if (allSelected) {
for (const id of pageIds) next.delete(id);
} else {
for (const id of pageIds) next.add(id);
}
return next;
});
}
async function handleBulkDelete() {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBulkDeleting(true);
try {
const res = await fetch("/api/drafts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
const data = await res.json();
if (!res.ok) {
notify({
type: "error",
title: "批量刪除失敗",
message: data.error ?? "請稍後再試",
});
return;
}
notify({
type: "success",
title: "已刪除選取的草稿",
message: `共刪除 ${data.deleted ?? ids.length}`,
});
exitSelectMode();
await load(page);
} finally {
setBulkDeleting(false);
}
}
const pageIds = rows.map((row) => row.id);
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
const selectedCount = selectedIds.size;
return (
<div>
<PageHeader
eyebrow="01 / COPY NINJA"
title="拷貝忍者"
description="海巡 Threads 熱門素材,依你的風格產出可直接發布的貼文草稿。"
action={
<Button asChild size="lg">
<Link href="/scans?mode=viral"><Sparkles className="h-4 w-4" /></Link>
</Button>
}
/>
<div className="mb-6 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">
{total > 0 ? `${total} 篇 · 第 ${page}/${totalPages}` : "完成第一個風格任務後會出現在這裡"}
</p>
</div>
{total > 0 && (
<div className="flex flex-wrap items-center gap-2">
{selectMode ? (
<>
<Button variant="outline" size="sm" onClick={toggleSelectAllOnPage}>
{allOnPageSelected ? "取消全選" : "全選本頁"}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmBulkDelete(true)}
disabled={selectedCount === 0 || bulkDeleting}
>
<Trash2 className="h-4 w-4" />
{bulkDeleting ? "刪除中…" : `刪除${selectedCount > 0 ? ` (${selectedCount})` : ""}`}
</Button>
<Button variant="ghost" size="sm" onClick={exitSelectMode} disabled={bulkDeleting}>
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => setSelectMode(true)}>
<Trash2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" asChild>
<a href="/api/matrix/export" download><Download className="h-4 w-4" /></a>
</Button>
</>
)}
</div>
)}
</div>
{loading ? (
<div className="space-y-6">
{[0, 1, 2].map((i) => (
<div key={i} className="skeleton h-48 animate-pulse" />
))}
</div>
) : rows.length === 0 ? (
<Card className="overflow-hidden border-dashed">
<CardContent className="flex min-h-64 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{sessionSynced ? <Radar className="h-6 w-6" /> : <Puzzle className="h-6 w-6" />}
</div>
<h3 className="text-lg font-semibold">
{sessionSynced ? "Extension 已同步,可以開始海巡" : hasAccount ? "先同步你的 Threads Extension" : "先建立經營帳號"}
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
{sessionSynced
? "目前帳號的 Threads Session 已就緒。新增海巡任務,設定主題後開始分析與抓取。"
: hasAccount
? "登入 Threads 後按 Extension 同步;完成後這裡會自動辨識帳號狀態。"
: "建立帳號並填好人設,再透過 Extension 同步 Threads Session。"}
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{sessionSynced ? (
<Button asChild><Link href="/scans?mode=viral"></Link></Button>
) : hasAccount ? (
<Button asChild><Link href="/connections"> Extension</Link></Button>
) : (
<Button asChild><Link href="/connections"></Link></Button>
)}
</div>
</CardContent>
</Card>
) : (
<>
<div className="divide-y divide-border">
{rows.map((row, index) => (
<DraftCard
key={row.id}
draft={row}
onUpdate={() => load(page)}
index={index}
selectable={selectMode}
selected={selectedIds.has(row.id)}
onSelectedChange={(selected) => toggleDraftSelection(row.id, selected)}
/>
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => goToPage(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => goToPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
<ConfirmDialog
open={confirmBulkDelete}
onOpenChange={setConfirmBulkDelete}
title="批量刪除草稿"
description={`確定要刪除選取的 ${selectedCount} 篇草稿?此操作無法復原。`}
confirmText="刪除"
danger
onConfirm={handleBulkDelete}
/>
</div>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { Bell, CheckCheck, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { ActiveJobsPanel } from "@/components/layout/active-jobs-panel";
import { useJobs } from "@/components/layout/jobs-provider";
import { useNotifications } from "@/lib/notifications/use-notifications";
import type { NotificationType } from "@/lib/notifications/store";
import { cn } from "@/lib/utils";
const typeStyle: Record<NotificationType, string> = {
success: "border-success-border bg-success-bg",
error: "border-danger-border bg-danger-bg",
warning: "border-warning-border bg-warning-bg",
info: "border-border bg-muted",
};
const typeLabel: Record<NotificationType, string> = {
success: "成功",
error: "失敗",
warning: "提醒",
info: "訊息",
};
export default function NotificationsPage() {
const { activeJobs } = useJobs();
const { notifications, unreadCount, markRead, markAllRead, clearAll, removeNotification } =
useNotifications();
useEffect(() => {
markAllRead();
}, [markAllRead]);
return (
<div>
<PageHeader
title="通知"
description="背景任務完成、發布結果與系統提醒都會記錄在這裡。"
action={
notifications.length > 0 ? (
<div className="flex gap-2">
{unreadCount > 0 && (
<Button size="sm" variant="outline" onClick={markAllRead}>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
)}
<Button size="sm" variant="ghost" onClick={clearAll}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : undefined
}
/>
<ActiveJobsPanel />
{notifications.length === 0 && activeJobs.length === 0 ? (
<EmptyState
icon={Bell}
title="尚無通知"
description="完成海巡、生成草稿或發布貼文後,相關結果會顯示在這裡。"
/>
) : notifications.length === 0 ? null : (
<div className="space-y-2">
<p className="mb-2 text-[13px] font-medium text-muted-foreground"></p>
{notifications.map((item) => (
<Card
key={item.id}
className={cn(
"transition-opacity",
typeStyle[item.type],
!item.read && "ring-1 ring-foreground/10"
)}
>
<CardContent className="flex items-start gap-3 p-3.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{!item.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-foreground" />
)}
<p className="text-[14px] font-medium">{item.title}</p>
<Badge variant="outline" className="text-[10px]">
{typeLabel[item.type]}
</Badge>
</div>
{item.message && (
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
{item.message}
</p>
)}
<p className="mt-1.5 text-[11px] text-muted-foreground">
{new Date(item.createdAt).toLocaleString("zh-TW")}
</p>
{item.href && (
<Link
href={item.href}
className="mt-2 inline-block text-[12px] underline"
onClick={() => markRead(item.id)}
>
</Link>
)}
</div>
<div className="flex shrink-0 flex-col gap-1">
{!item.read && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[11px]"
onClick={() => markRead(item.id)}
>
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[11px] text-muted-foreground"
onClick={() => removeNotification(item.id)}
>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,625 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Copy, ExternalLink, Loader2, RefreshCw, Radar, ScanSearch, Send, Target, Trash2 } from "lucide-react";
import { ProductContextForm } from "@/components/product-context-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/notifications/store";
import { useCapabilities } from "@/lib/capabilities/context";
import { useActionFeedback } from "@/lib/use-action-feedback";
import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context";
import { isPlacementGoal } from "@/lib/types/topic-goal";
import { parseFetchJson, THREADS_MAX_CHARS } from "@/lib/utils";
interface OutreachDraft {
id: string;
text: string;
angle?: string | null;
rationale?: string | null;
status: string;
}
interface OutreachTarget {
id: string;
status: string;
relevance?: number | null;
reason?: string | null;
scanItem: {
id: string;
text: string;
authorName?: string | null;
permalink?: string | null;
likeCount?: number | null;
replyCount?: number | null;
placementReason?: string | null;
placementScore?: number | null;
scan: {
topic: {
id: string;
label: string;
topicGoal?: string | null;
productContext?: string | null;
brief?: string | null;
};
scanGoal?: string | null;
};
};
drafts: OutreachDraft[];
}
interface PlacementTopic {
id: string;
label: string;
query: string;
topicGoal: string;
scanCount: number;
latestScan: {
id: string;
createdAt: string;
itemCount: number;
} | null;
}
const PAGE_SIZE = 10;
export default function OutreachPage() {
const [targets, setTargets] = useState<OutreachTarget[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [selectMode, setSelectMode] = useState(false);
const [selectedDraftIds, setSelectedDraftIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [placementTopics, setPlacementTopics] = useState<PlacementTopic[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [draftText, setDraftText] = useState<Record<string, string>>({});
const [productDrafts, setProductDrafts] = useState<Record<string, string>>({});
const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback();
const { isReady } = useCapabilities();
const threadsApiReady = isReady("threadsApi");
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const [outreachRes, topicsRes] = await Promise.all([
fetch(`/api/outreach?page=${page}&limit=${PAGE_SIZE}`),
fetch("/api/topics"),
]);
const [data, topicsData] = await Promise.all([
parseFetchJson<{ targets?: OutreachTarget[]; total?: number; totalPages?: number; error?: string }>(outreachRes),
parseFetchJson<{ topics?: PlacementTopic[]; error?: string }>(topicsRes),
]);
if (!outreachRes.ok) {
showError(data.error ?? "無法載入受眾名單", "載入失敗");
setTargets([]);
setTotal(0);
setTotalPages(1);
return;
}
const rows = data.targets ?? [];
setTargets(rows);
setTotal(data.total ?? rows.length);
setTotalPages(data.totalPages ?? 1);
if (page > (data.totalPages ?? 1)) setPage(Math.max(1, data.totalPages ?? 1));
setPlacementTopics((topicsData.topics ?? []).filter((t) => isPlacementGoal(t.topicGoal)));
setDraftText(
Object.fromEntries(
rows.flatMap((target: OutreachTarget) => target.drafts.map((draft) => [draft.id, draft.text]))
)
);
setProductDrafts((prev) => {
const next = { ...prev };
for (const target of rows as OutreachTarget[]) {
const topic = target.scanItem.scan.topic;
if (next[topic.id] === undefined) {
next[topic.id] = topic.productContext ?? "";
}
}
return next;
});
} catch {
showError("網路連線異常,請稍後再試", "載入失敗");
setTargets([]);
setTotal(0);
setTotalPages(1);
} finally {
if (!silent) setLoading(false);
}
}, [page, showError]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
setSelectedDraftIds(new Set());
}, [page]);
async function saveDraft(draftId: string) {
setBusy(`save-${draftId}`);
try {
const res = await fetch(`/api/outreach/drafts/${draftId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: draftText[draftId], status: "EDITED" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法儲存草稿", "儲存失敗");
return;
}
showSuccess("草稿已儲存");
load(true);
} catch {
showError("網路連線異常,請稍後再試", "儲存失敗");
} finally {
setBusy(null);
}
}
async function deleteDraft(draft: OutreachDraft) {
if (!confirm("確定刪除這則留言草稿?刪除後無法復原。")) return;
setBusy(`delete-${draft.id}`);
try {
const res = await fetch(`/api/outreach/drafts/${draft.id}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法刪除留言草稿", "刪除失敗");
return;
}
setDraftText((prev) => {
const next = { ...prev };
delete next[draft.id];
return next;
});
showSuccess("留言草稿已刪除");
load(true);
} catch {
showError("網路連線異常,請稍後再試", "刪除失敗");
} finally {
setBusy(null);
}
}
function toggleDraftSelection(id: string) {
setSelectedDraftIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function exitSelectMode() {
setSelectMode(false);
setSelectedDraftIds(new Set());
}
async function deleteSelectedDrafts() {
const ids = [...selectedDraftIds];
if (ids.length === 0) return;
if (!confirm(`確定刪除選取的 ${ids.length} 則留言草稿?刪除後無法復原。`)) return;
setBatchDeleting(true);
try {
const res = await fetch("/api/outreach/drafts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法批次刪除留言草稿", "批次刪除失敗");
return;
}
showSuccess(`已刪除 ${data.deleted ?? ids.length} 則留言草稿`);
exitSelectMode();
await load(true);
} catch {
showError("網路連線異常,請稍後再試", "批次刪除失敗");
} finally {
setBatchDeleting(false);
}
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
showSuccess("已複製留言");
}
const placementTopicId = targets.find((t) =>
isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal)
)?.scanItem.scan.topic.id;
async function saveProductContext(topicId: string) {
setBusy(`product-${topicId}`);
try {
const res = await fetch("/api/topics", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: topicId, productContext: productDrafts[topicId] ?? "" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法儲存品牌與產品", "儲存失敗");
return false;
}
showSuccess("品牌與產品已儲存");
load(true);
return true;
} catch {
showError("網路連線異常,請稍後再試", "儲存失敗");
return false;
} finally {
setBusy(null);
}
}
async function regenerateTarget(target: OutreachTarget) {
const topicId = target.scanItem.scan.topic.id;
const productContext = productDrafts[topicId] ?? "";
if (!hasProductContext(productContext)) {
showWarning("在上方區塊填寫品牌與產品後,再重新生成。", "請先填寫品牌與產品");
return;
}
setBusy(`regen-${target.id}`);
try {
const res = await fetch("/api/outreach/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scanItemId: target.scanItem.id,
productContext,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "重新生成失敗", message: data.error });
return;
}
notify({ type: "success", title: "已重新生成回覆草稿" });
load(true);
} catch {
notify({ type: "error", title: "重新生成失敗", message: "網路連線異常,請稍後再試" });
} finally {
setBusy(null);
}
}
async function publishDraft(draft: OutreachDraft) {
setBusy(`publish-${draft.id}`);
try {
const res = await fetch(`/api/outreach/drafts/${draft.id}/publish`, { method: "POST" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "發布留言失敗", message: data.error });
return;
}
notify({ type: "success", title: "留言已發布到 Threads" });
load(true);
} catch {
notify({ type: "error", title: "發布留言失敗", message: "網路連線異常,請稍後再試" });
} finally {
setBusy(null);
}
}
const hasPlacementTargets = targets.some((t) =>
isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal)
);
const deletableDraftIds = targets.flatMap((target) =>
target.drafts.filter((draft) => draft.status !== "PUBLISHED").map((draft) => draft.id)
);
const allPageDraftsSelected =
deletableDraftIds.length > 0 && deletableDraftIds.every((id) => selectedDraftIds.has(id));
function toggleAllPageDrafts() {
setSelectedDraftIds((prev) => {
const next = new Set(prev);
if (allPageDraftsSelected) deletableDraftIds.forEach((id) => next.delete(id));
else deletableDraftIds.forEach((id) => next.add(id));
return next;
});
}
return (
<div>
<PageHeader
eyebrow="02 / FIND YOUR TA"
title="找出正在需要你的人"
description="從 Threads 訊號辨識高意向受眾,再套入你的人設與產品脈絡,產出自然、不硬銷的開場話術。"
action={<Button asChild size="lg"><Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link></Button>}
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
{!loading && targets.length > 0 && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<p className="text-xs text-muted-foreground"> {total} · {page}/{totalPages} </p>
<div className="flex flex-wrap gap-2">
{selectMode ? (
<>
<Button size="sm" variant="outline" onClick={toggleAllPageDrafts} disabled={deletableDraftIds.length === 0}>
{allPageDraftsSelected ? "取消全選" : "全選本頁"}
</Button>
<Button size="sm" variant="outline" onClick={deleteSelectedDrafts} disabled={selectedDraftIds.size === 0 || batchDeleting}>
{batchDeleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
{selectedDraftIds.size}
</Button>
<Button size="sm" variant="ghost" onClick={exitSelectMode}></Button>
</>
) : (
<Button size="sm" variant="outline" onClick={() => setSelectMode(true)}>
</Button>
)}
</div>
</div>
)}
{loading ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => <div key={i} className="skeleton h-56 animate-pulse" />)}
</div>
) : targets.length === 0 ? (
placementTopics.length > 0 ? (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{placementTopics.length} TA
</div>
<div className="grid gap-4 sm:grid-cols-2">
{placementTopics.map((topic) => (
<Card key={topic.id}>
<CardHeader className="pb-3">
<CardTitle className="truncate">{topic.label}</CardTitle>
<CardDescription className="truncate">{topic.query}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{topic.latestScan ? (
<p className="text-[13px] text-muted-foreground">
{new Date(topic.latestScan.createdAt).toLocaleDateString("zh-TW", { month: "short", day: "numeric" })}
{" · "}
{topic.latestScan.itemCount}
</p>
) : (
<p className="text-[13px] text-muted-foreground"></p>
)}
<div className="flex flex-wrap gap-2">
{topic.latestScan ? (
<Button size="sm" asChild>
<Link href={`/scans/${topic.id}/results`}>
<ScanSearch className="h-3.5 w-3.5" />
</Link>
</Button>
) : (
<Button size="sm" variant="outline" asChild>
<Link href={`/scans/${topic.id}`}>
<ScanSearch className="h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-center pt-2">
<Button asChild variant="outline" size="sm">
<Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link>
</Button>
</div>
</div>
) : (
<EmptyState
icon={Target}
title="還沒有高意向受眾"
description="建立找 TA 主題並海巡後AI 會從中辨識高意向受眾並生成開場話術。"
action={<Button asChild><Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link></Button>}
/>
)
) : (
<div className="space-y-4">
{hasPlacementTargets && placementTopicId && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
調
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ProductContextForm
compact
value={productDrafts[placementTopicId] ?? ""}
onChange={(value) =>
setProductDrafts((prev) => ({ ...prev, [placementTopicId]: value }))
}
/>
<Button
size="sm"
variant="outline"
disabled={busy === `product-${placementTopicId}`}
onClick={() => saveProductContext(placementTopicId)}
>
{busy === `product-${placementTopicId}` ? "儲存中…" : "儲存品牌與產品"}
</Button>
</CardContent>
</Card>
)}
{targets.map((target) => {
const isPlacement = isPlacementGoal(
target.scanItem.scan.scanGoal ?? target.scanItem.scan.topic.topicGoal
);
const productSummary = summarizeProductContext(
productDrafts[target.scanItem.scan.topic.id] ?? target.scanItem.scan.topic.productContext
);
return (
<Card key={target.id}>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>
@{target.scanItem.authorName ?? "匿名"} · {target.scanItem.scan.topic.label}
</CardTitle>
<CardDescription>
{target.scanItem.likeCount ?? 0} / {target.scanItem.replyCount ?? 0}
</CardDescription>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant={target.status === "LOW_RELEVANCE" ? "warning" : "success"}>
{Math.round((target.relevance ?? 0) * 100)}%
</Badge>
<Badge variant="outline">{target.status}</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
{isPlacement && (
<Button
size="sm"
variant="outline"
disabled={busy === `regen-${target.id}`}
onClick={() => regenerateTarget(target)}
>
{busy === `regen-${target.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
{target.scanItem.permalink && (
<Button size="sm" variant="outline" asChild>
<a href={target.scanItem.permalink} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border bg-muted/50 p-3">
<p className="text-[13px] leading-relaxed">{target.scanItem.text}</p>
{productSummary && (
<p className="mt-2 text-xs text-muted-foreground">{productSummary}</p>
)}
{(target.scanItem.placementReason || target.reason) && (
<p className="mt-2 text-xs text-muted-foreground">
{target.scanItem.placementReason ?? target.reason}
</p>
)}
</div>
{target.drafts.map((draft) => {
const value = draftText[draft.id] ?? draft.text;
return (
<div key={draft.id} className="relative space-y-2 rounded-lg border border-border p-3">
{selectMode && draft.status !== "PUBLISHED" && (
<button
type="button"
aria-label="選取留言草稿"
onClick={() => toggleDraftSelection(draft.id)}
className={`absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded border text-xs ${
selectedDraftIds.has(draft.id)
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background"
}`}
>
{selectedDraftIds.has(draft.id) ? "✓" : ""}
</button>
)}
<div className="flex items-center justify-between gap-2">
<div className={`flex flex-wrap gap-1.5 ${selectMode ? "pr-8" : ""}`}>
<Badge variant={draft.status === "PUBLISHED" ? "success" : "warning"}>{draft.status}</Badge>
{draft.angle && <Badge variant="outline">{draft.angle}</Badge>}
</div>
<span className="text-xs text-muted-foreground">{value.length}/{THREADS_MAX_CHARS}</span>
</div>
<Textarea
value={value}
rows={3}
onChange={(e) => setDraftText((prev) => ({ ...prev, [draft.id]: e.target.value }))}
/>
{draft.rationale && <p className="text-xs text-muted-foreground">{draft.rationale}</p>}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => saveDraft(draft.id)} disabled={busy === `save-${draft.id}`}>
</Button>
<Button size="sm" variant="outline" onClick={() => copyText(value)}>
<Copy className="h-3.5 w-3.5" />
</Button>
{draft.status !== "PUBLISHED" && (
<Button
size="sm"
variant="outline"
onClick={() => deleteDraft(draft)}
disabled={busy === `delete-${draft.id}`}
>
{busy === `delete-${draft.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
{draft.status !== "PUBLISHED" && (
<Button
size="sm"
onClick={() => publishDraft(draft)}
disabled={busy === `publish-${draft.id}` || !threadsApiReady}
title={!threadsApiReady ? "需先在連線設定綁定 Threads API" : undefined}
>
{busy === `publish-${draft.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
})}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 pt-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground"> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)}
</div>
);
}

5
app/(dashboard)/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/matrix");
}

View File

@ -0,0 +1,441 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Package, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react";
import { BrandFormDialog, type BrandRow } from "@/components/product-profile/brand-form-dialog";
import {
ProductItemFormDialog,
type ProductItemRow,
} from "@/components/product-profile/product-item-form-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { InlineAlert } from "@/components/ui/inline-alert";
import { Input } from "@/components/ui/input";
import { summarizeProductContext } from "@/lib/types/product-context";
import { useActionFeedback } from "@/lib/use-action-feedback";
import { parseFetchJson } from "@/lib/utils";
const BRANDS_PER_PAGE = 8;
const PRODUCTS_PREVIEW = 6;
interface BrandWithProducts extends BrandRow {
products: ProductItemRow[];
productCount?: number;
}
interface BrandsResponse {
brands?: BrandWithProducts[];
total?: number;
page?: number;
totalPages?: number;
error?: string;
}
export default function ProductsPage() {
const [brands, setBrands] = useState<BrandWithProducts[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [expandedBrands, setExpandedBrands] = useState<Set<string>>(new Set());
const [fullProducts, setFullProducts] = useState<Record<string, ProductItemRow[]>>({});
const [loadingProducts, setLoadingProducts] = useState<string | null>(null);
const [brandDialogOpen, setBrandDialogOpen] = useState(false);
const [editingBrand, setEditingBrand] = useState<BrandRow | null>(null);
const [productDialogOpen, setProductDialogOpen] = useState(false);
const [productBrand, setProductBrand] = useState<BrandWithProducts | null>(null);
const [editingProduct, setEditingProduct] = useState<ProductItemRow | null>(null);
const [deletingBrandId, setDeletingBrandId] = useState<string | null>(null);
const [deletingProductId, setDeletingProductId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
title: string;
description?: string;
onConfirm: () => void | Promise<void>;
} | null>(null);
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
const load = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({
page: String(page),
limit: String(BRANDS_PER_PAGE),
productLimit: "12",
});
if (searchQuery) params.set("q", searchQuery);
const res = await fetch(`/api/brands?${params}`);
try {
const data = await parseFetchJson<BrandsResponse>(res);
if (!res.ok) {
showError(data.error ?? "無法載入品牌列表", "讀取失敗");
setBrands([]);
setTotal(0);
setTotalPages(1);
} else {
setBrands(data.brands ?? []);
setTotal(data.total ?? 0);
setTotalPages(data.totalPages ?? 1);
}
} catch (err) {
showError(err instanceof Error ? err.message : "伺服器回應異常", "讀取失敗");
setBrands([]);
setTotal(0);
setTotalPages(1);
}
setLoading(false);
}, [page, searchQuery, showError]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
const timer = window.setTimeout(() => {
setSearchQuery(searchInput.trim());
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [searchInput]);
const summaryText = useMemo(() => {
if (total === 0) return searchQuery ? "沒有符合的品牌或產品" : "尚無品牌";
if (searchQuery) return `找到 ${total} 個品牌`;
return `${total} 個品牌`;
}, [total, searchQuery]);
function openCreateBrand() {
setEditingBrand(null);
setBrandDialogOpen(true);
}
function openEditBrand(brand: BrandRow) {
setEditingBrand(brand);
setBrandDialogOpen(true);
}
function openCreateProduct(brand: BrandWithProducts) {
setProductBrand(brand);
setEditingProduct(null);
setProductDialogOpen(true);
}
function openEditProduct(brand: BrandWithProducts, product: ProductItemRow) {
setProductBrand(brand);
setEditingProduct(product);
setProductDialogOpen(true);
}
async function loadAllProducts(brandId: string) {
setLoadingProducts(brandId);
const res = await fetch(`/api/brands?brandId=${brandId}&productLimit=200`);
try {
const data = await parseFetchJson<{ brand?: BrandWithProducts; error?: string }>(res);
if (!res.ok || !data.brand) {
showError(data.error ?? "無法載入產品列表", "讀取失敗");
return;
}
setFullProducts((prev) => ({ ...prev, [brandId]: data.brand!.products }));
setExpandedBrands((prev) => new Set(prev).add(brandId));
} catch (err) {
showError(err instanceof Error ? err.message : "伺服器回應異常", "讀取失敗");
} finally {
setLoadingProducts(null);
}
}
function getVisibleProducts(brand: BrandWithProducts) {
const all = fullProducts[brand.id] ?? brand.products;
const expanded = expandedBrands.has(brand.id);
if (expanded || all.length <= PRODUCTS_PREVIEW) return all;
return all.slice(0, PRODUCTS_PREVIEW);
}
function hasMoreProducts(brand: BrandWithProducts) {
const count = brand.productCount ?? brand.products.length;
const loaded = fullProducts[brand.id] ?? brand.products;
if (expandedBrands.has(brand.id)) return false;
return count > loaded.length || loaded.length > PRODUCTS_PREVIEW;
}
async function handleDeleteBrand(id: string) {
setConfirmDialog({
title: "刪除品牌",
description: "確定刪除此品牌?若有主題正在使用將無法刪除。",
onConfirm: async () => {
setDeletingBrandId(id);
const res = await fetch(`/api/brands?id=${id}`, { method: "DELETE" });
const data = await res.json();
setDeletingBrandId(null);
if (!res.ok) {
showError(data.error ?? "無法刪除品牌", "刪除失敗");
return;
}
showSuccess("品牌已刪除");
if (brands.length === 1 && page > 1) {
setPage((p) => p - 1);
} else {
load();
}
},
});
}
async function handleDeleteProduct(id: string) {
setConfirmDialog({
title: "刪除產品",
description: "確定刪除此產品?若有主題指定此產品將無法刪除。",
onConfirm: async () => {
setDeletingProductId(id);
const res = await fetch(`/api/brands/products?id=${id}`, { method: "DELETE" });
const data = await res.json();
setDeletingProductId(null);
if (!res.ok) {
showError(data.error ?? "無法刪除產品", "刪除失敗");
return;
}
setFullProducts({});
setExpandedBrands(new Set());
load();
showSuccess("產品已刪除");
},
});
}
return (
<div>
<PageHeader
title="品牌與產品"
description="一個品牌可有多個產品;置入時 AI 會依海巡標籤自動推薦對應產品"
action={
<Button size="sm" onClick={openCreateBrand}>
<Plus className="h-4 w-4" />
</Button>
}
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜尋品牌、產品或標籤…"
className="pl-9"
/>
</div>
<p className="text-[13px] text-muted-foreground">{summaryText}</p>
</div>
{loading ? (
<div className="skeleton h-40 animate-pulse" />
) : brands.length === 0 ? (
<EmptyState
icon={Package}
title={searchQuery ? "找不到符合的結果" : "尚無品牌與產品"}
description={
searchQuery
? "試試其他關鍵字,或清除搜尋建立新品牌"
: "先建立品牌,再新增各項產品與適用標籤"
}
action={
searchQuery ? (
<Button size="sm" variant="outline" onClick={() => setSearchInput("")}>
</Button>
) : (
<Button size="sm" onClick={openCreateBrand}>
</Button>
)
}
/>
) : (
<>
<div className="space-y-4">
{brands.map((brand) => {
const visibleProducts = getVisibleProducts(brand);
const productTotal = brand.productCount ?? brand.products.length;
return (
<Card key={brand.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{brand.name}</CardTitle>
{brand.notes && (
<CardDescription className="mt-1">{brand.notes}</CardDescription>
)}
<p className="mt-1 text-[11px] text-muted-foreground">
{productTotal}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button size="sm" variant="outline" onClick={() => openEditBrand(brand)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={deletingBrandId === brand.id}
onClick={() => handleDeleteBrand(brand.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{visibleProducts.length === 0 ? (
<p className="text-[13px] text-muted-foreground"></p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{visibleProducts.map((product) => {
const summary = summarizeProductContext(product.context);
return (
<li
key={product.id}
className="flex items-start justify-between gap-3 px-3 py-2.5"
>
<div className="min-w-0">
<p className="text-[13px] font-medium">{product.label}</p>
{summary && (
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
{summary}
</p>
)}
{product.matchTags.length > 0 && (
<p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
<Tag className="h-3 w-3 shrink-0" />
{product.matchTags.join("、")}
</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => openEditProduct(brand, product)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={deletingProductId === product.id}
onClick={() => handleDeleteProduct(product.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</li>
);
})}
</ul>
)}
{hasMoreProducts(brand) && (
<Button
size="sm"
variant="ghost"
disabled={loadingProducts === brand.id}
onClick={() => loadAllProducts(brand.id)}
>
{loadingProducts === brand.id
? "載入中…"
: `顯示全部 ${productTotal} 個產品`}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => openCreateProduct(brand)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
);
})}
</div>
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
<p className="text-[13px] text-muted-foreground">
{page} / {totalPages}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
<BrandFormDialog
open={brandDialogOpen}
onOpenChange={setBrandDialogOpen}
brand={editingBrand}
onSaved={() => {
setFullProducts({});
setExpandedBrands(new Set());
load();
}}
/>
{productBrand && (
<ProductItemFormDialog
open={productDialogOpen}
onOpenChange={setProductDialogOpen}
brandId={productBrand.id}
brandName={productBrand.name}
product={editingProduct}
onSaved={() => {
setFullProducts({});
setExpandedBrands(new Set());
load();
}}
/>
)}
<ConfirmDialog
open={confirmDialog !== null}
onOpenChange={(open) => !open && setConfirmDialog(null)}
title={confirmDialog?.title ?? ""}
description={confirmDialog?.description}
confirmText="確認刪除"
danger
onConfirm={confirmDialog?.onConfirm ?? (() => {})}
/>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import { ExternalLink, Send } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { notify } from "@/lib/notifications/store";
interface PublishedItem {
id: string;
text: string;
angle?: string | null;
hook?: string | null;
permalink?: string | null;
publishedAt: string;
views?: number | null;
likes?: number | null;
replies?: number | null;
}
export default function PublishedPage() {
const [items, setItems] = useState<PublishedItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/published")
.then(async (r) => {
const data = await r.json();
if (!r.ok) {
throw new Error(data.error ?? "無法載入已發布貼文");
}
setItems(data.published ?? []);
})
.catch((error) => {
notify({
type: "error",
title: "載入失敗",
message: error instanceof Error ? error.message : "請稍後再試",
});
})
.finally(() => setLoading(false));
}, []);
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="成效紀錄"
description="查看已發布到 Threads 的貼文,追蹤瀏覽與互動數據。"
/>
{loading ? (
<div className="space-y-4">
{[0, 1].map((i) => (
<div key={i} className="skeleton h-32 animate-pulse" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
icon={Send}
title="尚無已發布貼文"
description="完成海巡與草稿審核後,發布的貼文會出現在這裡。"
/>
) : (
<div className="space-y-4">
{items.map((item, i) => (
<Card
key={item.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 50}ms` }}
>
<CardHeader>
<CardTitle>{item.angle ?? "已發布貼文"}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-3">
<span>{new Date(item.publishedAt).toLocaleString("zh-TW")}</span>
{item.permalink && (
<a
href={item.permalink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</CardDescription>
</CardHeader>
<CardContent>
{item.hook && (
<p className="mb-2 text-[14px] font-medium leading-snug">{item.hook}</p>
)}
<p className="whitespace-pre-wrap text-[15px] leading-[1.7] text-foreground/90">
{item.text}
</p>
{(item.views != null || item.likes != null || item.replies != null) && (
<div className="mt-4 flex flex-wrap gap-4 border-t border-border pt-3 text-sm text-muted-foreground">
{item.views != null && <span> {item.views}</span>}
{item.likes != null && <span> {item.likes}</span>}
{item.replies != null && <span> {item.replies}</span>}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,868 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Loader2,
MessageSquare,
Radar,
RefreshCw,
Save,
Sparkles,
Table2,
} from "lucide-react";
import { ResearchMapView } from "@/components/research-map-view";
import { SuggestedTagsPicker } from "@/components/suggested-tags-picker";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FeatureGate } from "@/components/layout/feature-gate";
import { PageHeader } from "@/components/layout/page-header";
import { JobProgressPanel } from "@/components/job-progress-panel";
import { openRefine, registerApplyCallback, syncResearchMap } from "@/lib/refine-session/store";
import { reconcileSelectedTags } from "@/lib/services/preserve-selected-tags";
import { Textarea } from "@/components/ui/textarea";
import { InlineAlert } from "@/components/ui/inline-alert";
import { notify } from "@/lib/notifications/store";
import type { ActionFeedback } from "@/lib/use-action-feedback";
import { useActionFeedback } from "@/lib/use-action-feedback";
import type { ResearchMap } from "@/lib/types/research";
import { BrandProductPicker } from "@/components/product-profile/brand-product-picker";
import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context";
import { TOPIC_GOAL_LABELS, isPlacementGoal } from "@/lib/types/topic-goal";
import { parseFetchJson } from "@/lib/utils";
import { parseStyle8DProfile } from "@/lib/types/style-profile";
interface Topic {
id: string;
label: string;
query: string;
brief: string | null;
productContext: string | null;
brandProfileId?: string | null;
productProfileId?: string | null;
topicGoal: string;
researchMap: string | null;
selectedTags: string | null;
active: boolean;
}
function parseResearchMap(raw: string | null): ResearchMap | null {
if (!raw) return null;
try {
return JSON.parse(raw) as ResearchMap;
} catch {
return null;
}
}
function parseTags(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === "string") : [];
} catch {
return [];
}
}
export default function TopicDetailPage() {
const params = useParams();
const router = useRouter();
const topicId = params.topicId as string;
const [topic, setTopic] = useState<Topic | null>(null);
const [brief, setBrief] = useState("");
const [productContext, setProductContext] = useState("");
const [brandProfileId, setBrandProfileId] = useState<string | null>(null);
const [productProfileId, setProductProfileId] = useState<string | null>(null);
const [researchMap, setResearchMap] = useState<ResearchMap | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [analyzing, setAnalyzing] = useState(false);
const [analyzeElapsed, setAnalyzeElapsed] = useState(0);
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
const [analyzeProgress, setAnalyzeProgress] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [analyzingTags, setAnalyzingTags] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanProgressDetail, setScanProgressDetail] = useState<string | null>(null);
const [showScanSummary, setShowScanSummary] = useState(false);
const [activeScanJobId, setActiveScanJobId] = useState<string | null>(null);
const [cancellingScan, setCancellingScan] = useState(false);
const [generating, setGenerating] = useState(false);
const [lastScanId, setLastScanId] = useState<string | null>(null);
const [style8DReady, setStyle8DReady] = useState(false);
const { feedback, clearFeedback, showError, showSuccess, showWarning } =
useActionFeedback();
const [brandFeedback, setBrandFeedback] = useState<ActionFeedback | null>(null);
const [tagsFeedback, setTagsFeedback] = useState<ActionFeedback | null>(null);
const [scanFeedback, setScanFeedback] = useState<ActionFeedback | null>(null);
const load = useCallback(async () => {
const [topicsRes, scansRes, accountsRes] = await Promise.all([
fetch("/api/topics"),
fetch("/api/scans"),
fetch("/api/accounts"),
]);
const topicsData = await topicsRes.json();
const scansData = await scansRes.json();
const accountsData = (await accountsRes.json()) as {
activeAccountId?: string | null;
accounts?: Array<{ id: string; styleProfile?: string | null }>;
};
const activeAccount = accountsData.accounts?.find(
(row) => row.id === accountsData.activeAccountId
) ?? accountsData.accounts?.[0];
setStyle8DReady(!!parseStyle8DProfile(activeAccount?.styleProfile));
const found = (topicsData.topics as Topic[]).find((t) => t.id === topicId);
if (!found) return;
setTopic(found);
setBrief(found.brief ?? "");
setProductContext(found.productContext ?? "");
setBrandProfileId(found.brandProfileId ?? null);
setProductProfileId(found.productProfileId ?? null);
setResearchMap(parseResearchMap(found.researchMap));
setSelectedTags(parseTags(found.selectedTags));
const latestScan = (scansData.scans as Array<{ id: string; topicId: string }>).find(
(s) => s.topicId === topicId
);
if (latestScan) setLastScanId(latestScan.id);
}, [topicId]);
const syncActiveJobs = useCallback(async () => {
try {
const res = await fetch(`/api/jobs?topicId=${topicId}&active=1`);
const data = await parseFetchJson<{ jobs?: Array<{
id: string;
type: string;
progress?: string | null;
progressDetail?: string | null;
}> }>(res);
if (!res.ok) return;
const active = data.jobs ?? [];
const analyzeJob = active.find((j) => j.type === "analyze-topic");
const scanJob = active.find((j) => j.type === "scan");
setAnalyzing(!!analyzeJob);
setAnalyzeProgress(analyzeJob?.progress ?? null);
setScanning(!!scanJob);
setScanProgress(scanJob?.progress ?? null);
setScanProgressDetail(scanJob?.progressDetail ?? null);
setActiveScanJobId(scanJob?.id ?? null);
} catch {
// 背景輪詢失敗時略過,避免整頁崩潰
}
}, [topicId]);
useEffect(() => {
if (!topic || !researchMap) return;
syncResearchMap(topicId, researchMap, topic.label);
}, [topicId, topic, researchMap]);
useEffect(() => {
return registerApplyCallback(topicId, (map) => {
setSelectedTags((prev) => {
const reconciled = reconcileSelectedTags(prev, map, {
label: topic?.label ?? "",
query: topic?.query ?? "",
brief: topic?.brief,
});
setResearchMap(reconciled.researchMap);
void fetch("/api/topics", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: topicId,
researchMap: reconciled.researchMap,
selectedTags: reconciled.selectedTags,
}),
});
return reconciled.selectedTags;
});
});
}, [topicId, topic?.label, topic?.query, topic?.brief]);
useEffect(() => {
load();
syncActiveJobs();
}, [load, syncActiveJobs]);
useEffect(() => {
if (!analyzing && !scanning) return;
const timer = window.setInterval(syncActiveJobs, 2000);
return () => window.clearInterval(timer);
}, [analyzing, scanning, syncActiveJobs]);
useEffect(() => {
if (!analyzing) {
setAnalyzeElapsed(0);
return;
}
const timer = window.setInterval(() => {
setAnalyzeElapsed((s) => s + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [analyzing]);
useEffect(() => {
function onJobCompleted(event: Event) {
const { job } = (event as CustomEvent).detail as {
job: {
topicId?: string | null;
type: string;
status: string;
result?: string | null;
error?: string | null;
progress?: string | null;
progressDetail?: string | null;
};
};
if (job.topicId !== topicId) return;
if (job.type === "analyze-topic") {
setAnalyzing(false);
setAnalyzeProgress(null);
if (job.status === "completed") {
setAnalyzeError(null);
load();
} else if (job.status === "failed") {
setAnalyzeError(job.error ?? "AI 分析失敗");
}
}
if (job.type === "scan") {
setScanning(false);
setActiveScanJobId(null);
if (job.status === "completed") {
setScanProgress(job.progress ?? "海巡完成");
setScanProgressDetail(job.progressDetail ?? null);
setShowScanSummary(true);
} else {
setScanProgress(null);
setScanProgressDetail(null);
setShowScanSummary(false);
if (job.status === "failed") {
setScanFeedback({
type: "error",
title: "海巡失敗",
message: job.error ?? "海巡執行失敗,請再試一次。",
});
}
}
if (job.status === "completed" && job.result) {
try {
const scan = JSON.parse(job.result) as {
id?: string;
items?: Array<{ qualityTier?: string }>;
};
if (scan.id) {
setLastScanId(scan.id);
router.push(`/scans/${topicId}/results`);
}
load();
} catch {
load();
}
}
}
}
window.addEventListener("job-completed", onJobCompleted);
return () => window.removeEventListener("job-completed", onJobCompleted);
}, [topicId, load, router]);
async function handleSaveBrief() {
if (!topic) return;
clearFeedback();
setSaving(true);
const res = await fetch("/api/topics", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: topic.id, brief }),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showError((data as { error?: string }).error ?? "無法儲存 Brief", "儲存失敗");
return;
}
showSuccess("Brief 已儲存");
load();
}
async function handleAnalyze() {
if (!topic) return;
if (!brief.trim()) {
showWarning("請先填寫主題 BriefAI 才能準確分析。", "尚無 Brief");
return;
}
if (
isPlacementGoal(topic.topicGoal) &&
!brandProfileId &&
!hasProductContext(productContext)
) {
showWarning("置入模式需要品牌與至少一項產品。", "請先選擇品牌");
return;
}
setAnalyzeError(null);
try {
const res = await fetch("/api/analyze-topic", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topicId: topic.id, brief }),
});
let data: { error?: string; jobId?: string; message?: string } = {};
try {
data = await res.json();
} catch {
throw new Error("伺服器回應格式錯誤");
}
if (!res.ok) {
throw new Error(data.error ?? `AI 分析失敗HTTP ${res.status}`);
}
setAnalyzing(true);
setAnalyzeProgress(data.message ?? "已於背景執行…");
notify({
type: "info",
title: `${topic.label} · 主題分析中`,
message: "AI 正在建立研究地圖與搜尋標籤,可以先去其他頁面。完成後會再通知你。",
href: `/scans/${topic.id}`,
});
window.dispatchEvent(new Event("haixun:jobs-updated"));
syncActiveJobs();
} catch (err) {
const message = err instanceof Error ? err.message : "AI 分析失敗";
setAnalyzeError(message);
}
}
function toggleTag(tag: string) {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}
function selectAllAccountTags() {
if (!researchMap) return;
const accountTags = researchMap.suggestedTags
.filter((t) => t.searchType === "帳號" || t.tag.startsWith("@"))
.map((t) => t.tag);
const fromSimilar = (researchMap.similarAccounts ?? []).map((a) =>
a.username.startsWith("@") ? a.username : `@${a.username}`
);
setSelectedTags((prev) => [...new Set([...prev, ...accountTags, ...fromSimilar])]);
}
async function handleSaveTags() {
if (!topic) return;
setSaving(true);
await fetch("/api/topics", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: topic.id, selectedTags }),
});
setSaving(false);
setTagsFeedback({ type: "success", message: "標籤已儲存" });
load();
}
async function handleAnalyzeTags() {
if (!topic || !researchMap || analyzingTags) return;
setTagsFeedback(null);
setAnalyzingTags(true);
try {
const res = await fetch("/api/analyze-tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topicId: topic.id }),
});
const data = await parseFetchJson<{
error?: string;
researchMap?: ResearchMap;
selectedTags?: string[];
count?: number;
}>(res);
if (!res.ok || !data.researchMap || !data.selectedTags) {
throw new Error(data.error ?? "搜尋標籤分析沒有回傳可用結果");
}
setResearchMap(data.researchMap);
setSelectedTags(data.selectedTags);
setTagsFeedback({
type: "success",
message: `已重新產生 ${data.count ?? data.selectedTags.length} 個自然搜尋詞,研究地圖未重新分析。`,
});
} catch (error) {
setTagsFeedback({
type: "error",
title: "標籤分析失敗",
message: error instanceof Error ? error.message : "請重試或更換研究模型",
});
} finally {
setAnalyzingTags(false);
}
}
async function handleScan() {
if (!topic) return;
setScanFeedback(null);
setShowScanSummary(false);
const placement = isPlacementGoal(topic.topicGoal);
if (!placement && selectedTags.length === 0) {
setScanFeedback({
type: "warning",
title: "請先選擇標籤",
message: "完成 AI 分析後,至少選擇一個搜尋標籤再海巡。",
});
return;
}
if (
placement &&
(!researchMap ||
(researchMap.questions.length === 0 && researchMap.pillars.length === 0))
) {
setScanFeedback({
type: "warning",
title: "請先完成 AI 分析",
message: "置入模式需要研究地圖的「受眾會問什麼」與「內容支柱」才能自動海巡。",
});
return;
}
setScanning(true);
try {
const res = await fetch("/api/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topicId: topic.id,
useTags: !placement,
selectedTags,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "無法啟動海巡");
setActiveScanJobId(data.jobId ?? null);
setScanProgress(data.message ?? "海巡已啟動…");
notify({
type: "info",
title: `${topic.label} · 海巡中`,
message: "正在搜尋 Threads 並篩選素材,完成後會再通知你。",
href: `/scans/${topic.id}`,
});
window.dispatchEvent(new Event("haixun:jobs-updated"));
syncActiveJobs();
} catch (error) {
setScanning(false);
setScanFeedback({
type: "error",
title: "海巡失敗",
message: error instanceof Error ? error.message : "無法啟動海巡",
});
}
}
async function handleCancelScan() {
if (!activeScanJobId) return;
setCancellingScan(true);
try {
const res = await fetch(`/api/jobs/${activeScanJobId}/cancel`, { method: "POST" });
const data = await res.json();
if (!res.ok) {
setScanFeedback({
type: "error",
title: "取消失敗",
message: data.error ?? "無法停止海巡",
});
return;
}
setScanning(false);
setScanProgress(null);
setScanProgressDetail(null);
setActiveScanJobId(null);
setScanFeedback({ type: "info", message: "海巡已停止" });
syncActiveJobs();
} finally {
setCancellingScan(false);
}
}
async function handleGenerateMatrix() {
if (!lastScanId) {
setScanFeedback({ type: "warning", message: "請先完成海巡再生成內容矩陣。" });
return;
}
setGenerating(true);
const res = await fetch("/api/generate-matrix", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanId: lastScanId }),
});
const data = await res.json();
setGenerating(false);
if (!res.ok) {
notify({ type: "error", title: "生成內容矩陣失敗", message: data.error });
return;
}
notify({
type: "success",
title: "內容矩陣已生成",
message: `${data.drafts?.length ?? 0}`,
href: "/matrix",
});
router.push("/matrix");
}
if (!topic) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link
href={`/scans?mode=${isPlacementGoal(topic.topicGoal) ? "placement" : "viral"}`}
>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</div>
<PageHeader
title={topic.label}
description={`${topic.query} · ${TOPIC_GOAL_LABELS[isPlacementGoal(topic.topicGoal) ? "placement" : "viral"]}`}
/>
<div className="mb-5 flex flex-col gap-3 rounded-xl border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium"> 8D </p>
<Badge variant={style8DReady ? "success" : "secondary"}>
{style8DReady ? "已套用" : "尚未設定"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
D1D8
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/accounts#style-8d"> D1D8</Link>
</Button>
</div>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle> Brief</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={brief}
onChange={(e) => setBrief(e.target.value)}
rows={5}
placeholder={
isPlacementGoal(topic.topicGoal)
? "例:服務自己在家幫狗洗澡的飼主,不要送寵物美容的受眾。"
: "例30 歲上班族女性第一次備孕。想分享科學備孕知識,服務同齡焦慮型讀者。不要業配、偏方、純情緒宣洩。"
}
/>
{isPlacementGoal(topic.topicGoal) && (
<div className="space-y-2 rounded-lg border border-border bg-muted/40 p-4">
{brandFeedback && (
<InlineAlert
type={brandFeedback.type}
title={brandFeedback.title}
message={brandFeedback.message}
onDismiss={() => setBrandFeedback(null)}
/>
)}
<BrandProductPicker
brandId={brandProfileId}
productId={productProfileId}
onChange={async ({ brandId, productId, context }) => {
setBrandFeedback(null);
const res = await fetch("/api/topics", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: topic.id,
brandProfileId: brandId,
productProfileId: productId,
}),
});
let data: { error?: string; topic?: { productContext?: string } } = {};
try {
data = await parseFetchJson(res);
} catch (err) {
setBrandFeedback({
type: "error",
title: "儲存失敗",
message: err instanceof Error ? err.message : "伺服器回應異常",
});
return;
}
if (!res.ok) {
setBrandFeedback({
type: "error",
title: "儲存失敗",
message: data.error ?? "無法連結品牌與產品",
});
return;
}
setBrandProfileId(brandId);
setProductProfileId(productId);
if (data.topic?.productContext) {
setProductContext(data.topic.productContext);
} else if (context) {
setProductContext(context);
}
setBrandFeedback({ type: "success", message: "品牌與產品已連結" });
}}
/>
{!brandProfileId && !productProfileId && summarizeProductContext(productContext) && (
<p className="text-[12px] text-muted-foreground">
使{summarizeProductContext(productContext)}
</p>
)}
</div>
)}
<Button size="sm" onClick={handleSaveBrief} disabled={saving}>
<Save className="h-3.5 w-3.5" />
{saving ? "儲存中…" : "儲存"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle> </CardTitle>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{researchMap && (
<FeatureGate feature="research">
<Button
variant="outline"
onClick={() =>
openRefine(topicId, { researchMap, topicLabel: topic.label })
}
>
<MessageSquare className="h-4 w-4" />
調
</Button>
</FeatureGate>
)}
<FeatureGate feature="analyze">
<Button onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{analyzing ? `主題分析中… ${analyzeElapsed}s` : "主題分析"}
</Button>
</FeatureGate>
</div>
</div>
</CardHeader>
<CardContent>
{analyzing && (
<p className="page-lead mb-3">
{analyzeProgress ?? "分析中…"}
{analyzeElapsed > 0 && ` · ${analyzeElapsed}s`}
</p>
)}
{analyzeError && !analyzing && (
<InlineAlert type="error" title="AI 分析失敗" message={analyzeError} className="mb-3" />
)}
<FeatureGate feature="analyze" showHint />
{researchMap ? (
<ResearchMapView
map={researchMap}
showSimilarAccounts={!isPlacementGoal(topic.topicGoal)}
/>
) : (
<p className="page-lead"> Brief </p>
)}
</CardContent>
</Card>
{researchMap && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle> {isPlacementGoal(topic.topicGoal) ? "海巡關鍵字" : "搜尋標籤"}</CardTitle>
<CardDescription className="mt-1">
{isPlacementGoal(topic.topicGoal)
? "以真人會搜尋的痛點、求助與選購詞找出高意向受眾"
: "勾選後以 Threads API 與網搜找參考貼文;可一併勾選相似帳號"}
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleAnalyzeTags}
disabled={analyzingTags}
>
{analyzingTags ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{analyzingTags ? "分析標籤中…" : "重新分析標籤"}
</Button>
<Button size="sm" variant="outline" onClick={handleSaveTags} disabled={saving || analyzingTags}>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{tagsFeedback && (
<InlineAlert
type={tagsFeedback.type}
title={tagsFeedback.title}
message={tagsFeedback.message}
onDismiss={() => setTagsFeedback(null)}
/>
)}
<SuggestedTagsPicker
tags={researchMap.suggestedTags}
selected={selectedTags}
onToggle={toggleTag}
onSelectAllAccounts={selectAllAccountTags}
hideAccounts={isPlacementGoal(topic.topicGoal)}
/>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{isPlacementGoal(topic.topicGoal)
? "分析完成後一鍵海巡:自動用受眾問題與內容支柱搜尋,再以「不要碰」過濾"
: "海巡後生成內容矩陣"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{scanFeedback && (
<InlineAlert
type={scanFeedback.type}
title={scanFeedback.title}
message={scanFeedback.message}
onDismiss={() => setScanFeedback(null)}
/>
)}
{(scanning || showScanSummary) && (scanProgress || scanProgressDetail) && (
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
<JobProgressPanel
summary={scanProgress ?? (scanning ? "背景海巡中…" : "海巡完成")}
progressDetailRaw={scanProgressDetail}
completed={!scanning && showScanSummary}
jobType="scan"
/>
{scanning ? (
<p className="page-lead"> · </p>
) : (
<div className="flex flex-wrap items-center gap-2">
<p className="page-lead"></p>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[12px]"
onClick={() => {
setShowScanSummary(false);
setScanProgress(null);
setScanProgressDetail(null);
}}
>
</Button>
</div>
)}
</div>
)}
<FeatureGate feature="scan" showHint className="mb-3" />
<div className="flex flex-wrap gap-2">
<FeatureGate feature="scan">
<Button
onClick={handleScan}
disabled={
scanning ||
(!isPlacementGoal(topic.topicGoal) && selectedTags.length === 0) ||
(isPlacementGoal(topic.topicGoal) && !researchMap)
}
>
<Radar className="h-4 w-4" />
{scanning
? "海巡中…"
: isPlacementGoal(topic.topicGoal)
? "置入海巡"
: `綜合海巡(${selectedTags.length} 個標籤)`}
</Button>
</FeatureGate>
{scanning && activeScanJobId && (
<Button
variant="outline"
onClick={handleCancelScan}
disabled={cancellingScan}
>
{cancellingScan ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
</Button>
)}
{!isPlacementGoal(topic.topicGoal) && (
<Button
variant="outline"
onClick={handleGenerateMatrix}
disabled={generating || !lastScanId}
>
<Table2 className="h-4 w-4" />
{generating ? "生成中…" : "生成內容矩陣"}
</Button>
)}
<Button variant="ghost" asChild>
<Link href={`/scans/${topicId}/results`}>
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Loader2, Settings2 } from "lucide-react";
import { TopicScanResults, type Scan } from "@/components/inspiration/topic-scan-results";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/layout/page-header";
import { notify } from "@/lib/notifications/store";
import { TOPIC_GOAL_LABELS, isPlacementGoal } from "@/lib/types/topic-goal";
import { parseFetchJson } from "@/lib/utils";
interface Topic {
id: string;
label: string;
query: string;
topicGoal: string;
}
export default function TopicScanResultsPage() {
const params = useParams();
const topicId = params.topicId as string;
const [topic, setTopic] = useState<Topic | null>(null);
const [scans, setScans] = useState<Scan[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [topicsRes, scansRes] = await Promise.all([
fetch("/api/topics"),
fetch(`/api/scans?topicId=${topicId}`),
]);
const topicsData = await parseFetchJson<{ topics?: Topic[]; error?: string }>(topicsRes);
const scansData = await parseFetchJson<{ scans?: Scan[]; error?: string }>(scansRes);
if (!topicsRes.ok) {
notify({ type: "error", title: "載入主題失敗", message: topicsData.error });
}
if (!scansRes.ok) {
notify({ type: "error", title: "載入海巡失敗", message: scansData.error });
}
const found = (topicsData.topics ?? []).find((t) => t.id === topicId) ?? null;
setTopic(found);
setScans(scansData.scans ?? []);
} catch (err) {
const message = err instanceof Error ? err.message : "載入失敗";
notify({ type: "error", title: "載入海巡成果失敗", message });
setTopic(null);
setScans([]);
} finally {
setLoading(false);
}
}, [topicId]);
useEffect(() => {
setLoading(true);
load();
}, [load]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!topic) {
return (
<div className="py-16 text-center">
<p className="text-muted-foreground"></p>
<Button size="sm" variant="outline" className="mt-4" asChild>
<Link href="/scans"></Link>
</Button>
</div>
);
}
const goal = isPlacementGoal(topic.topicGoal) ? "placement" : "viral";
return (
<div>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/scans?mode=${goal}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</div>
<PageHeader
title={topic.label}
description={`${topic.query} · ${TOPIC_GOAL_LABELS[goal]} · 海巡成果`}
action={
<Button size="sm" variant="outline" asChild>
<Link href={`/scans/${topicId}`}>
<Settings2 className="h-3.5 w-3.5" />
</Link>
</Button>
}
/>
<TopicScanResults
scans={scans}
onReload={load}
emptyAction={
<Button size="sm" asChild>
<Link href={`/scans/${topicId}`}></Link>
</Button>
}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { Suspense } from "react";
export default function ScansLayout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

View File

@ -0,0 +1,201 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Plus, ScanSearch, Settings2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { TopicFormDialog } from "@/components/inspiration/topic-form-dialog";
import { EmptyState } from "@/components/layout/empty-state";
import { PageHeader } from "@/components/layout/page-header";
import { notify } from "@/lib/notifications/store";
import {
TOPIC_GOAL_LABELS,
isPlacementGoal,
parseTopicGoal,
type TopicGoal,
} from "@/lib/types/topic-goal";
import { parseFetchJson } from "@/lib/utils";
interface TopicListItem {
id: string;
label: string;
query: string;
topicGoal: string;
scanCount: number;
latestScan: {
id: string;
createdAt: string;
itemCount: number;
} | null;
}
export default function ScansPage() {
const searchParams = useSearchParams();
const [topics, setTopics] = useState<TopicListItem[]>([]);
const mode: TopicGoal = parseTopicGoal(searchParams.get("mode"));
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch("/api/topics");
const data = await parseFetchJson<{ topics?: TopicListItem[]; error?: string }>(res);
if (!res.ok) {
notify({ type: "error", title: "載入主題失敗", message: data.error });
setTopics([]);
return;
}
setTopics(data.topics ?? []);
} catch (err) {
const message = err instanceof Error ? err.message : "載入失敗";
notify({ type: "error", title: "載入找靈感失敗", message });
setTopics([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const modeTopics = useMemo(
() =>
topics.filter((t) =>
isPlacementGoal(t.topicGoal) ? mode === "placement" : mode === "viral"
),
[topics, mode]
);
const filteredTopics = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return modeTopics;
return modeTopics.filter(
(t) => t.label.toLowerCase().includes(q) || t.query.toLowerCase().includes(q)
);
}, [modeTopics, search]);
return (
<div>
<PageHeader
eyebrow={mode === "viral" ? "HAIXUN" : "FIND YOUR TA"}
title={mode === "viral" ? "建立主題海巡任務" : "建立高意向受眾任務"}
description={mode === "viral" ? "設定要模仿的內容主題Extension 會使用你的 Threads session 進行抓取與分析。" : "設定產品與受眾關鍵字,找出有需求訊號的人並生成人設置入話術。"}
action={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
}
/>
<div className="mb-5 space-y-3">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋主題名稱或種子關鍵字…"
className="max-w-sm"
/>
</div>
<TopicFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultGoal={mode}
onCreated={load}
/>
{loading ? (
<div className="py-16 text-center text-[13px] text-muted-foreground"></div>
) : filteredTopics.length === 0 ? (
<EmptyState
icon={ScanSearch}
title={modeTopics.length === 0 ? "尚無此模式的主題" : "找不到符合的主題"}
description={
modeTopics.length === 0
? "按「新增任務」開始建立第一個任務"
: "試試其他關鍵字,或清除搜尋"
}
action={
modeTopics.length === 0 ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => setSearch("")}>
</Button>
)
}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filteredTopics.map((topic, i) => {
const goal = isPlacementGoal(topic.topicGoal) ? "placement" : "viral";
const hasScans = topic.scanCount > 0;
return (
<Card
key={topic.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{topic.label}</CardTitle>
<CardDescription className="mt-1 truncate">{topic.query}</CardDescription>
</div>
<Badge variant={goal === "placement" ? "secondary" : "default"}>
{TOPIC_GOAL_LABELS[goal]}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{topic.latestScan ? (
<p className="text-[13px] text-muted-foreground">
{new Date(topic.latestScan.createdAt).toLocaleString("zh-TW")}
{" · "}
{topic.latestScan.itemCount}
{" · "}
{topic.scanCount}
</p>
) : (
<p className="text-[13px] text-muted-foreground"></p>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" asChild>
<Link href={`/scans/${topic.id}`}>
<Settings2 className="h-3.5 w-3.5" />
</Link>
</Button>
{hasScans ? (
<Button size="sm" asChild>
<Link href={`/scans/${topic.id}/results`}>
<ScanSearch className="h-3.5 w-3.5" />
</Link>
</Button>
) : (
<Button size="sm" disabled title="請先完成海巡">
<ScanSearch className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,384 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Loader2, Package, PlugZap, Settings2, ShieldCheck, UserRound } from "lucide-react";
import { ApiKeysForm } from "@/components/settings/api-keys-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/layout/page-header";
import { ThemeToggle } from "@/components/theme-toggle";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InlineAlert } from "@/components/ui/inline-alert";
import { PROVIDER_OPTIONS } from "@/lib/ai/provider";
import type { ProviderApiKeys, ProviderId } from "@/lib/ai/keys";
import { useActionFeedback } from "@/lib/use-action-feedback";
interface SettingsData {
aiProvider: string;
aiModel: string;
researchAiProvider?: string | null;
researchAiModel?: string | null;
draftsPerScan: number;
matrixRows: number;
scanCron: string;
apiKeys?: ProviderApiKeys;
apiKeysConfigured?: Partial<Record<ProviderId, boolean>>;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsData | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<ProviderApiKeys>({});
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
async function load() {
try {
const res = await fetch("/api/settings");
const data = (await res.json().catch(() => ({}))) as SettingsData;
if (!res.ok || !data.aiProvider) {
setLoadError("無法載入設定,請重新整理頁面");
return;
}
setSettings(data);
setApiKeyInputs(data.apiKeys ?? {});
} catch {
setLoadError("網路連線異常,請重新整理頁面");
}
}
useEffect(() => {
load();
}, []);
async function handleSave() {
if (!settings) return;
clearFeedback();
setSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
researchAiProvider: settings.researchAiProvider,
researchAiModel: settings.researchAiModel,
draftsPerScan: settings.draftsPerScan,
matrixRows: settings.matrixRows,
scanCron: settings.scanCron,
apiKeys: apiKeyInputs,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error ?? "無法儲存設定", "儲存失敗");
return;
}
setSettings(data);
showSuccess("設定已儲存");
} catch {
showError("網路連線異常,請稍後再試", "儲存失敗");
} finally {
setSaving(false);
}
}
if (loadError) {
return (
<div>
<PageHeader eyebrow="SYSTEM" title="設定" description="只留下會直接影響海巡與找 TA 的設定。" />
<InlineAlert type="error" title="載入失敗" message={loadError} />
<Button onClick={load} className="mt-4"></Button>
</div>
);
}
if (!settings) {
return (
<div className="space-y-4">
<div className="skeleton h-12 animate-pulse" />
<div className="skeleton h-48 animate-pulse" />
<div className="skeleton h-64 animate-pulse" />
</div>
);
}
const provider = PROVIDER_OPTIONS.find((p) => p.value === settings.aiProvider);
const researchProvider = PROVIDER_OPTIONS.find(
(p) => p.value === (settings.researchAiProvider ?? settings.aiProvider)
);
const currentProviderConfigured = settings.apiKeysConfigured?.[settings.aiProvider as ProviderId];
return (
<div>
<PageHeader
eyebrow="SYSTEM"
title="設定"
description="只留下會直接影響海巡與找 TA 的設定。"
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-success" /></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl bg-muted/70 p-3"><b className="block"></b><span className="text-xs text-muted-foreground"></span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 4 </b><span className="text-xs text-muted-foreground"> 12 </span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 4 × 5</b><span className="text-xs text-muted-foreground"></span></div>
<div className="rounded-xl bg-muted/70 p-3"><b className="block"> 40 </b><span className="text-xs text-muted-foreground"></span></div>
</div>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><PlugZap className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground">Chrome Threads API</p></div>
<Button asChild variant="outline" size="sm"><Link href="/connections"></Link></Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><UserRound className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground"></p></div>
<Button asChild variant="outline" size="sm"><Link href="/accounts"></Link></Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><Package className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><p className="font-semibold"></p><p className="mt-1 text-xs text-muted-foreground"> TA </p></div>
<Button asChild variant="outline" size="sm"><Link href="/products"></Link></Button>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ThemeToggle />
</CardContent>
</Card>
<Card id="ai-keys">
<CardHeader>
<CardTitle>AI API Key</CardTitle>
<CardDescription>
Threads
{currentProviderConfigured ? " 已設定 key。" : " 尚未設定 key。"}
</CardDescription>
</CardHeader>
<CardContent>
<ApiKeysForm
values={apiKeyInputs}
configured={settings.apiKeysConfigured ?? {}}
onChange={(providerId, value) =>
setApiKeyInputs((prev) => ({ ...prev, [providerId]: value }))
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>稿使 AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>AI </Label>
<Select
value={settings.aiProvider}
onValueChange={(value) => {
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
setSettings({
...settings,
aiProvider: value,
aiModel: nextProvider?.models[0] ?? settings.aiModel,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={settings.aiModel}
onValueChange={(value) => setSettings({ ...settings, aiModel: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(provider?.models ?? []).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border border-border bg-muted p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label> AI </Label>
<Select
value={settings.researchAiProvider ?? settings.aiProvider}
onValueChange={(value) => {
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
setSettings({
...settings,
researchAiProvider: value,
researchAiModel:
nextProvider?.models[0] ?? settings.researchAiModel ?? settings.aiModel,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={settings.researchAiModel ?? settings.aiModel}
onValueChange={(value) => setSettings({ ...settings, researchAiModel: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(researchProvider?.models ?? []).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>稿</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>稿</Label>
<Input
type="number"
min={1}
max={10}
value={settings.draftsPerScan}
onChange={(event) =>
setSettings({
...settings,
draftsPerScan: parseInt(event.target.value, 10) || 4,
})
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
min={3}
max={20}
value={settings.matrixRows}
onChange={(event) =>
setSettings({
...settings,
matrixRows: parseInt(event.target.value, 10) || 7,
})
}
/>
</div>
<div className="space-y-2">
<Label>cron</Label>
<Input
value={settings.scanCron}
onChange={(event) => setSettings({ ...settings, scanCron: event.target.value })}
placeholder="0 9 * * *"
className="font-mono"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-[13px] text-muted-foreground">
<p>
<strong></strong> Threads API
Brave
</p>
<p>
<code>.env</code> <code>BRAVE_SEARCH_API_KEY</code>
<a
href="https://api-dashboard.search.brave.com/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Brave Search API
</a>
8 Brave <code>SCAN_BRAVE_MAX_QUERIES</code> 調
</p>
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} size="lg" className="w-full sm:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Settings2 className="h-4 w-4" />}
{saving ? "儲存中…" : "儲存設定"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { setActiveAccountForUser } from "@/lib/account-context";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireSessionUser();
const { id } = await params;
const account = await assertAccountOwnedByUser(user.id, id);
await setActiveAccountForUser(user.id, account.id);
return NextResponse.json({ success: true, account });
} catch (error) {
return apiRouteErrorResponse(error, "accounts/activate");
}
}

View File

@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { setActiveAccountForUser } from "@/lib/account-context";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { deleteAccountWithRelations } from "@/lib/services/delete-account";
function trimOrNull(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed || null;
}
function trimUsername(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.replace(/^@/, "").trim();
return trimmed || null;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireSessionUser();
const { id } = await params;
await assertAccountOwnedByUser(user.id, id);
const body = (await request.json()) as {
displayName?: string | null;
username?: string | null;
persona?: string | null;
styleProfile?: string | null;
styleBenchmark?: string | null;
brief?: string | null;
productBrief?: string | null;
targetAudience?: string | null;
goals?: string | null;
};
const account = await prisma.account.update({
where: { id },
data: {
...(body.displayName !== undefined && { displayName: trimOrNull(body.displayName) }),
...(body.username !== undefined && { username: trimUsername(body.username) }),
...(body.persona !== undefined && { persona: trimOrNull(body.persona) }),
...(body.styleProfile !== undefined && { styleProfile: trimOrNull(body.styleProfile) }),
...(body.styleBenchmark !== undefined && {
styleBenchmark: trimUsername(body.styleBenchmark),
}),
...(body.brief !== undefined && { brief: trimOrNull(body.brief) }),
...(body.productBrief !== undefined && { productBrief: trimOrNull(body.productBrief) }),
...(body.targetAudience !== undefined && {
targetAudience: trimOrNull(body.targetAudience),
}),
...(body.goals !== undefined && { goals: trimOrNull(body.goals) }),
},
});
return NextResponse.json({ account });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新帳號失敗";
console.error("[accounts] PATCH failed:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireSessionUser();
const { id } = await params;
await assertAccountOwnedByUser(user.id, id);
const isActive = user.activeAccountId === id;
let nextActiveId: string | null = user.activeAccountId ?? null;
if (isActive) {
const next = await prisma.account.findFirst({
where: { userId: user.id, id: { not: id } },
orderBy: { updatedAt: "desc" },
select: { id: true },
});
nextActiveId = next?.id ?? null;
}
await deleteAccountWithRelations(id);
if (isActive) {
if (nextActiveId) {
await setActiveAccountForUser(user.id, nextActiveId);
} else {
await prisma.user.update({
where: { id: user.id },
data: { activeAccountId: null },
});
}
}
return NextResponse.json({ deleted: true, activeAccountId: nextActiveId });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除帳號失敗";
console.error("[accounts] DELETE failed:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { enqueueJob, findActiveAccountJob } from "@/lib/jobs/runner";
import { scheduleBackgroundJob } from "@/lib/jobs/schedule";
export const maxDuration = 180;
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireSessionUser();
const { id } = await params;
await assertAccountOwnedByUser(user.id, id);
const body = (await request.json()) as { benchmarkUsername?: string };
const username = body.benchmarkUsername?.replace(/^@/, "").trim();
if (!username) return NextResponse.json({ error: "請輸入對標帳號" }, { status: 400 });
const existing = await findActiveAccountJob(id, "style-8d");
if (existing) {
return NextResponse.json({
jobId: existing.id,
status: existing.status,
message: "這個帳號已有 8D 分析任務進行中",
});
}
const job = await enqueueJob({
type: "style-8d",
accountId: id,
label: `@${username} · 8D 帳號風格`,
payload: { accountId: id, benchmarkUsername: username },
});
scheduleBackgroundJob(job.id);
return NextResponse.json({ jobId: job.id, status: "pending", message: "8D 分析已在背景執行" });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
return NextResponse.json({ error: error instanceof Error ? error.message : "8D 分析失敗" }, { status: 500 });
}
}

View File

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { setActiveAccountForUser } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
/**
* API
*/
export async function POST() {
try {
const user = await requireSessionUser();
const existingUnbound = await prisma.account.findFirst({
where: { userId: user.id, threadsUserId: null },
orderBy: { updatedAt: "desc" },
select: { id: true },
});
const accountId =
existingUnbound?.id ??
(
await prisma.account.create({
data: {
userId: user.id,
displayName: "待綁定帳號",
storageState: "",
valid: false,
},
select: { id: true },
})
).id;
await setActiveAccountForUser(user.id, accountId);
return NextResponse.json({ accountId });
} catch (error) {
return apiRouteErrorResponse(error, "accounts/bind");
}
}

View File

@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { getActiveAccountProfile } from "@/lib/account-context";
import {
fromAccountPublic,
getActiveAccountConnectionSettings,
migrateUserConnectionSettingsIfNeeded,
updateActiveAccountConnectionSettings,
} from "@/lib/account-connection-settings";
import { parseSearchSourceMode } from "@/lib/search/source-mode";
import type { AccountConnectionSettings } from "@/lib/account-connection-settings";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
export async function GET() {
try {
const user = await requireSessionUser();
await migrateUserConnectionSettingsIfNeeded(user.id);
const account = await getActiveAccountProfile();
if (!account) {
return NextResponse.json({
accountId: null,
accountName: null,
connection: null,
});
}
const connection = await getActiveAccountConnectionSettings();
return NextResponse.json({
accountId: account.id,
accountName: account.displayName ?? account.username ?? "未命名帳號",
username: account.username,
connection: fromAccountPublic(connection),
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
console.error("[accounts/connection GET]", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "載入連線設定失敗" },
{ status: 500 }
);
}
}
export async function PATCH(request: Request) {
try {
const user = await requireSessionUser();
const body = (await request.json()) as Partial<{
searchViaApi: boolean;
searchSourceMode: string;
publishViaApi: boolean;
devMode: boolean;
scrapeReplies: boolean;
repliesPerPost: number;
publishHeaded: boolean;
playwrightDebug: boolean;
}>;
const { searchSourceMode: rawSourceMode, ...rest } = body;
const patch: Partial<AccountConnectionSettings> = {
...rest,
...(rawSourceMode !== undefined && {
searchSourceMode: parseSearchSourceMode(rawSourceMode),
}),
};
const { account } = await updateActiveAccountConnectionSettings(user.id, patch);
return NextResponse.json({
accountId: account.id,
accountName: account.displayName ?? account.username ?? "未命名帳號",
connection: fromAccountPublic({
searchViaApi: account.searchViaApi,
searchSourceMode: parseSearchSourceMode(account.searchSourceMode),
publishViaApi: account.publishViaApi,
devMode: account.devMode,
scrapeReplies: account.scrapeReplies,
repliesPerPost: account.repliesPerPost,
publishHeaded: account.publishHeaded,
playwrightDebug: account.playwrightDebug,
}),
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
if (error instanceof Error && error.message.includes("經營帳號")) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("[accounts/connection PATCH]", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "儲存連線設定失敗" },
{ status: 500 }
);
}
}

116
app/api/accounts/route.ts Normal file
View File

@ -0,0 +1,116 @@
import { NextResponse } from "next/server";
import { accountHasStoredSession } from "@/lib/threads-browser";
import { prisma } from "@/lib/db";
import { setActiveAccountForUser } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
function accountSelect() {
return {
id: true,
username: true,
displayName: true,
persona: true,
styleProfile: true,
styleBenchmark: true,
brief: true,
productBrief: true,
targetAudience: true,
goals: true,
valid: true,
storageState: true,
updatedAt: true,
threadsUserId: true,
threadsTokenExpiresAt: true,
} as const;
}
type RawAccount = Awaited<
ReturnType<typeof prisma.account.findFirst<{ select: ReturnType<typeof accountSelect> }>>
>;
function toPublicAccount(account: NonNullable<RawAccount>) {
const { threadsUserId, threadsTokenExpiresAt } = account;
return {
id: account.id,
username: account.username,
displayName: account.displayName,
persona: account.persona,
styleProfile: account.styleProfile,
styleBenchmark: account.styleBenchmark,
brief: account.brief,
productBrief: account.productBrief,
targetAudience: account.targetAudience,
goals: account.goals,
valid: account.valid,
updatedAt: account.updatedAt,
apiConnected: !!threadsUserId,
browserConnected: accountHasStoredSession(account),
sessionSynced: accountHasStoredSession(account),
threadsUserId,
threadsTokenExpiresAt: threadsTokenExpiresAt?.toISOString() ?? null,
};
}
export async function GET() {
try {
const user = await requireSessionUser();
const accounts = await prisma.account.findMany({
where: { userId: user.id },
orderBy: { updatedAt: "desc" },
select: accountSelect(),
});
const activeAccountId = user.activeAccountId ?? accounts[0]?.id ?? null;
if (!user.activeAccountId && activeAccountId) {
await setActiveAccountForUser(user.id, activeAccountId);
}
return NextResponse.json({
accounts: accounts.map(toPublicAccount),
activeAccountId,
});
} catch (error) {
return apiRouteErrorResponse(error, "accounts");
}
}
export async function POST(request: Request) {
try {
const user = await requireSessionUser();
const body = (await request.json()) as {
displayName?: string;
username?: string;
persona?: string;
brief?: string;
productBrief?: string;
targetAudience?: string;
goals?: string;
activate?: boolean;
};
const account = await prisma.account.create({
data: {
userId: user.id,
displayName: body.displayName?.trim() || body.username?.trim() || "新經營帳號",
username: body.username?.replace(/^@/, "").trim() || null,
persona: body.persona?.trim() || null,
brief: body.brief?.trim() || null,
productBrief: body.productBrief?.trim() || null,
targetAudience: body.targetAudience?.trim() || null,
goals: body.goals?.trim() || null,
storageState: "",
valid: false,
},
select: accountSelect(),
});
if (body.activate ?? true) {
await setActiveAccountForUser(user.id, account.id);
}
return NextResponse.json({ account: toPublicAccount(account) });
} catch (error) {
return apiRouteErrorResponse(error, "accounts/create");
}
}

View File

@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { getOrCreateSettings } from "@/lib/user-settings";
import { generateAccountStrategy, type AccountStrategyFields } from "@/lib/ai/generate-account-strategy";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 60;
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
instruction?: string;
current?: Partial<AccountStrategyFields>;
};
const instruction = body.instruction?.trim();
if (!instruction) {
return NextResponse.json({ error: "請先跟小幫手說你想經營什麼方向" }, { status: 400 });
}
const settings = await getOrCreateSettings();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const result = await trackAiTask("帳號策略助手", () => generateAccountStrategy({
instruction,
current: body.current ?? {},
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
}));
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "策略小幫手暫時無法產生內容";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { regenerateSearchTags } from "@/lib/ai/analyze-topic";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import { prisma } from "@/lib/db";
import { pickDefaultSelectedTags } from "@/lib/services/scan-tasks";
import { getOrCreateSettings } from "@/lib/user-settings";
import { parseResearchMap } from "@/lib/types/research";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const { topicId } = (await request.json()) as { topicId?: string };
if (!topicId) {
return NextResponse.json({ error: "缺少 topicId" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
if (!topic || !isAccountInUserScope(accountIds, topic.accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const currentMap = parseResearchMap(topic.researchMap);
if (!currentMap) {
return NextResponse.json({ error: "請先完成一次主題分析,建立研究地圖" }, { status: 400 });
}
const settings = await getOrCreateSettings();
const tags = await regenerateSearchTags({
label: topic.label,
query: topic.query,
brief: topic.brief,
topicGoal: topic.topicGoal,
aiProvider: settings.researchAiProvider ?? settings.aiProvider,
aiModel: settings.researchAiModel ?? settings.aiModel,
apiKeys: parseProviderApiKeys(settings.providerApiKeys),
}, currentMap);
const accountTags = currentMap.suggestedTags.filter(
(tag) => tag.searchType === "帳號" || tag.tag.startsWith("@")
);
const researchMap = {
...currentMap,
suggestedTags: [...tags, ...accountTags].slice(0, 14),
};
const selectedTags = pickDefaultSelectedTags(researchMap, topic.topicGoal);
await prisma.topic.update({
where: { id: topic.id },
data: {
researchMap: JSON.stringify(researchMap),
selectedTags: JSON.stringify(selectedTags),
},
});
return NextResponse.json({ researchMap, selectedTags, count: tags.length });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "搜尋標籤分析失敗";
console.error("[analyze-tags] failed:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { enqueueJob, findActiveJob } from "@/lib/jobs/runner";
import { scheduleBackgroundJob } from "@/lib/jobs/schedule";
export async function POST(request: Request) {
try {
const { topicId, brief } = (await request.json()) as {
topicId?: string;
brief?: string | null;
};
if (!topicId) {
return NextResponse.json({ error: "缺少 topicId" }, { status: 400 });
}
const accountId = await getActiveAccountId();
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
if (!topic || (accountId && topic.accountId !== accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const existing = await findActiveJob(topicId, "analyze-topic");
if (existing) {
return NextResponse.json({
jobId: existing.id,
status: existing.status,
message: "已有分析任務在背景執行中",
});
}
const job = await enqueueJob({
type: "analyze-topic",
topicId,
label: `${topic.label} · AI 研究地圖`,
payload: { topicId, brief },
});
scheduleBackgroundJob(job.id);
return NextResponse.json({
jobId: job.id,
status: "pending",
message: "已於背景執行,可自由切換頁面",
});
} catch (error) {
const message = error instanceof Error ? error.message : "分析失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { analyzeScanItemViral, analyzeScanTopViral } from "@/lib/services/viral";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 300;
export async function POST(request: Request) {
try {
const body = (await request.json()) as { scanItemId?: string; scanId?: string; limit?: number };
if (body.scanItemId) {
const result = await trackAiTask("分析爆文結構", () => analyzeScanItemViral(body.scanItemId!));
return NextResponse.json(result);
}
if (body.scanId) {
const results = await trackAiTask("批次分析爆文結構", () => analyzeScanTopViral(body.scanId!, body.limit ?? 5));
return NextResponse.json({ results });
}
return NextResponse.json({ error: "請提供 scanItemId 或 scanId" }, { status: 400 });
} catch (error) {
const message = error instanceof Error ? error.message : "爆款分析失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { userHasBoundThreadsAccount } from "@/lib/auth/accounts";
import { createSession } from "@/lib/auth/session";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function POST(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as { email?: string; password?: string };
const email = body.email?.trim().toLowerCase();
const password = body.password ?? "";
if (!email || !password) {
return NextResponse.json({ error: "請輸入 Email 與密碼" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !verifyPassword(password, user.passwordHash)) {
return NextResponse.json({ error: "Email 或密碼錯誤" }, { status: 401 });
}
await createSession(user.id);
const needsThreadsBind = !(await userHasBoundThreadsAccount(user.id));
return NextResponse.json({
user: { id: user.id, email: user.email, name: user.name },
needsThreadsBind,
});
} catch (error) {
return apiRouteErrorResponse(error, "auth/login");
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/auth/session";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function POST() {
try {
await destroySession();
return NextResponse.json({ success: true });
} catch (error) {
return apiRouteErrorResponse(error, "auth/logout");
}
}

22
app/api/auth/me/route.ts Normal file
View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { userHasBoundThreadsAccount } from "@/lib/auth/accounts";
import { getSessionUser } from "@/lib/auth/session";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function GET() {
try {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "未登入" }, { status: 401 });
}
const needsThreadsBind = !(await userHasBoundThreadsAccount(user.id));
return NextResponse.json({
user: { id: user.id, email: user.email, name: user.name },
needsThreadsBind,
});
} catch (error) {
return apiRouteErrorResponse(error, "auth/me");
}
}

View File

@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { hashPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function POST(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
name?: string;
};
const email = body.email?.trim().toLowerCase();
const password = body.password ?? "";
const name = body.name?.trim() || null;
if (!email || !password) {
return NextResponse.json({ error: "請輸入 Email 與密碼" }, { status: 400 });
}
if (password.length < 6) {
return NextResponse.json({ error: "密碼至少 6 個字元" }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "此 Email 已註冊" }, { status: 409 });
}
const user = await prisma.user.create({
data: {
email,
name,
passwordHash: hashPassword(password),
},
});
await createSession(user.id);
return NextResponse.json({
user: { id: user.id, email: user.email, name: user.name },
needsThreadsBind: true,
});
} catch (error) {
return apiRouteErrorResponse(error, "auth/register");
}
}

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountProfile } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function PATCH(request: Request) {
try {
const account = await getActiveAccountProfile();
if (!account) {
return NextResponse.json({ error: "尚未建立帳號" }, { status: 400 });
}
const body = (await request.json().catch(() => ({}))) as { automationEnabled?: boolean };
if (typeof body.automationEnabled !== "boolean") {
return NextResponse.json({ error: "缺少 automationEnabled" }, { status: 400 });
}
const updated = await prisma.account.update({
where: { id: account.id },
data: { automationEnabled: body.automationEnabled },
});
return NextResponse.json({ automationEnabled: updated.automationEnabled });
} catch (error) {
return apiRouteErrorResponse(error, "automation/account");
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { killAllAutomation } from "@/lib/automation/engine";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function POST() {
try {
const count = await killAllAutomation();
return NextResponse.json({ success: true, disabledAccounts: count });
} catch (error) {
return apiRouteErrorResponse(error, "automation/kill");
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where } = await requireUserAccountScope(accountId);
const logs = await prisma.actionLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: 60,
});
return NextResponse.json({ logs });
} catch (error) {
return apiRouteErrorResponse(error, "automation/logs");
}
}

View File

@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { getActiveAccountProfile } from "@/lib/account-context";
import { getRulesForAccount, upsertRule } from "@/lib/automation/rules";
import { isValidCron } from "@/lib/automation/cron-match";
import { AUTOMATION_TASK_TYPES, type AutomationTaskType } from "@/lib/automation/types";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function GET() {
try {
const account = await getActiveAccountProfile();
if (!account) {
return NextResponse.json({ error: "尚未建立帳號,請先到帳號策略新增人設" }, { status: 400 });
}
const rules = await getRulesForAccount(account.id);
return NextResponse.json({
accountId: account.id,
accountName: account.displayName ?? account.username ?? "未命名帳號",
automationEnabled: account.automationEnabled,
rules,
});
} catch (error) {
return apiRouteErrorResponse(error, "automation/rules");
}
}
export async function PATCH(request: Request) {
try {
const account = await getActiveAccountProfile();
if (!account) {
return NextResponse.json({ error: "尚未建立帳號" }, { status: 400 });
}
const body = (await request.json().catch(() => ({}))) as {
taskType?: string;
mode?: "manual" | "auto";
dailyCap?: number;
schedule?: string;
enabled?: boolean;
};
if (!body.taskType || !AUTOMATION_TASK_TYPES.includes(body.taskType as AutomationTaskType)) {
return NextResponse.json({ error: "無效的任務類型" }, { status: 400 });
}
if (body.schedule !== undefined && !isValidCron(body.schedule)) {
return NextResponse.json({ error: "無效的 cron 排程(需 5 欄位)" }, { status: 400 });
}
if (body.dailyCap !== undefined && (body.dailyCap < 0 || body.dailyCap > 500)) {
return NextResponse.json({ error: "每日上限需在 0500 之間" }, { status: 400 });
}
const rule = await upsertRule(account.id, body.taskType as AutomationTaskType, {
mode: body.mode,
dailyCap: body.dailyCap,
schedule: body.schedule,
enabled: body.enabled,
});
return NextResponse.json({ rule });
} catch (error) {
return apiRouteErrorResponse(error, "automation/rules/update");
}
}

View File

@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getActiveAccountProfile } from "@/lib/account-context";
import { runTaskForAccount } from "@/lib/automation/engine";
import { AUTOMATION_TASK_TYPES, type AutomationTaskType } from "@/lib/automation/types";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export const maxDuration = 300;
export async function POST(request: Request) {
try {
const account = await getActiveAccountProfile();
if (!account) {
return NextResponse.json({ error: "尚未建立帳號" }, { status: 400 });
}
const body = (await request.json().catch(() => ({}))) as { taskType?: string };
if (!body.taskType || !AUTOMATION_TASK_TYPES.includes(body.taskType as AutomationTaskType)) {
return NextResponse.json({ error: "無效的任務類型" }, { status: 400 });
}
const result = await runTaskForAccount(account.id, body.taskType as AutomationTaskType, {
triggeredBy: "manual",
});
return NextResponse.json({ result });
} catch (error) {
return apiRouteErrorResponse(error, "automation/run");
}
}

View File

@ -0,0 +1,138 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import { syncTopicProductSnapshot } from "@/lib/services/product-catalog";
import { parseMatchTags, serializeMatchTags } from "@/lib/types/product-match";
import { hasProductContext } from "@/lib/types/product-context";
export async function POST(request: Request) {
try {
const { brandId, label, context, matchTags } = (await request.json()) as {
brandId?: string;
label?: string;
context?: string;
matchTags?: string[];
};
if (!brandId || !label?.trim()) {
return NextResponse.json({ error: "請填寫品牌與產品名稱" }, { status: 400 });
}
if (!context?.trim() || !hasProductContext(context)) {
return NextResponse.json({ error: "請填寫產品特色" }, { status: 400 });
}
const accountId = await getActiveAccountId();
const { accountIds } = await requireUserAccountScope(accountId);
const brand = await prisma.brandProfile.findUnique({ where: { id: brandId } });
if (!brand || !isAccountInUserScope(accountIds, brand.accountId)) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
const product = await prisma.productProfile.create({
data: {
accountId: brand.accountId ?? accountId ?? undefined,
brandId,
label: label.trim(),
context: context.trim(),
matchTags: serializeMatchTags(matchTags ?? []),
},
});
return NextResponse.json({
product: {
...product,
matchTags: parseMatchTags(product.matchTags),
},
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "建立產品失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const { id, label, context, matchTags } = (await request.json()) as {
id?: string;
label?: string;
context?: string;
matchTags?: string[];
};
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.productProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到產品" }, { status: 404 });
}
if (context !== undefined && !hasProductContext(context)) {
return NextResponse.json({ error: "請填寫產品特色" }, { status: 400 });
}
const product = await prisma.productProfile.update({
where: { id },
data: {
...(label !== undefined && { label: label.trim() }),
...(context !== undefined && { context: context.trim() }),
...(matchTags !== undefined && { matchTags: serializeMatchTags(matchTags) }),
},
});
const linkedTopics = await prisma.topic.findMany({
where: {
OR: [{ productProfileId: id }, { brandProfileId: existing.brandId }],
},
select: { id: true },
});
for (const topic of linkedTopics) {
await syncTopicProductSnapshot(topic.id);
}
return NextResponse.json({
product: {
...product,
matchTags: parseMatchTags(product.matchTags),
},
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新產品失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const id = new URL(request.url).searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.productProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到產品" }, { status: 404 });
}
const linked = await prisma.topic.count({ where: { productProfileId: id } });
if (linked > 0) {
return NextResponse.json(
{ error: `仍有 ${linked} 個主題指定此產品,請先更換` },
{ status: 400 }
);
}
await prisma.productProfile.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除產品失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

241
app/api/brands/route.ts Normal file
View File

@ -0,0 +1,241 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import { migrateLegacyProductCatalog } from "@/lib/services/product-catalog";
import { parseMatchTags, serializeMatchTags } from "@/lib/types/product-match";
import { hasProductContext } from "@/lib/types/product-context";
function mapBrand(row: {
id: string;
name: string;
notes: string | null;
products: Array<{
id: string;
label: string;
context: string;
matchTags: string | null;
}>;
_count?: { products: number };
}) {
return {
id: row.id,
name: row.name,
notes: row.notes,
products: row.products.map((p) => ({
id: p.id,
label: p.label,
context: p.context,
matchTags: parseMatchTags(p.matchTags),
})),
productCount: row._count?.products ?? row.products.length,
};
}
function parsePositiveInt(value: string | null, fallback: number, max: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.min(parsed, max);
}
export async function GET(request: Request) {
try {
const accountId = await getActiveAccountId();
const { where: accountWhere, accountIds } = await requireUserAccountScope(accountId);
try {
await migrateLegacyProductCatalog(accountIds);
} catch (migrateErr) {
console.error("[brands] migrateLegacyProductCatalog failed:", migrateErr);
}
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brandId");
const fetchAll = searchParams.get("all") === "1";
const q = searchParams.get("q")?.trim() ?? "";
const page = parsePositiveInt(searchParams.get("page"), 1, 10_000);
const limit = fetchAll
? 500
: parsePositiveInt(searchParams.get("limit"), 8, 50);
const productLimit = parsePositiveInt(searchParams.get("productLimit"), 12, 200);
const searchWhere = q
? {
OR: [
{ name: { contains: q } },
{ notes: { contains: q } },
{
products: {
some: {
OR: [{ label: { contains: q } }, { matchTags: { contains: q } }],
},
},
},
],
}
: {};
const where = { ...accountWhere, ...searchWhere };
if (brandId) {
const brand = await prisma.brandProfile.findFirst({
where: { ...where, id: brandId },
include: {
products: { orderBy: { updatedAt: "desc" }, take: productLimit },
_count: { select: { products: true } },
},
});
if (!brand) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
return NextResponse.json({
brand: mapBrand(brand),
brands: [mapBrand(brand)],
total: 1,
page: 1,
limit: 1,
totalPages: 1,
});
}
const total = await prisma.brandProfile.count({ where });
const brands = await prisma.brandProfile.findMany({
where,
include: {
products: { orderBy: { updatedAt: "desc" }, take: productLimit },
_count: { select: { products: true } },
},
orderBy: { updatedAt: "desc" },
...(fetchAll ? {} : { skip: (page - 1) * limit, take: limit }),
});
const totalPages = fetchAll ? 1 : Math.max(1, Math.ceil(total / limit));
return NextResponse.json({
brands: brands.map(mapBrand),
total,
page: fetchAll ? 1 : page,
limit: fetchAll ? total : limit,
totalPages,
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取品牌失敗";
return NextResponse.json({ error: message, brands: [] }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { name, notes, product } = (await request.json()) as {
name?: string;
notes?: string;
product?: {
label?: string;
context?: string;
matchTags?: string[];
};
};
if (!name?.trim()) {
return NextResponse.json({ error: "請填寫品牌名稱" }, { status: 400 });
}
const accountId = await getActiveAccountId();
if (!accountId) {
return NextResponse.json({ error: "請先建立並選定經營帳號" }, { status: 400 });
}
const brand = await prisma.brandProfile.create({
data: {
accountId,
name: name.trim(),
notes: notes?.trim() || null,
...(product?.label && product.context && hasProductContext(product.context)
? {
products: {
create: {
accountId,
label: product.label.trim(),
context: product.context.trim(),
matchTags: serializeMatchTags(product.matchTags ?? []),
},
},
}
: {}),
},
include: { products: true },
});
return NextResponse.json({ brand: mapBrand(brand) });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "建立品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const { id, name, notes } = (await request.json()) as {
id?: string;
name?: string;
notes?: string | null;
};
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.brandProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
const brand = await prisma.brandProfile.update({
where: { id },
data: {
...(name !== undefined && { name: name.trim() }),
...(notes !== undefined && { notes: notes?.trim() || null }),
},
include: { products: { orderBy: { updatedAt: "desc" } } },
});
return NextResponse.json({ brand: mapBrand(brand) });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const id = new URL(request.url).searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.brandProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌" }, { status: 404 });
}
const linkedTopics = await prisma.topic.count({ where: { brandProfileId: id } });
if (linkedTopics > 0) {
return NextResponse.json(
{ error: `仍有 ${linkedTopics} 個主題使用此品牌,請先更換` },
{ status: 400 }
);
}
await prisma.brandProfile.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除品牌失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authErrorResponse } from "@/lib/auth/api";
import { getWorkspaceCapabilities } from "@/lib/capabilities";
export async function GET() {
try {
const capabilities = await getWorkspaceCapabilities();
return NextResponse.json({ capabilities });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取功能狀態失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { readFile } from "fs/promises";
import path from "path";
import { debugDirPath } from "@/lib/threads-browser/debug";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string; file: string }> }
) {
try {
const { id, file } = await params;
if (id.includes("..") || file.includes("..") || !file.endsWith(".png")) {
return NextResponse.json({ error: "無效的檔案" }, { status: 400 });
}
const bytes = await readFile(path.join(debugDirPath(), id, file));
return new NextResponse(bytes, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "private, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "找不到截圖" }, { status: 404 });
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import path from "path";
import { debugDirPath } from "@/lib/threads-browser/debug";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
if (id.includes("..") || id.includes("/")) {
return NextResponse.json({ error: "無效的 run id" }, { status: 400 });
}
const dir = path.join(debugDirPath(), id);
const manifest = JSON.parse(
await readFile(path.join(dir, "manifest.json"), "utf8")
);
const files = (await readdir(dir)).filter((f) => f.endsWith(".png"));
return NextResponse.json({ run: manifest, screenshots: files });
} catch {
return NextResponse.json({ error: "找不到 debug 紀錄" }, { status: 404 });
}
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { readdir, readFile, stat } from "fs/promises";
import path from "path";
import { debugDirPath } from "@/lib/threads-browser/debug";
export async function GET() {
try {
const root = debugDirPath();
let entries: string[] = [];
try {
entries = await readdir(root);
} catch {
return NextResponse.json({ runs: [] });
}
const runs = [];
for (const id of entries) {
const dir = path.join(root, id);
const info = await stat(dir).catch(() => null);
if (!info?.isDirectory()) continue;
let manifest: {
label?: string;
startedAt?: string;
steps?: Array<{ step?: string; at?: string; screenshot?: string }>;
} = {};
try {
manifest = JSON.parse(await readFile(path.join(dir, "manifest.json"), "utf8"));
} catch {
// ignore
}
runs.push({
id,
label: manifest.label ?? id,
startedAt: manifest.startedAt ?? info.mtime.toISOString(),
stepCount: manifest.steps?.length ?? 0,
lastStep: manifest.steps?.at(-1)?.step ?? null,
});
}
runs.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
return NextResponse.json({ runs: runs.slice(0, 30) });
} catch (error) {
const message = error instanceof Error ? error.message : "讀取 debug 紀錄失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateSettings } from "@/lib/user-settings";
import { generateDraftImage } from "@/lib/ai/generate-draft-image";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import {
MAX_DRAFT_IMAGES,
parseDraftImagePaths,
saveDraftImage,
syncDraftImageFields,
} from "@/lib/drafts/images";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await prisma.draft.findUnique({ where: { id } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
const existing = parseDraftImagePaths(draft);
if (existing.length >= MAX_DRAFT_IMAGES) {
return NextResponse.json(
{ error: `每篇草稿最多 ${MAX_DRAFT_IMAGES} 張配圖` },
{ status: 400 }
);
}
const settings = await getOrCreateSettings();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const { image, prompt } = await trackAiTask("AI 生成貼文配圖", () => generateDraftImage({
text: draft.text,
hook: draft.hook,
imageBrief: draft.imageBrief,
angle: draft.angle,
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
}));
const imagePath = await saveDraftImage(id, image.uint8Array, image.mimeType);
const nextPaths = [...existing, imagePath];
const updated = await prisma.draft.update({
where: { id },
data: syncDraftImageFields(nextPaths),
});
return NextResponse.json({
draft: updated,
imagePaths: parseDraftImagePaths(updated),
prompt,
});
} catch (error) {
const message = error instanceof Error ? error.message : "AI 生圖失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,165 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import {
deleteDraftImageFile,
deleteDraftImages,
isDraftOwnedImagePath,
MAX_DRAFT_IMAGES,
parseDraftImagePaths,
readDraftImage,
saveDraftImage,
syncDraftImageFields,
validateDraftImageFile,
} from "@/lib/drafts/images";
import { apiRouteErrorResponse } from "@/lib/auth/api";
function resolveImagePath(
draft: { id: string; imagePath?: string | null; imagePaths?: string | null },
requested?: string | null
): string | null {
const paths = parseDraftImagePaths(draft);
if (paths.length === 0) return null;
if (requested) {
const match = paths.find((p) => p === requested || p.endsWith(`/${requested}`));
if (match && isDraftOwnedImagePath(draft.id, match)) return match;
return null;
}
return paths[0] ?? null;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await prisma.draft.findUnique({ where: { id } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
const requested = new URL(request.url).searchParams.get("p");
const imagePath = resolveImagePath(draft, requested);
if (!imagePath) {
return NextResponse.json({ error: "沒有配圖" }, { status: 404 });
}
const image = await readDraftImage(imagePath);
if (!image) {
return NextResponse.json({ error: "找不到圖片檔案" }, { status: 404 });
}
return new NextResponse(new Uint8Array(image.bytes), {
headers: {
"Content-Type": image.mimeType,
"Cache-Control": "private, no-store, max-age=0",
},
});
} catch (error) {
return apiRouteErrorResponse(error, "drafts/image");
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await prisma.draft.findUnique({ where: { id } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
const formData = await request.formData();
const incoming = [
...formData.getAll("file"),
...formData.getAll("files"),
].filter((item): item is File => item instanceof File);
if (incoming.length === 0) {
return NextResponse.json({ error: "請上傳圖片檔案" }, { status: 400 });
}
const existing = parseDraftImagePaths(draft);
if (existing.length + incoming.length > MAX_DRAFT_IMAGES) {
return NextResponse.json(
{ error: `每篇草稿最多 ${MAX_DRAFT_IMAGES} 張配圖` },
{ status: 400 }
);
}
const saved: string[] = [];
for (const file of incoming) {
const validationError = validateDraftImageFile(file);
if (validationError) {
await deleteDraftImages(saved);
return NextResponse.json({ error: validationError }, { status: 400 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
saved.push(await saveDraftImage(id, bytes, file.type));
}
const nextPaths = [...existing, ...saved];
const updated = await prisma.draft.update({
where: { id },
data: syncDraftImageFields(nextPaths),
});
return NextResponse.json({
draft: updated,
imagePaths: parseDraftImagePaths(updated),
added: saved,
});
} catch (error) {
const message = error instanceof Error ? error.message : "上傳失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await prisma.draft.findUnique({ where: { id } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
const requested = new URL(request.url).searchParams.get("p");
const existing = parseDraftImagePaths(draft);
if (!requested) {
await deleteDraftImages(existing);
const updated = await prisma.draft.update({
where: { id },
data: { imagePath: null, imagePaths: null },
});
return NextResponse.json({ draft: updated, imagePaths: [] });
}
const target = existing.find((p) => p === requested || p.endsWith(`/${requested}`));
if (!target || !isDraftOwnedImagePath(id, target)) {
return NextResponse.json({ error: "找不到要刪除的配圖" }, { status: 404 });
}
await deleteDraftImageFile(target);
const nextPaths = existing.filter((p) => p !== target);
const updated = await prisma.draft.update({
where: { id },
data: syncDraftImageFields(nextPaths),
});
return NextResponse.json({
draft: updated,
imagePaths: parseDraftImagePaths(updated),
});
} catch (error) {
return apiRouteErrorResponse(error, "drafts/image/delete");
}
}

View File

@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { deleteDraftImages, parseDraftImagePaths } from "@/lib/drafts/images";
import { THREADS_MAX_CHARS } from "@/lib/utils";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = (await request.json().catch(() => ({}))) as {
text?: string;
status?: string;
angle?: string;
rationale?: string;
};
if (body.text && body.text.length > THREADS_MAX_CHARS) {
return NextResponse.json({ error: `超過 ${THREADS_MAX_CHARS} 字上限` }, { status: 400 });
}
const draft = await prisma.draft.update({
where: { id },
data: {
...(body.text !== undefined && { text: body.text }),
...(body.status !== undefined && { status: body.status }),
...(body.angle !== undefined && { angle: body.angle }),
...(body.rationale !== undefined && { rationale: body.rationale }),
},
});
return NextResponse.json({ draft });
} catch (error) {
return apiRouteErrorResponse(error, "drafts/patch");
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await prisma.draft.findUnique({ where: { id } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
await deleteDraftImages(parseDraftImagePaths(draft));
await prisma.draft.update({
where: { id },
data: { status: "REJECTED", imagePath: null, imagePaths: null },
});
return NextResponse.json({ success: true });
} catch (error) {
return apiRouteErrorResponse(error, "drafts/delete");
}
}

69
app/api/drafts/route.ts Normal file
View File

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
import { deleteDraftImages, parseDraftImagePaths } from "@/lib/drafts/images";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const statusParam = searchParams.get("status");
const statuses = statusParam
? statusParam.split(",").map((s) => s.trim())
: undefined;
const accountId = await getActiveAccountId();
const { where: accountWhere } = await requireUserAccountScope(accountId);
const drafts = await prisma.draft.findMany({
where: {
...accountWhere,
...(statuses
? { status: { in: statuses } }
: { status: { notIn: ["PUBLISHED", "REJECTED"] } }),
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ drafts });
} catch (error) {
return apiRouteErrorResponse(error, "drafts");
}
}
export async function DELETE(request: Request) {
try {
const body = (await request.json()) as { ids?: unknown };
const ids = Array.isArray(body.ids)
? body.ids.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
if (ids.length === 0) {
return NextResponse.json({ error: "請提供要刪除的草稿 ID" }, { status: 400 });
}
const accountId = await getActiveAccountId();
const { where: accountWhere } = await requireUserAccountScope(accountId);
const drafts = await prisma.draft.findMany({
where: {
id: { in: ids },
...accountWhere,
status: { not: "REJECTED" },
},
});
for (const draft of drafts) {
await deleteDraftImages(parseDraftImagePaths(draft));
await prisma.draft.update({
where: { id: draft.id },
data: { status: "REJECTED", imagePath: null, imagePaths: null },
});
}
return NextResponse.json({ deleted: drafts.length });
} catch (error) {
return apiRouteErrorResponse(error, "drafts");
}
}

View File

@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { prisma, resolvePersona } from "@/lib/db";
import { getOrCreateSettings } from "@/lib/user-settings";
import { getActiveAccountProfile } from "@/lib/account-context";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { generateInboundReplyDrafts } from "@/lib/ai/generate-replies";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 60;
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inbound = await prisma.inboundReply.findUnique({
where: { id },
include: { published: true },
});
if (!inbound) return NextResponse.json({ error: "找不到留言" }, { status: 404 });
const settings = await getOrCreateSettings();
const account = await getActiveAccountProfile();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const generated = await trackAiTask("生成互動回覆", () => generateInboundReplyDrafts({
persona: resolvePersona(settings, account),
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
publishedText: inbound.published?.text,
replyText: inbound.text,
authorName: inbound.authorName,
count: 2,
}));
await prisma.inboundReply.update({
where: { id },
data: {
sentiment: generated.sentiment,
intent: generated.intent,
status: "DRAFTED",
},
});
const drafts = [];
for (const draft of generated.drafts) {
drafts.push(
await prisma.replyDraft.create({
data: {
inboundReplyId: id,
text: draft.text,
rationale: draft.rationale,
},
})
);
}
return NextResponse.json({ drafts, sentiment: generated.sentiment, intent: generated.intent });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: "AI 回傳格式不完整(缺少回覆正文),請再試一次" },
{ status: 500 }
);
}
const message = error instanceof Error ? error.message : "生成回覆草稿失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
import { syncThreadsOwnPostsAndInsights } from "@/lib/services/threads-api-sync";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where } = await requireUserAccountScope(accountId);
const replies = await prisma.inboundReply.findMany({
where: { published: where },
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
include: {
published: true,
replyDrafts: { orderBy: { createdAt: "desc" } },
},
take: 80,
});
return NextResponse.json({ replies });
} catch (error) {
return apiRouteErrorResponse(error, "engagement/replies");
}
}
export async function POST(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as {
sync?: boolean;
postsLimit?: number;
repliesLimit?: number;
};
if (body.sync) {
const result = await syncThreadsOwnPostsAndInsights({
postsLimit: body.postsLimit ?? 25,
repliesLimit: body.repliesLimit ?? 25,
});
return NextResponse.json(result);
}
return NextResponse.json({ error: "缺少操作" }, { status: 400 });
} catch (error) {
return apiRouteErrorResponse(error, "engagement/replies/sync");
}
}

View File

@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { requireActiveThreadsAuth } from "@/lib/services/threads-auth-guard";
import { replyViaThreadsApi } from "@/lib/threads-api";
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireSessionUser();
const authCheck = await requireActiveThreadsAuth();
if (!authCheck.ok) {
return NextResponse.json({ error: authCheck.error }, { status: 401 });
}
const { id } = await params;
const draft = await prisma.replyDraft.findUnique({
where: { id },
include: { inboundReply: true },
});
if (!draft) return NextResponse.json({ error: "找不到回覆草稿" }, { status: 404 });
if (!draft.inboundReply.externalId) {
return NextResponse.json({ error: "此留言缺少 Threads reply id無法 API 回覆" }, { status: 400 });
}
const credentials = await getActiveThreadsCredentials();
if (!credentials) return NextResponse.json({ error: "Threads API 尚未連線" }, { status: 401 });
const result = await replyViaThreadsApi(credentials, {
replyToId: draft.inboundReply.externalId,
text: draft.text,
});
if (!result.success) {
return NextResponse.json({ error: result.error ?? "發布回覆失敗" }, { status: 500 });
}
await prisma.$transaction([
prisma.replyDraft.update({
where: { id },
data: { status: "PUBLISHED", publishedAt: new Date() },
}),
prisma.inboundReply.update({
where: { id: draft.inboundReplyId },
data: { status: "REPLIED" },
}),
]);
return NextResponse.json({ success: true, result });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "發布回覆失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { THREADS_MAX_CHARS } from "@/lib/utils";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = (await request.json().catch(() => ({}))) as { text?: string; status?: string };
if (body.text && body.text.length > THREADS_MAX_CHARS) {
return NextResponse.json({ error: `超過 ${THREADS_MAX_CHARS} 字上限` }, { status: 400 });
}
const draft = await prisma.replyDraft.update({
where: { id },
data: {
...(body.text !== undefined && { text: body.text }),
...(body.status !== undefined && { status: body.status }),
},
});
return NextResponse.json({ draft });
} catch (error) {
return apiRouteErrorResponse(error, "reply-drafts/patch");
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateSettings } from "@/lib/user-settings";
import { factCheckDraft } from "@/lib/ai/fact-check";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const body = (await request.json()) as { draftId?: string; text?: string };
const settings = await getOrCreateSettings();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
let text = body.text;
let angle: string | null | undefined;
let searchTag: string | null | undefined;
let topicLabel: string | null | undefined;
if (body.draftId) {
const draft = await prisma.draft.findUnique({ where: { id: body.draftId } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
text = draft.text;
angle = draft.angle;
searchTag = draft.searchTag;
if (draft.topicId) {
const topic = await prisma.topic.findUnique({ where: { id: draft.topicId } });
topicLabel = topic?.label;
}
}
if (!text?.trim()) {
return NextResponse.json({ error: "缺少貼文內容" }, { status: 400 });
}
const result = await trackAiTask("貼文事實查核", () => factCheckDraft({
text: text.trim(),
angle,
searchTag,
topicLabel,
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
}));
if (body.draftId) {
await prisma.draft.update({
where: { id: body.draftId },
data: { factCheckResult: JSON.stringify(result) },
});
}
return NextResponse.json({ factCheck: result });
} catch (error) {
const message = error instanceof Error ? error.message : "查證失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { applyQualityFilter } from "@/lib/services/scan";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const { scanId } = (await request.json()) as { scanId?: string };
if (!scanId) {
return NextResponse.json({ error: "缺少 scanId" }, { status: 400 });
}
const assessments = await trackAiTask("整理海巡結果", () => applyQualityFilter(scanId));
return NextResponse.json({ assessments });
} catch (error) {
const message = error instanceof Error ? error.message : "整理海巡結果失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { generateMatrixForScan } from "@/lib/services/matrix";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 180;
export async function POST(request: Request) {
try {
const { scanId, count } = (await request.json()) as { scanId?: string; count?: number };
if (!scanId) {
return NextResponse.json({ error: "缺少 scanId" }, { status: 400 });
}
const drafts = await trackAiTask("生成內容矩陣", () => generateMatrixForScan(scanId, count));
return NextResponse.json({ drafts });
} catch (error) {
const message = error instanceof Error ? error.message : "生成內容矩陣失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

27
app/api/generate/route.ts Normal file
View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { generateDraftsForScan } from "@/lib/services/generate";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const { scanId } = (await request.json()) as { scanId?: string };
if (!scanId) {
return NextResponse.json({ error: "缺少 scanId" }, { status: 400 });
}
const accountId = await getActiveAccountId();
const scan = await prisma.scan.findUnique({ where: { id: scanId } });
if (!scan || (accountId && scan.accountId !== accountId)) {
return NextResponse.json({ error: "找不到海巡紀錄" }, { status: 404 });
}
const drafts = await trackAiTask("生成貼文草稿", () => generateDraftsForScan(scanId));
return NextResponse.json({ drafts });
} catch (error) {
const message = error instanceof Error ? error.message : "生成草稿失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { cancelJob } from "@/lib/jobs/cancel";
import { prisma } from "@/lib/db";
import { requireSessionUser } from "@/lib/auth/session";
import { authErrorResponse } from "@/lib/auth/api";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireSessionUser();
const { id } = await params;
const job = await prisma.backgroundJob.findUnique({ where: { id } });
const ownedAccount = job?.accountId
? await prisma.account.findFirst({
where: { id: job.accountId, userId: user.id },
select: { id: true },
})
: null;
if (!job || !ownedAccount) {
return NextResponse.json({ error: "找不到任務" }, { status: 404 });
}
const result = await cancelJob(id);
if (!result.ok) {
return NextResponse.json({ error: result.error ?? "停止失敗" }, { status: 400 });
}
return NextResponse.json({ ok: true, message: "已停止背景任務" });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
return NextResponse.json({ error: "停止任務失敗" }, { status: 500 });
}
}

64
app/api/jobs/route.ts Normal file
View File

@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { recoverStaleBackgroundJobs } from "@/lib/jobs/runner";
export async function GET(request: Request) {
try {
const user = await requireSessionUser();
const { searchParams } = new URL(request.url);
const activeOnly = searchParams.get("active") === "1";
const topicId = searchParams.get("topicId");
const limit = Math.min(parseInt(searchParams.get("limit") ?? "10", 10), 30);
const userAccounts = await prisma.account.findMany({
where: { userId: user.id },
select: { id: true },
});
const accountIds = userAccounts.map((account) => account.id);
if (accountIds.length === 0) {
return NextResponse.json({ jobs: [] });
}
if (activeOnly) {
await recoverStaleBackgroundJobs();
}
const baseWhere = {
accountId: { in: accountIds },
...(topicId ? { topicId } : {}),
};
const jobs = activeOnly
? await prisma.backgroundJob.findMany({
where: { ...baseWhere, status: { in: ["pending", "running"] } },
orderBy: { createdAt: "desc" },
})
: await Promise.all([
prisma.backgroundJob.findMany({
where: { ...baseWhere, status: { in: ["pending", "running"] } },
orderBy: { createdAt: "desc" },
}),
prisma.backgroundJob.findMany({
where: baseWhere,
orderBy: { createdAt: "desc" },
take: limit,
}),
]).then(([active, recent]) => {
const merged = new Map([...active, ...recent].map((job) => [job.id, job]));
return [...merged.values()].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
);
});
return NextResponse.json({ jobs });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
console.error("[jobs GET]", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "載入任務失敗", jobs: [] },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
function escapeCsv(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const topicId = searchParams.get("topicId");
const accountId = await getActiveAccountId();
const { where: accountWhere } = await requireUserAccountScope(accountId);
const drafts = await prisma.draft.findMany({
where: {
sortOrder: { not: null },
...accountWhere,
...(topicId ? { topicId } : {}),
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
const headers = [
"序號",
"主題標籤",
"切角",
"Hook",
"貼文內文",
"參考重點",
"配圖說明",
"參考來源",
"撰寫理由",
"狀態",
"建立時間",
];
const rows = drafts.map((draft) => {
const sources: string[] = [];
if (draft.sources) {
try {
sources.push(...(JSON.parse(draft.sources) as string[]));
} catch {
sources.push(draft.sources);
}
}
return [
String(draft.sortOrder ?? ""),
draft.searchTag ?? "",
draft.angle ?? "",
draft.hook ?? "",
draft.text,
draft.referenceNotes ?? "",
draft.imageBrief ?? "",
sources.join(" | "),
draft.rationale ?? "",
draft.status,
new Date(draft.createdAt).toLocaleString("zh-TW"),
].map(escapeCsv);
});
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const bom = "\uFEFF";
return new NextResponse(bom + csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="content-matrix-${Date.now()}.csv"`,
},
});
} catch (error) {
return apiRouteErrorResponse(error, "matrix/export");
}
}

45
app/api/matrix/route.ts Normal file
View File

@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
const PAGE_SIZE = 10;
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const topicId = searchParams.get("topicId");
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1);
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") ?? String(PAGE_SIZE), 10) || PAGE_SIZE));
const accountId = await getActiveAccountId();
const { where: accountWhere } = await requireUserAccountScope(accountId);
const where = {
sortOrder: { not: null },
status: { not: "REJECTED" },
...accountWhere,
...(topicId ? { topicId } : {}),
};
const [drafts, total] = await Promise.all([
prisma.draft.findMany({
where,
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
skip: (page - 1) * limit,
take: limit,
}),
prisma.draft.count({ where }),
]);
return NextResponse.json({
rows: drafts,
total,
page,
limit,
totalPages: Math.ceil(total / limit) || 1,
});
} catch (error) {
return apiRouteErrorResponse(error, "matrix");
}
}

90
app/api/optimize/route.ts Normal file
View File

@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { prisma, resolvePersona } from "@/lib/db";
import { getActiveAccountProfile } from "@/lib/account-context";
import { getOrCreateSettings } from "@/lib/user-settings";
import { optimizePost, type OptimizeMode } from "@/lib/ai/optimize-post";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { THREADS_MAX_CHARS } from "@/lib/utils";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 60;
const VALID_MODES: OptimizeMode[] = ["polish", "hook", "shorter", "engaging", "custom"];
export async function POST(request: Request) {
try {
const body = await request.json();
const {
draftId,
text,
mode = "polish",
instruction,
save = false,
} = body as {
draftId?: string;
text?: string;
mode?: OptimizeMode;
instruction?: string;
save?: boolean;
};
if (!VALID_MODES.includes(mode)) {
return NextResponse.json({ error: "無效的優化模式" }, { status: 400 });
}
let sourceText = text?.trim();
let angle: string | null | undefined;
let draftRecord = null;
if (draftId) {
draftRecord = await prisma.draft.findUnique({ where: { id: draftId } });
if (!draftRecord) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
sourceText = sourceText || draftRecord.text;
angle = draftRecord.angle;
}
if (!sourceText) {
return NextResponse.json({ error: "缺少貼文內容" }, { status: 400 });
}
if (sourceText.length > THREADS_MAX_CHARS) {
return NextResponse.json({ error: `原文超過 ${THREADS_MAX_CHARS} 字上限` }, { status: 400 });
}
const settings = await getOrCreateSettings();
const account = await getActiveAccountProfile();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const result = await trackAiTask("AI 優化貼文", () => optimizePost({
text: sourceText,
mode,
instruction,
persona: resolvePersona(settings, account),
angle,
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
}));
if (save && draftRecord) {
await prisma.draft.update({
where: { id: draftRecord.id },
data: {
text: result.text,
status: "EDITED",
},
});
}
return NextResponse.json({
text: result.text,
summary: result.summary,
saved: save && !!draftRecord,
});
} catch (error) {
const message = error instanceof Error ? error.message : "AI 優化失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { requireActiveThreadsAuth } from "@/lib/services/threads-auth-guard";
import { replyViaThreadsApi } from "@/lib/threads-api";
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireSessionUser();
const authCheck = await requireActiveThreadsAuth();
if (!authCheck.ok) {
return NextResponse.json({ error: authCheck.error }, { status: 401 });
}
const { id } = await params;
const draft = await prisma.outreachDraft.findUnique({
where: { id },
include: { outreachTarget: { include: { scanItem: true } } },
});
if (!draft) return NextResponse.json({ error: "找不到獲客留言草稿" }, { status: 404 });
const externalId = draft.outreachTarget.scanItem.externalId;
if (!externalId) {
return NextResponse.json({ error: "目標貼文缺少 Threads media id請改用複製後手動留言" }, { status: 400 });
}
const credentials = await getActiveThreadsCredentials();
if (!credentials) return NextResponse.json({ error: "Threads API 尚未連線" }, { status: 401 });
const result = await replyViaThreadsApi(credentials, {
replyToId: externalId,
text: draft.text,
});
if (!result.success) {
return NextResponse.json({ error: result.error ?? "發布留言失敗" }, { status: 500 });
}
await prisma.$transaction([
prisma.outreachDraft.update({
where: { id },
data: { status: "PUBLISHED", publishedAt: new Date() },
}),
prisma.outreachTarget.update({
where: { id: draft.outreachTargetId },
data: { status: "COMMENTED" },
}),
]);
return NextResponse.json({ success: true, result });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "發布留言失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { THREADS_MAX_CHARS } from "@/lib/utils";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
async function findOwnedDraft(id: string) {
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const draft = await prisma.outreachDraft.findUnique({
where: { id },
include: {
outreachTarget: {
include: { scanItem: { include: { scan: true } }, _count: { select: { drafts: true } } },
},
},
});
if (!draft || !isAccountInUserScope(accountIds, draft.outreachTarget.scanItem.scan.accountId)) {
return null;
}
return draft;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = (await request.json()) as { text?: string; status?: string };
if (body.text && body.text.length > THREADS_MAX_CHARS) {
return NextResponse.json({ error: `超過 ${THREADS_MAX_CHARS} 字上限` }, { status: 400 });
}
const existing = await findOwnedDraft(id);
if (!existing) return NextResponse.json({ error: "找不到留言草稿" }, { status: 404 });
const draft = await prisma.outreachDraft.update({
where: { id },
data: {
...(body.text !== undefined && { text: body.text }),
...(body.status !== undefined && { status: body.status }),
},
});
return NextResponse.json({ draft });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新留言草稿失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const draft = await findOwnedDraft(id);
if (!draft) return NextResponse.json({ error: "找不到留言草稿" }, { status: 404 });
if (draft.status === "PUBLISHED") {
return NextResponse.json(
{ error: "這則留言已發布到 Threads不能只刪除本地紀錄" },
{ status: 409 }
);
}
const removeTarget = draft.outreachTarget._count.drafts <= 1;
if (removeTarget) {
await prisma.outreachTarget.delete({ where: { id: draft.outreachTargetId } });
} else {
await prisma.outreachDraft.delete({ where: { id } });
}
return NextResponse.json({ deleted: true, removedTarget: removeTarget });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除留言草稿失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
import { prisma } from "@/lib/db";
export async function DELETE(request: Request) {
try {
const body = (await request.json()) as { ids?: string[] };
const ids = [...new Set((body.ids ?? []).filter(Boolean))].slice(0, 100);
if (ids.length === 0) {
return NextResponse.json({ error: "請至少選擇一則留言草稿" }, { status: 400 });
}
const { where } = await requireUserAccountScope(await getActiveAccountId());
const drafts = await prisma.outreachDraft.findMany({
where: {
id: { in: ids },
outreachTarget: { scanItem: { scan: { ...where, scanGoal: "placement" } } },
},
select: { id: true, status: true, outreachTargetId: true },
});
if (drafts.length !== ids.length) {
return NextResponse.json({ error: "部分草稿不存在或不屬於目前帳號" }, { status: 403 });
}
if (drafts.some((draft) => draft.status === "PUBLISHED")) {
return NextResponse.json({ error: "已發布留言不能只刪除本地紀錄" }, { status: 409 });
}
const targetIds = [...new Set(drafts.map((draft) => draft.outreachTargetId))];
const result = await prisma.$transaction(async (tx) => {
const deleted = await tx.outreachDraft.deleteMany({ where: { id: { in: ids } } });
const emptyTargets = await tx.outreachTarget.findMany({
where: { id: { in: targetIds }, drafts: { none: {} } },
select: { id: true },
});
if (emptyTargets.length > 0) {
await tx.outreachTarget.deleteMany({
where: { id: { in: emptyTargets.map((target) => target.id) } },
});
}
return { deleted: deleted.count, removedTargets: emptyTargets.length };
});
return NextResponse.json(result);
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "批次刪除失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { generateOutreachForScanItem } from "@/lib/services/outreach";
import { trackAiTask } from "@/lib/jobs/track";
import { isPlacementGoal } from "@/lib/types/topic-goal";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
scanItemId?: string;
scanId?: string;
limit?: number;
productBrief?: string | null;
productContext?: string | null;
topicId?: string;
};
if (body.scanItemId) {
const accountId = await getActiveAccountId();
const item = await prisma.scanItem.findUnique({
where: { id: body.scanItemId },
include: { scan: { include: { topic: true } } },
});
if (!item || (accountId && item.scan.accountId !== accountId)) {
return NextResponse.json({ error: "找不到貼文" }, { status: 404 });
}
if (!isPlacementGoal(item.scan.scanGoal ?? item.scan.topic.topicGoal)) {
return NextResponse.json({ error: "這篇來自拷貝忍者,不會送到找 TA" }, { status: 400 });
}
if (body.productContext !== undefined && body.productContext !== null) {
await prisma.topic.update({
where: { id: item.scan.topicId },
data: { productContext: body.productContext.trim() || null },
});
}
// 傳「原始」品牌資料JSON讓 service 自行格式化並取出 CTA網址
const rawBrief = body.productBrief?.trim() || body.productContext?.trim() || undefined;
const target = await trackAiTask("生成找 TA 話術", () =>
generateOutreachForScanItem(body.scanItemId!, { productBrief: rawBrief })
);
return NextResponse.json({ target });
}
if (!body.scanId) {
return NextResponse.json({ error: "缺少 scanItemId 或 scanId" }, { status: 400 });
}
const accountId = await getActiveAccountId();
const scan = await prisma.scan.findUnique({
where: { id: body.scanId },
include: {
topic: true,
items: {
where: { qualityTier: { not: "EXCLUDE" } },
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: body.limit ?? 8,
include: { replies: { orderBy: { likeCount: "desc" }, take: 5 } },
},
},
});
if (!scan || (accountId && scan.accountId !== accountId)) {
return NextResponse.json({ error: "找不到海巡紀錄" }, { status: 404 });
}
if (!isPlacementGoal(scan.scanGoal ?? scan.topic.topicGoal)) {
return NextResponse.json({ error: "只有找 TA 任務可以批次生成留言" }, { status: 400 });
}
const created = await trackAiTask("批次生成找 TA 話術", async () => {
const targets = [];
for (const item of scan.items) {
targets.push(await generateOutreachForScanItem(item.id));
}
return targets;
});
return NextResponse.json({ targets: created });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: "AI 回傳格式不完整(缺少留言正文),請再試一次" },
{ status: 500 }
);
}
const message = error instanceof Error ? error.message : "生成獲客留言失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

39
app/api/outreach/route.ts Normal file
View File

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
export async function GET(request: Request) {
try {
const accountId = await getActiveAccountId();
const { where } = await requireUserAccountScope(accountId);
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number.parseInt(searchParams.get("page") ?? "1", 10) || 1);
const limit = Math.min(30, Math.max(5, Number.parseInt(searchParams.get("limit") ?? "10", 10) || 10));
const targetWhere = { scanItem: { scan: { ...where, scanGoal: "placement" } } };
const [targets, total] = await Promise.all([
prisma.outreachTarget.findMany({
where: targetWhere,
orderBy: { createdAt: "desc" },
include: {
scanItem: { include: { scan: { include: { topic: true } }, replies: true } },
drafts: { orderBy: { createdAt: "desc" } },
},
skip: (page - 1) * limit,
take: limit,
}),
prisma.outreachTarget.count({ where: targetWhere }),
]);
return NextResponse.json({
targets,
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
});
} catch (error) {
return apiRouteErrorResponse(error, "outreach");
}
}

View File

@ -0,0 +1,137 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import {
migrateOrphanProductContexts,
syncProfileContextToTopics,
validateProfileContext,
} from "@/lib/services/product-profile";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where, accountIds } = await requireUserAccountScope(accountId);
try {
await migrateOrphanProductContexts(accountIds);
} catch (migrateErr) {
console.error("[product-profiles] migrate failed:", migrateErr);
}
const profiles = await prisma.productProfile.findMany({
where,
orderBy: { updatedAt: "desc" },
});
return NextResponse.json({ profiles });
} catch (error) {
return apiRouteErrorResponse(error, "product-profiles");
}
}
export async function POST(request: Request) {
try {
const { label, context } = (await request.json()) as {
label?: string;
context?: string;
};
if (!label?.trim()) {
return NextResponse.json({ error: "請填寫名稱" }, { status: 400 });
}
const serialized = context?.trim() || "";
if (!validateProfileContext(serialized)) {
return NextResponse.json({ error: "請填寫品牌、產品或特色" }, { status: 400 });
}
const accountId = await getActiveAccountId();
if (!accountId) {
return NextResponse.json({ error: "請先建立並選定經營帳號" }, { status: 400 });
}
const profile = await prisma.productProfile.create({
data: {
accountId,
label: label.trim(),
context: serialized,
},
});
return NextResponse.json({ profile });
} catch (error) {
return apiRouteErrorResponse(error, "product-profiles/create");
}
}
export async function PATCH(request: Request) {
try {
const { id, label, context } = (await request.json()) as {
id?: string;
label?: string;
context?: string;
};
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.productProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌與產品" }, { status: 404 });
}
const nextContext = context !== undefined ? context.trim() : existing.context;
if (context !== undefined && !validateProfileContext(nextContext)) {
return NextResponse.json({ error: "請填寫品牌、產品或特色" }, { status: 400 });
}
const profile = await prisma.productProfile.update({
where: { id },
data: {
...(label !== undefined && { label: label.trim() }),
...(context !== undefined && { context: nextContext }),
},
});
if (context !== undefined) {
await syncProfileContextToTopics(id, nextContext);
}
return NextResponse.json({ profile });
} catch (error) {
return apiRouteErrorResponse(error, "product-profiles/update");
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.productProfile.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到品牌與產品" }, { status: 404 });
}
const linked = await prisma.topic.count({ where: { productProfileId: id } });
if (linked > 0) {
return NextResponse.json(
{ error: `仍有 ${linked} 個主題使用此品牌,請先更換或刪除主題` },
{ status: 400 }
);
}
await prisma.productProfile.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
return apiRouteErrorResponse(error, "product-profiles/delete");
}
}

214
app/api/publish/route.ts Normal file
View File

@ -0,0 +1,214 @@
import { NextResponse } from "next/server";
import { existsSync } from "fs";
import { prisma } from "@/lib/db";
import { getActiveAccountConnectionSettings } from "@/lib/account-connection-settings";
import { getOrCreateSettings } from "@/lib/user-settings";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { requirePublishAuth } from "@/lib/services/threads-auth-guard";
import { factCheckDraft } from "@/lib/ai/fact-check";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import {
deleteDraftImages,
draftImageAbsolutePath,
draftImagePublicUrl,
parseDraftImagePaths,
} from "@/lib/drafts/images";
import { ensureActiveSession, publish, SessionError } from "@/lib/threads-browser";
import {
getAppBaseUrl,
isPubliclyReachableUrl,
publishViaThreadsApi,
} from "@/lib/threads-api";
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const user = await requireSessionUser();
const authCheck = await requirePublishAuth();
if (!authCheck.ok) {
return NextResponse.json({ error: authCheck.error }, { status: 401 });
}
const { draftId } = (await request.json()) as { draftId?: string };
if (!draftId) {
return NextResponse.json({ error: "缺少 draftId" }, { status: 400 });
}
const draft = await prisma.draft.findUnique({ where: { id: draftId } });
if (!draft) {
return NextResponse.json({ error: "找不到草稿" }, { status: 404 });
}
if (draft.accountId) {
await assertAccountOwnedByUser(user.id, draft.accountId);
}
const [settings, connection] = await Promise.all([
getOrCreateSettings(),
getActiveAccountConnectionSettings(),
]);
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
let topicLabel: string | undefined;
if (draft.topicId) {
const topic = await prisma.topic.findUnique({ where: { id: draft.topicId } });
topicLabel = topic?.label;
}
const factCheck = await trackAiTask("發布前內容查核", () => factCheckDraft({
text: draft.text,
angle: draft.angle,
searchTag: draft.searchTag,
topicLabel,
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
}));
if (factCheck.isKnowledgeContent && !factCheck.passed) {
await prisma.draft.update({
where: { id: draftId },
data: { factCheckResult: JSON.stringify(factCheck) },
});
return NextResponse.json(
{
error: "知識型內容未通過網路查證,請修正後再發布",
factCheck,
},
{ status: 422 }
);
}
const draftImagePaths = parseDraftImagePaths(draft).filter((imagePath) =>
existsSync(draftImageAbsolutePath(imagePath))
);
const hasImage = draftImagePaths.length > 0;
// API 優先:帳號有連官方 API 就先用 API 發布;失敗或未連線才退回瀏覽器。
const credentials = connection.publishViaApi
? await getActiveThreadsCredentials().catch(() => null)
: null;
type PublishOutcome = {
success: boolean;
permalink?: string;
mediaId?: string;
error?: string;
debugRunId?: string;
method?: string;
warning?: string;
};
async function tryApiPublish(creds: NonNullable<typeof credentials>): Promise<PublishOutcome> {
let imageUrl: string | undefined;
let warning: string | undefined;
if (hasImage) {
const primaryPath = draftImagePaths[0];
const publicUrl = draftImagePublicUrl(
getAppBaseUrl(request, settings.appUrl),
draftId!,
primaryPath
);
if (isPubliclyReachableUrl(publicUrl)) {
imageUrl = publicUrl;
if (draftImagePaths.length > 1) {
warning = "API 發布目前只會附上第一張配圖。";
}
} else {
// 配圖需公開網址才能走 API交給瀏覽器發布以保留配圖。
return { success: false, method: "api", error: "配圖需公開網址,改用瀏覽器發布" };
}
}
const apiResult = await publishViaThreadsApi(creds, { text: draft!.text, imageUrl });
return {
success: apiResult.success,
permalink: apiResult.permalink,
error: apiResult.error,
method: "api",
warning: apiResult.warning ?? warning,
};
}
async function browserPublish(): Promise<PublishOutcome> {
const session = await ensureActiveSession();
const browserResult = await publish(
session,
draft!.text,
draftImagePaths.map((imagePath) => draftImageAbsolutePath(imagePath))
);
return {
success: browserResult.success,
permalink: browserResult.permalink,
error: browserResult.error,
debugRunId: browserResult.debugRunId,
method: "browser",
};
}
let result: PublishOutcome | null = null;
if (credentials) {
try {
const apiOutcome = await tryApiPublish(credentials);
if (apiOutcome.success) result = apiOutcome;
} catch {
// 落入瀏覽器備援
}
}
if (!result) {
result = await browserPublish();
}
if (!result.success) {
return NextResponse.json(
{
error: result.error ?? "發布失敗",
method: result.method,
debugRunId: result.debugRunId,
},
{ status: 500 }
);
}
const published = await prisma.$transaction(async (tx) => {
const record = await tx.published.create({
data: {
accountId: draft.accountId,
topicId: draft.topicId,
externalId: result.mediaId,
text: draft.text,
angle: draft.angle,
hook: draft.hook,
rationale: draft.rationale,
draftType: draft.draftType,
permalink: result.permalink,
},
});
await deleteDraftImages(draftImagePaths);
await tx.draft.delete({ where: { id: draftId } });
return record;
});
return NextResponse.json({
published,
permalink: result.permalink,
factCheck,
method: result.method,
warning: result.warning,
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
if (error instanceof SessionError) {
return NextResponse.json({ error: error.message }, { status: 401 });
}
const message = error instanceof Error ? error.message : "發布失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where } = await requireUserAccountScope(accountId);
const published = await prisma.published.findMany({
where,
orderBy: { publishedAt: "desc" },
select: {
id: true,
accountId: true,
text: true,
angle: true,
hook: true,
rationale: true,
draftType: true,
permalink: true,
publishedAt: true,
views: true,
likes: true,
replies: true,
},
});
return NextResponse.json({ published });
} catch (error) {
return apiRouteErrorResponse(error, "published");
}
}

View File

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { prisma, resolvePersona } from "@/lib/db";
import { getActiveAccountProfile } from "@/lib/account-context";
import { getOrCreateSettings } from "@/lib/user-settings";
import { refineResearchMap, type RefineChatMessage } from "@/lib/ai/refine-research-map";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import type { ResearchMap } from "@/lib/types/research";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
topicId?: string;
researchMap?: ResearchMap;
message?: string;
history?: RefineChatMessage[];
};
const { topicId, researchMap, message, history } = body;
if (!topicId || !researchMap || !message?.trim()) {
return NextResponse.json(
{ error: "缺少 topicId、researchMap 或 message" },
{ status: 400 }
);
}
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const settings = await getOrCreateSettings();
const account = await getActiveAccountProfile();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const result = await trackAiTask("微調研究地圖", () => refineResearchMap({
label: topic.label,
query: topic.query,
brief: topic.brief,
persona: resolvePersona(settings, account),
currentMap: researchMap,
message: message.trim(),
history: history ?? [],
aiProvider: settings.researchAiProvider ?? settings.aiProvider,
aiModel: settings.researchAiModel ?? settings.aiModel,
apiKeys,
}));
return NextResponse.json(result);
} catch (error) {
const msg = error instanceof Error ? error.message : "微調失敗";
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { replicateScanItem } from "@/lib/services/viral";
import { trackAiTask } from "@/lib/jobs/track";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const { scanItemId } = (await request.json()) as { scanItemId?: string };
if (!scanItemId) {
return NextResponse.json({ error: "缺少 scanItemId" }, { status: 400 });
}
const result = await trackAiTask("仿寫爆款貼文", () => replicateScanItem(scanItemId));
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "複製爆款失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

57
app/api/scan/route.ts Normal file
View File

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { enqueueJob, findActiveJob } from "@/lib/jobs/runner";
import { scheduleBackgroundJob } from "@/lib/jobs/schedule";
import { runScanForAllActiveTopics } from "@/lib/services/scan";
export const maxDuration = 300;
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const accountId = await getActiveAccountId();
const { topicId, useTags, selectedTags } = body as {
topicId?: string;
useTags?: boolean;
selectedTags?: string[];
};
if (topicId) {
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
if (!topic || (accountId && topic.accountId !== accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const existing = await findActiveJob(topicId, "scan");
if (existing) {
return NextResponse.json({
jobId: existing.id,
status: existing.status,
message: "已有海巡任務在背景執行中",
});
}
const job = await enqueueJob({
type: "scan",
topicId,
label: `${topic.label} · 標籤海巡`,
payload: { topicId, useTags, selectedTags },
});
scheduleBackgroundJob(job.id);
return NextResponse.json({
jobId: job.id,
status: "pending",
message: "已於背景執行,可自由切換頁面",
});
}
const scans = await runScanForAllActiveTopics(accountId);
return NextResponse.json({ scans });
} catch (error) {
const message = error instanceof Error ? error.message : "海巡失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

42
app/api/scans/route.ts Normal file
View File

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { requireUserAccountScope } from "@/lib/auth/user-scope";
export async function GET(request: Request) {
try {
const accountId = await getActiveAccountId();
const { where: accountWhere } = await requireUserAccountScope(accountId);
const topicId = new URL(request.url).searchParams.get("topicId");
const where = {
...accountWhere,
...(topicId ? { topicId } : {}),
};
const scans = await prisma.scan.findMany({
where,
orderBy: { createdAt: "desc" },
take: 20,
include: {
topic: true,
items: {
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
include: {
replies: { orderBy: { likeCount: "desc" } },
outreachTargets: {
include: { drafts: { orderBy: { createdAt: "desc" } } },
},
},
},
},
});
return NextResponse.json({ scans });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取海巡紀錄失敗";
console.error("[scans] GET failed:", error);
return NextResponse.json({ error: message, scans: [] }, { status: 500 });
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { clearBrowserSession } from "@/lib/threads-browser";
export async function POST() {
try {
const result = await clearBrowserSession();
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "清除 session 失敗";
return NextResponse.json({ cleared: false, message }, { status: 500 });
}
}

View File

@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { getActiveAccountProfile } from "@/lib/account-context";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { authErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import {
normalizeStorageStateInput,
type PlaywrightStorageState,
} from "@/lib/threads-browser/storage-state";
import { probeSession, saveAccountSession } from "@/lib/threads-browser";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const user = await requireSessionUser();
const body = (await request.json()) as {
storageState?: string | PlaywrightStorageState;
accountId?: string;
};
if (!body.storageState) {
return NextResponse.json({ error: "缺少 storageState" }, { status: 400 });
}
const normalized = normalizeStorageStateInput(body.storageState);
if (!normalized.ok) {
return NextResponse.json({ error: normalized.error }, { status: 400 });
}
const account = body.accountId
? await assertAccountOwnedByUser(user.id, body.accountId)
: await getActiveAccountProfile();
if (!account) {
return NextResponse.json(
{ error: "請先在側欄選擇或建立經營帳號,再匯入 session" },
{ status: 400 }
);
}
await saveAccountSession(account.id, normalized.storageState, { valid: true });
return NextResponse.json({
success: true,
valid: true,
synced: true,
username: account.username,
message: account.username
? `Session 已同步:@${account.username}`
: "Session 已同步到 server",
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "匯入 session 失敗";
return NextResponse.json({ success: false, valid: false, message }, { status: 500 });
}
}
/** 不啟動瀏覽器,僅檢查 JSON 格式(供 UI 預覽用)。 */
export async function PUT(request: Request) {
try {
await requireSessionUser();
const body = (await request.json()) as {
storageState?: string | PlaywrightStorageState;
};
if (!body.storageState) {
return NextResponse.json({ error: "缺少 storageState" }, { status: 400 });
}
const normalized = normalizeStorageStateInput(body.storageState);
if (!normalized.ok) {
return NextResponse.json({ valid: false, message: normalized.error }, { status: 400 });
}
const valid = await probeSession(normalized.storageState);
return NextResponse.json({
valid,
message: valid ? "格式正確Threads session 有效" : "格式正確,但 Threads session 已失效",
});
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "驗證失敗";
return NextResponse.json({ valid: false, message }, { status: 500 });
}
}

View File

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { startLoginFlow } from "@/lib/threads-browser";
export const maxDuration = 300;
export async function POST(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as { clearSession?: boolean };
const result = await startLoginFlow({
clearSession: body.clearSession !== false,
});
return NextResponse.json(result, { status: result.success ? 200 : 400 });
} catch (error) {
const message = error instanceof Error ? error.message : "登入流程失敗";
return NextResponse.json({ success: false, message }, { status: 500 });
}
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { refreshSession } from "@/lib/threads-browser";
export const maxDuration = 60;
export async function POST() {
try {
const result = await refreshSession();
return NextResponse.json(result, { status: result.valid ? 200 : 401 });
} catch (error) {
const message = error instanceof Error ? error.message : "更新 session 失敗";
return NextResponse.json({ valid: false, message, refreshed: false }, { status: 500 });
}
}

View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getStoredSessionStatus } from "@/lib/threads-browser";
export async function GET() {
try {
const status = await getStoredSessionStatus();
return NextResponse.json(status);
} catch (error) {
const message = error instanceof Error ? error.message : "檢查 session 失敗";
return NextResponse.json({ valid: false, message }, { status: 500 });
}
}

95
app/api/settings/route.ts Normal file
View File

@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { getOrCreateSettingsForUser } from "@/lib/user-settings";
import {
getApiKeyStatus,
getMaskedProviderApiKeys,
mergeProviderApiKeys,
parseProviderApiKeys,
serializeProviderApiKeys,
type ProviderApiKeys,
} from "@/lib/ai/keys";
import { getAppBaseUrl } from "@/lib/threads-api";
import type { Setting } from "@prisma/client";
function toPublicSettings(settings: Setting, request?: Request) {
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
return {
id: settings.id,
persona: settings.persona,
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
researchAiProvider: settings.researchAiProvider,
researchAiModel: settings.researchAiModel,
draftsPerScan: settings.draftsPerScan,
matrixRows: settings.matrixRows,
scanCron: settings.scanCron,
appUrl: settings.appUrl,
threadsOAuthRedirectUri: request
? `${getAppBaseUrl(request, settings.appUrl)}/api/threads/oauth/callback`
: null,
apiKeys: getMaskedProviderApiKeys(apiKeys),
apiKeysConfigured: getApiKeyStatus(apiKeys),
};
}
export async function GET(request: Request) {
try {
const user = await requireSessionUser();
const settings = await getOrCreateSettingsForUser(user.id);
return NextResponse.json(toPublicSettings(settings, request));
} catch (error) {
return apiRouteErrorResponse(error, "settings");
}
}
export async function PATCH(request: Request) {
try {
const user = await requireSessionUser();
const body = await request.json();
const current = await getOrCreateSettingsForUser(user.id);
const existingKeys = parseProviderApiKeys(current.providerApiKeys);
let providerApiKeys = existingKeys;
if (body.apiKeys && typeof body.apiKeys === "object") {
providerApiKeys = mergeProviderApiKeys(existingKeys, body.apiKeys as ProviderApiKeys);
}
const settings = await prisma.setting.update({
where: { userId: user.id },
data: {
...(body.persona !== undefined && { persona: body.persona }),
...(body.aiProvider !== undefined && { aiProvider: body.aiProvider }),
...(body.aiModel !== undefined && { aiModel: body.aiModel }),
...(body.researchAiProvider !== undefined && { researchAiProvider: body.researchAiProvider }),
...(body.researchAiModel !== undefined && { researchAiModel: body.researchAiModel }),
...(body.draftsPerScan !== undefined && { draftsPerScan: body.draftsPerScan }),
...(body.matrixRows !== undefined && { matrixRows: body.matrixRows }),
...(body.scanCron !== undefined && { scanCron: body.scanCron }),
...(body.appUrl !== undefined && {
appUrl: (() => {
const trimmed = body.appUrl?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (!["http:", "https:"].includes(url.protocol)) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
})(),
}),
...(body.apiKeys !== undefined && {
providerApiKeys: serializeProviderApiKeys(providerApiKeys),
}),
},
});
return NextResponse.json(toPublicSettings(settings, request));
} catch (error) {
return apiRouteErrorResponse(error, "settings/update");
}
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getActiveAccountId } from "@/lib/account-context";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { apiRouteErrorResponse } from "@/lib/auth/api";
import { requireSessionUser } from "@/lib/auth/session";
import { clearThreadsAuthForAccount } from "@/lib/threads-api";
export async function POST(request: Request) {
try {
const user = await requireSessionUser();
const body = (await request.json().catch(() => ({}))) as { accountId?: string };
const accountId = body.accountId ?? (await getActiveAccountId());
if (!accountId) {
return NextResponse.json({ error: "沒有可中斷的帳號" }, { status: 400 });
}
await assertAccountOwnedByUser(user.id, accountId);
await clearThreadsAuthForAccount(accountId);
return NextResponse.json({ success: true });
} catch (error) {
return apiRouteErrorResponse(error, "threads/disconnect");
}
}

View File

@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { getOrCreateSettings } from "@/lib/user-settings";
import { getActiveAccountId } from "@/lib/account-context";
import { buildThreadsOAuthUrl, getAppBaseUrl, getThreadsAppId } from "@/lib/threads-api";
export async function GET(request: Request) {
let homeUrl: URL;
try {
homeUrl = new URL("/", new URL(request.url).origin);
} catch {
return NextResponse.json({ error: "invalid request url" }, { status: 400 });
}
try {
const settings = await getOrCreateSettings();
const appId = getThreadsAppId();
homeUrl = new URL("/", getAppBaseUrl(request, settings.appUrl));
if (!appId) {
homeUrl.searchParams.set(
"threads_error",
"系統尚未設定 Threads App請管理員在 .env 填入 THREADS_APP_ID"
);
return NextResponse.redirect(homeUrl);
}
const accountId = await getActiveAccountId();
if (!accountId) {
homeUrl.searchParams.set("threads_error", "no_active_account");
return NextResponse.redirect(homeUrl);
}
try {
const url = buildThreadsOAuthUrl(request, appId, accountId, settings.appUrl);
return NextResponse.redirect(url);
} catch (err) {
const message = err instanceof Error ? err.message : "invalid_app_id";
homeUrl.searchParams.set("threads_error", message);
return NextResponse.redirect(homeUrl);
}
} catch (err) {
const message = err instanceof Error ? err.message : "authorize_failed";
homeUrl.searchParams.set("threads_error", message);
return NextResponse.redirect(homeUrl);
}
}

View File

@ -0,0 +1,116 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateSettingsForUser } from "@/lib/user-settings";
import { getActiveAccountId } from "@/lib/account-context";
import { assertAccountOwnedByUser } from "@/lib/auth/accounts";
import { getSessionUser } from "@/lib/auth/session";
import {
exchangeCodeForToken,
exchangeForLongLivedToken,
fetchThreadsProfile,
getAppBaseUrl,
getThreadsAppId,
getThreadsAppSecret,
saveThreadsAuthForAccount,
} from "@/lib/threads-api";
export async function GET(request: Request) {
let homeUrl: URL;
try {
homeUrl = new URL("/matrix", new URL(request.url).origin);
} catch {
return NextResponse.json({ error: "invalid request url" }, { status: 400 });
}
try {
const sessionUser = await getSessionUser();
const settings = sessionUser ? await getOrCreateSettingsForUser(sessionUser.id) : null;
homeUrl = new URL("/matrix", getAppBaseUrl(request, settings?.appUrl));
const { searchParams } = new URL(request.url);
const error = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
if (error) {
homeUrl.searchParams.set("threads_error", error);
return NextResponse.redirect(homeUrl);
}
if (!code) {
homeUrl.searchParams.set("threads_error", "missing_code");
return NextResponse.redirect(homeUrl);
}
if (!sessionUser || !settings) {
homeUrl.searchParams.set("threads_error", "請先登入後再綁定 Threads");
return NextResponse.redirect(homeUrl);
}
const appId = getThreadsAppId();
const appSecret = getThreadsAppSecret();
if (!appId || !appSecret) {
homeUrl.searchParams.set("threads_error", "missing_app_credentials");
return NextResponse.redirect(homeUrl);
}
const stateAccountId = state && state !== "threadtools" ? state : null;
const accountId = stateAccountId ?? (await getActiveAccountId());
if (!accountId) {
homeUrl.searchParams.set("threads_error", "no_active_account");
return NextResponse.redirect(homeUrl);
}
let accountExists;
try {
accountExists = await assertAccountOwnedByUser(sessionUser.id, accountId);
} catch {
homeUrl.searchParams.set("threads_error", "account_not_found");
return NextResponse.redirect(homeUrl);
}
const redirectUri = `${getAppBaseUrl(request, settings.appUrl)}/api/threads/oauth/callback`;
const shortLived = await exchangeCodeForToken({
appId,
appSecret,
code,
redirectUri,
});
const longLived = await exchangeForLongLivedToken(appSecret, shortLived.accessToken);
await saveThreadsAuthForAccount(accountId, {
accessToken: longLived.accessToken,
userId: shortLived.userId,
expiresIn: longLived.expiresIn,
});
const profile = await fetchThreadsProfile(longLived.accessToken);
if (profile.username) {
const isPlaceholderName =
!accountExists.displayName ||
accountExists.displayName === "待綁定帳號" ||
accountExists.displayName === "新經營帳號";
await prisma.account.update({
where: { id: accountId },
data: {
username: profile.username,
valid: true,
...(isPlaceholderName ? { displayName: `@${profile.username}` } : {}),
},
});
}
await prisma.setting.update({
where: { userId: sessionUser.id },
data: { publishViaApi: true },
});
homeUrl.searchParams.set("threads_connected", "1");
return NextResponse.redirect(homeUrl);
} catch (err) {
const message = err instanceof Error ? err.message : "oauth_failed";
homeUrl.searchParams.set("threads_error", message);
return NextResponse.redirect(homeUrl);
}
}

View File

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getActiveAccountConnectionSettings } from "@/lib/account-connection-settings";
import { getActiveAccountProfile } from "@/lib/account-context";
import {
accountHasThreadsToken,
isThreadsAppConfigured,
maskToken,
} from "@/lib/threads-api";
import { apiRouteErrorResponse } from "@/lib/auth/api";
export async function GET() {
try {
const [account, connection] = await Promise.all([
getActiveAccountProfile(),
getActiveAccountConnectionSettings(),
]);
return NextResponse.json({
connected: accountHasThreadsToken(account),
publishViaApi: connection.publishViaApi,
accountId: account?.id ?? null,
accountName: account?.displayName ?? account?.username ?? null,
userId: account?.threadsUserId ?? null,
tokenExpiresAt: account?.threadsTokenExpiresAt?.toISOString() ?? null,
tokenMasked: maskToken(account?.threadsAccessToken),
appIdConfigured: isThreadsAppConfigured(),
});
} catch (error) {
return apiRouteErrorResponse(error, "threads/status");
}
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { syncThreadsOwnPostsAndInsights } from "@/lib/services/threads-api-sync";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as {
postsLimit?: number;
repliesLimit?: number;
};
const result = await syncThreadsOwnPostsAndInsights({
postsLimit: body.postsLimit,
repliesLimit: body.repliesLimit,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Threads 同步失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

237
app/api/topics/route.ts Normal file
View File

@ -0,0 +1,237 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getActiveAccountId } from "@/lib/account-context";
import { authErrorResponse } from "@/lib/auth/api";
import { isAccountInUserScope, requireUserAccountScope } from "@/lib/auth/user-scope";
import { resolveProductContextForPlacement } from "@/lib/services/product-catalog";
import {
attachBrandSelectionToTopic,
attachProfileToTopic,
migrateOrphanProductContexts,
} from "@/lib/services/product-profile";
import { deleteTopicWithRelations } from "@/lib/services/topic";
import { hasProductContext } from "@/lib/types/product-context";
export async function GET() {
try {
const accountId = await getActiveAccountId();
const { where, accountIds } = await requireUserAccountScope(accountId);
try {
await migrateOrphanProductContexts(accountIds);
} catch (migrateErr) {
console.error("[topics] migrateOrphanProductContexts failed:", migrateErr);
}
const topics = await prisma.topic.findMany({
where,
include: {
productProfile: true,
brandProfile: true,
scans: {
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
createdAt: true,
_count: { select: { items: true } },
},
},
_count: { select: { scans: true } },
},
orderBy: { createdAt: "desc" },
});
const enriched = topics.map(({ scans, _count, ...topic }) => ({
...topic,
scanCount: _count.scans,
latestScan: scans[0]
? {
id: scans[0].id,
createdAt: scans[0].createdAt,
itemCount: scans[0]._count.items,
}
: null,
}));
return NextResponse.json({ topics: enriched });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "讀取主題失敗";
console.error("[topics] GET failed:", error);
return NextResponse.json({ error: message, topics: [] }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { label, query, brief, productContext, brandProfileId, productProfileId, topicGoal } =
(await request.json()) as {
label?: string;
query?: string;
brief?: string;
productContext?: string;
brandProfileId?: string;
productProfileId?: string | null;
topicGoal?: string;
};
if (!label?.trim() || !query?.trim()) {
return NextResponse.json({ error: "label 與 query 為必填" }, { status: 400 });
}
const goal = topicGoal === "placement" ? "placement" : "viral";
if (goal === "placement" && !brandProfileId && !productProfileId && !hasProductContext(productContext)) {
return NextResponse.json({ error: "置入產品模式請選擇品牌" }, { status: 400 });
}
const accountId = await getActiveAccountId();
if (!accountId) {
return NextResponse.json({ error: "請先建立並選定經營帳號" }, { status: 400 });
}
let resolvedContext = productContext?.trim() || null;
let resolvedBrandId: string | null = null;
let resolvedProfileId: string | null = productProfileId ?? null;
if (goal === "placement" && brandProfileId) {
const brand = await prisma.brandProfile.findUnique({ where: { id: brandProfileId } });
if (!brand || brand.accountId !== accountId) {
return NextResponse.json({ error: "找不到品牌" }, { status: 400 });
}
resolvedBrandId = brand.id;
resolvedContext = await resolveProductContextForPlacement({
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
fallbackContext: productContext,
});
} else if (goal === "placement" && productProfileId) {
const profile = await prisma.productProfile.findUnique({ where: { id: productProfileId } });
if (!profile || profile.accountId !== accountId) {
return NextResponse.json({ error: "找不到產品" }, { status: 400 });
}
resolvedProfileId = profile.id;
resolvedBrandId = profile.brandId;
resolvedContext = await resolveProductContextForPlacement({
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
fallbackContext: productContext,
});
}
const topic = await prisma.topic.create({
data: {
accountId,
label: label.trim(),
query: query.trim(),
brief: brief?.trim() || null,
productContext: resolvedContext,
brandProfileId: resolvedBrandId,
productProfileId: resolvedProfileId,
topicGoal: goal,
},
include: { productProfile: true, brandProfile: true },
});
return NextResponse.json({ topic });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "建立主題失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const {
id,
label,
query,
brief,
productContext,
brandProfileId,
productProfileId,
topicGoal,
active,
selectedTags,
researchMap,
} = (await request.json()) as {
id?: string;
label?: string;
query?: string;
brief?: string | null;
productContext?: string | null;
brandProfileId?: string | null;
productProfileId?: string | null;
topicGoal?: string;
active?: boolean;
selectedTags?: string[];
researchMap?: unknown;
};
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const existing = await prisma.topic.findUnique({ where: { id } });
if (!existing || !isAccountInUserScope(accountIds, existing.accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
if (brandProfileId !== undefined) {
await attachBrandSelectionToTopic(id, brandProfileId, productProfileId ?? null);
} else if (productProfileId !== undefined) {
await attachProfileToTopic(id, productProfileId);
}
const topic = await prisma.topic.update({
where: { id },
data: {
...(label !== undefined && { label }),
...(query !== undefined && { query }),
...(brief !== undefined && { brief }),
...(productContext !== undefined && productProfileId === undefined && { productContext }),
...(topicGoal !== undefined && {
topicGoal: topicGoal === "placement" ? "placement" : "viral",
}),
...(active !== undefined && { active }),
...(selectedTags !== undefined && { selectedTags: JSON.stringify(selectedTags) }),
...(researchMap !== undefined && { researchMap: JSON.stringify(researchMap) }),
},
include: { productProfile: true, brandProfile: true },
});
return NextResponse.json({ topic });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "更新主題失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const { accountIds } = await requireUserAccountScope(await getActiveAccountId());
const topic = await prisma.topic.findUnique({ where: { id } });
if (!topic || !isAccountInUserScope(accountIds, topic.accountId)) {
return NextResponse.json({ error: "找不到主題" }, { status: 404 });
}
const result = await deleteTopicWithRelations(id);
return NextResponse.json({ success: true, ...result });
} catch (error) {
const authRes = authErrorResponse(error);
if (authRes) return authRes;
const message = error instanceof Error ? error.message : "刪除主題失敗";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,96 @@
Fusion Pixel Font
https://github.com/TakWolf/fusion-pixel-font
Copyright (c) 2022, TakWolf (https://takwolf.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

456
app/globals.css Normal file
View File

@ -0,0 +1,456 @@
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-readable), "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif;
--font-display: var(--font-readable), "Noto Sans TC", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Menlo", monospace;
--color-background: var(--x-bg);
--color-foreground: var(--x-fg);
--color-card: var(--x-card);
--color-card-foreground: var(--x-fg);
--color-popover: var(--x-popover);
--color-popover-foreground: var(--x-fg);
--color-primary: var(--x-primary);
--color-primary-foreground: var(--x-primary-fg);
--color-secondary: var(--x-secondary);
--color-secondary-foreground: var(--x-fg);
--color-muted: var(--x-muted);
--color-muted-foreground: var(--x-muted-fg);
--color-accent: var(--x-accent);
--color-accent-foreground: var(--x-fg);
--color-destructive: var(--x-destructive);
--color-border: var(--x-border);
--color-input: var(--x-input);
--color-ring: var(--x-ring);
--color-sidebar: var(--x-sidebar);
--color-magic-gold: #e8b84a;
--color-magic-cream: var(--x-fg);
--color-gold-muted: var(--x-muted-fg);
--color-gold-dim: var(--x-muted-fg);
--color-gold-soft: var(--x-title);
--color-ink-faint: var(--x-muted-fg);
--color-teal: var(--x-teal);
--color-success: var(--x-success);
--color-success-bg: var(--x-success-bg);
--color-success-border: var(--x-success-border);
--color-warning: var(--x-warning);
--color-warning-bg: var(--x-warning-bg);
--color-warning-border: var(--x-warning-border);
--color-danger: var(--x-danger);
--color-danger-bg: var(--x-danger-bg);
--color-danger-border: var(--x-danger-border);
--color-indigo: var(--x-primary);
--color-violet: #8b5cf6;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
}
/* 深色模式預設script 載入前不閃爍) */
:root,
[data-theme="dark"] {
color-scheme: dark;
--x-bg: #0f1117;
--x-fg: #e8eaed;
--x-title: #f3f4f6;
--x-card: #181b22;
--x-popover: #1c1f27;
--x-primary: #4d9fff;
--x-primary-fg: #ffffff;
--x-primary-hover: #3d8ef5;
--x-secondary: #1c1f27;
--x-muted: #13161d;
--x-muted-fg: #8b9298;
--x-accent: #1e2430;
--x-destructive: #f87171;
--x-border: #2a2f3a;
--x-input: #13161d;
--x-ring: #4d9fff;
--x-sidebar: #0c0e13;
--x-teal: #34d399;
--x-success: #34d399;
--x-success-bg: rgba(52, 211, 153, 0.12);
--x-success-border: rgba(52, 211, 153, 0.35);
--x-warning: #fbbf24;
--x-warning-bg: rgba(251, 191, 36, 0.12);
--x-warning-border: rgba(251, 191, 36, 0.35);
--x-danger: #f87171;
--x-danger-bg: rgba(248, 113, 113, 0.12);
--x-danger-border: rgba(248, 113, 113, 0.35);
--x-link-hover: #7ab8ff;
--x-selection-bg: rgba(77, 159, 255, 0.28);
--x-selection-fg: #ffffff;
--x-nav-active-bg: #1e2430;
--x-nav-hover-bg: #1a1d24;
--x-nav-active-fg: #f0f1f3;
--x-kv-value: #c4c8ce;
--x-section-bg: #13161d;
--x-mobile-chrome: rgba(12, 14, 19, 0.96);
--x-login-overlay: linear-gradient(180deg, rgba(15, 17, 23, 0.4) 0%, rgba(15, 17, 23, 0.95) 100%);
--x-login-panel: rgba(15, 17, 23, 0.4);
--x-placeholder: #6b7280;
}
/* 淺色模式 */
[data-theme="light"] {
color-scheme: light;
--x-bg: #f5f7fa;
--x-fg: #202938;
--x-title: #101828;
--x-card: #ffffff;
--x-popover: #ffffff;
--x-primary: #2563eb;
--x-primary-fg: #ffffff;
--x-primary-hover: #1d4ed8;
--x-secondary: #eef2f7;
--x-muted: #f1f4f8;
--x-muted-fg: #667085;
--x-accent: #e8eef8;
--x-destructive: #dc2626;
--x-border: #dce3ec;
--x-input: #ffffff;
--x-ring: #2563eb;
--x-sidebar: #f8fafc;
--x-teal: #0f766e;
--x-success: #0f766e;
--x-success-bg: rgba(15, 118, 110, 0.08);
--x-success-border: rgba(15, 118, 110, 0.25);
--x-warning: #b45309;
--x-warning-bg: rgba(180, 83, 9, 0.08);
--x-warning-border: rgba(180, 83, 9, 0.25);
--x-danger: #dc2626;
--x-danger-bg: rgba(220, 38, 38, 0.08);
--x-danger-border: rgba(220, 38, 38, 0.25);
--x-link-hover: #1d4ed8;
--x-selection-bg: rgba(37, 99, 235, 0.18);
--x-selection-fg: #0f1216;
--x-nav-active-bg: #e9effb;
--x-nav-hover-bg: #eef2f7;
--x-nav-active-fg: #0f1216;
--x-kv-value: #3d4450;
--x-section-bg: #f8fafc;
--x-mobile-chrome: rgba(248, 250, 252, 0.94);
--x-login-overlay: linear-gradient(180deg, rgba(246, 247, 249, 0.35) 0%, rgba(246, 247, 249, 0.96) 100%);
--x-login-panel: rgba(255, 255, 255, 0.55);
--x-placeholder: #9aa3af;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
min-height: 100vh;
font-size: 15px;
line-height: 1.55;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
a {
color: var(--color-primary);
}
a:hover {
color: var(--x-link-hover);
}
::selection {
background: var(--x-selection-bg);
color: var(--x-selection-fg);
}
::placeholder {
color: var(--x-placeholder);
opacity: 1;
}
}
@layer utilities {
.font-display {
font-family: var(--font-display);
}
.brand-title {
font-weight: 600;
font-size: 15px;
color: var(--x-title);
line-height: 1.25;
letter-spacing: -0.01em;
}
.page-title {
font-weight: 600;
font-size: clamp(28px, 4vw, 42px);
color: var(--x-title);
letter-spacing: -0.02em;
line-height: 1.12;
}
.page-eyebrow {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--x-muted-fg);
text-transform: uppercase;
}
.page-lead {
margin-top: 0.25rem;
max-width: 42rem;
font-size: 15px;
line-height: 1.7;
color: var(--x-muted-fg);
}
.font-readable {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
color: var(--x-fg);
}
.text-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.03em;
color: var(--x-muted-fg);
text-transform: uppercase;
}
.brand-art {
image-rendering: auto;
}
.app-bg {
background-color: var(--x-bg);
background-image:
linear-gradient(180deg, color-mix(in srgb, var(--x-bg) 88%, transparent) 0%, color-mix(in srgb, var(--x-bg) 98%, transparent) 100%),
url("/brand/xunlou-login-bg.jpg");
background-size: cover;
background-position: center 35%;
}
.app-bg-dashboard {
background: var(--x-bg);
}
.app-main-content {
position: relative;
z-index: 1;
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
}
.safe-bottom {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
}
.mobile-floating {
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 1024px) {
.app-main-content {
padding-bottom: 2rem;
}
.mobile-floating {
bottom: 1.25rem;
}
}
.app-sidebar {
position: relative;
z-index: 2;
background: var(--x-sidebar);
border-right: 1px solid var(--x-border);
box-shadow: 18px 0 50px rgba(15, 23, 42, 0.035);
}
.account-switcher-trigger {
border: 1px solid color-mix(in srgb, var(--x-border) 80%, transparent);
background: color-mix(in srgb, var(--x-card) 65%, var(--x-sidebar));
}
.account-switcher-trigger:hover,
.account-switcher-trigger:focus-visible {
border-color: color-mix(in srgb, var(--x-primary) 35%, var(--x-border));
background: var(--x-accent);
}
.tool-card,
.post-card,
.pixel-panel {
background: var(--x-card);
border: 1px solid var(--x-border);
border-radius: 18px;
color: var(--x-fg);
}
.post-card::before,
.post-card::after {
content: none;
}
.feed-divider {
border-bottom: 1px solid var(--x-border);
}
.nav-item-active {
font-weight: 500;
color: var(--x-fg);
background: var(--x-nav-active-bg);
box-shadow: 0 1px 0 rgba(255,255,255,.8), 0 8px 22px rgba(15,23,42,.05);
}
.nav-item-idle {
font-weight: 400;
color: var(--x-muted-fg);
}
.nav-item-idle:hover,
.nav-item-idle:focus-visible {
color: var(--x-fg);
}
.nav-item:focus-visible {
outline: none;
}
.skeleton {
background: var(--x-muted);
border: 1px solid var(--x-border);
border-radius: 18px;
}
.tool-btn,
.pixel-btn {
border-radius: 6px;
font-weight: 500;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.tool-input,
.pixel-input {
border-radius: 6px;
border: 1px solid var(--x-border);
background: var(--x-input);
color: var(--x-fg);
font-family: var(--font-sans);
}
.tool-input:focus-visible,
.pixel-input:focus-visible {
border-color: var(--x-ring);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--x-ring) 22%, transparent);
outline: none;
}
.login-hero-overlay {
background: var(--x-login-overlay);
}
.login-form-panel {
background: var(--x-login-panel);
}
.magic-divider,
.tool-divider {
height: 1px;
background: var(--x-border);
}
.mobile-topbar,
.mobile-bottom-nav {
border-color: var(--x-border);
background: var(--x-mobile-chrome);
backdrop-filter: blur(8px);
}
.brand-logo {
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--x-primary) 28%, var(--x-border));
background: var(--x-card);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--x-primary) 10%, transparent),
0 4px 14px color-mix(in srgb, var(--x-primary) 22%, transparent);
}
.brand-title-accent {
background: linear-gradient(120deg, var(--x-primary), var(--x-teal));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tool-section {
border: 1px solid var(--x-border);
border-radius: 16px;
background: var(--x-section-bg);
}
.tool-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 12px;
font-weight: 500;
color: var(--x-muted-fg);
}
.tool-kv-label {
font-size: 11px;
font-weight: 500;
color: var(--x-muted-fg);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tool-kv-value {
margin-top: 0.25rem;
font-size: 13px;
line-height: 1.5;
color: var(--x-kv-value);
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out both;
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out both;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-fade-in-up {
animation: none;
}
}
}

44
app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Noto_Sans_TC } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { BRAND_ASSETS, BRAND_FULL_TITLE, BRAND_NAME } from "@/lib/brand";
import { DEFAULT_THEME } from "@/lib/theme";
import "./globals.css";
const notoSansTc = Noto_Sans_TC({
weight: ["400", "500", "600", "700"],
subsets: ["latin"],
variable: "--font-readable",
display: "swap",
preload: true,
});
const themeInitScript = `(function(){try{var k="xunlou-theme";var t=localStorage.getItem(k);var m=t==="light"||t==="dark"?t:"${DEFAULT_THEME}";document.documentElement.setAttribute("data-theme",m);document.documentElement.style.colorScheme=m;}catch(e){document.documentElement.setAttribute("data-theme","${DEFAULT_THEME}");}})();`;
export const viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export const metadata: Metadata = {
title: BRAND_FULL_TITLE,
description: `${BRAND_NAME}Threads AI 經營工作台`,
icons: {
icon: BRAND_ASSETS.logo,
apple: BRAND_ASSETS.logo,
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-Hant" className={notoSansTc.variable} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

5
app/login/layout.tsx Normal file
View File

@ -0,0 +1,5 @@
import { Suspense } from "react";
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

113
app/login/page.tsx Normal file
View File

@ -0,0 +1,113 @@
"use client";
import Image from "next/image";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BrandLogo, BrandMark } from "@/components/brand/logo";
import { ThemeToggle } from "@/components/theme-toggle";
import { BRAND_ASSETS } from "@/lib/brand";
import { notify } from "@/lib/notifications/store";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
notify({ type: "error", title: data.error ?? "登入失敗" });
setLoading(false);
return;
}
const next = searchParams.get("next");
if (data.needsThreadsBind) {
router.replace("/matrix?bind_threads=1");
return;
}
router.replace(next && next.startsWith("/") ? next : "/matrix");
} catch {
notify({ type: "error", title: "連線失敗,請稍後再試" });
setLoading(false);
}
}
return (
<div className="relative flex min-h-[100dvh] flex-col lg:min-h-screen">
<div className="absolute right-4 top-4 z-20 safe-bottom sm:right-5 sm:top-5">
<ThemeToggle compact />
</div>
<div className="relative h-36 shrink-0 sm:h-44 lg:absolute lg:inset-y-0 lg:left-0 lg:z-0 lg:h-full lg:w-[52%]">
<Image
src={BRAND_ASSETS.loginBg}
alt=""
fill
priority
className="object-cover object-center"
sizes="(max-width: 1024px) 100vw, 52vw"
/>
<div className="login-hero-overlay absolute inset-0" />
</div>
<div className="login-form-panel relative z-10 flex flex-1 items-center justify-center px-4 py-6 pb-8 safe-bottom sm:px-5 sm:py-10 lg:ml-[52%]">
<Card className="w-full max-w-md">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<BrandLogo size="lg" />
<BrandMark />
</div>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
</div>
<Button className="w-full" type="submit" disabled={loading} aria-label="登入">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
CheckCircle2,
Eraser,
Link2,
Loader2,
Unlink,
XCircle,
} from "lucide-react";
import { ChromeSessionSync } from "@/components/connections/chrome-session-sync";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { notify } from "@/lib/notifications/store";
import { cn } from "@/lib/utils";
interface SessionStatus {
synced?: boolean;
valid: boolean;
username?: string | null;
message: string;
}
interface ThreadsStatus {
connected: boolean;
accountName?: string | null;
tokenExpiresAt?: string | null;
appIdConfigured?: boolean;
}
export function AccountConnectionCard() {
const [session, setSession] = useState<SessionStatus | null>(null);
const [threads, setThreads] = useState<ThreadsStatus | null>(null);
const [clearingSession, setClearingSession] = useState(false);
const loadConnections = useCallback(async () => {
try {
const [sessionRes, threadsRes] = await Promise.all([
fetch("/api/session/status"),
fetch("/api/threads/status"),
]);
const [sessionData, threadsData] = await Promise.all([
sessionRes.json().catch(() => null),
threadsRes.json().catch(() => null),
]);
if (sessionData) setSession(sessionData);
if (threadsData) setThreads(threadsData);
} catch {
// 網路異常時保持現狀,不阻塞頁面
}
}, []);
useEffect(() => {
loadConnections();
}, [loadConnections]);
async function handleClearSession() {
setClearingSession(true);
try {
const res = await fetch("/api/session/clear", { method: "POST" });
const data = await res.json().catch(() => ({}));
notify({
type: data.cleared ? "success" : "info",
title: data.cleared ? "已清除瀏覽器 session" : "無需清除",
message: data.message,
});
window.dispatchEvent(new CustomEvent("haixun:accounts-updated"));
loadConnections();
} catch {
notify({ type: "error", title: "清除失敗", message: "網路連線異常,請稍後再試" });
} finally {
setClearingSession(false);
}
}
async function handleBindApi() {
if (!threads?.appIdConfigured) {
notify({
type: "warning",
title: "無法綁定官方 API",
message: "請由管理員在 server 的 .env 設定 THREADS_APP_ID 與 THREADS_APP_SECRET。",
});
return;
}
const res = await fetch("/api/accounts/bind", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify({ type: "error", title: "無法開始綁定", message: data.error });
return;
}
window.location.href = "/api/threads/oauth/authorize";
}
async function handleDisconnectApi() {
try {
const res = await fetch("/api/threads/disconnect", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify({ type: "error", title: "中斷失敗", message: data.error });
return;
}
notify({ type: "success", title: "已中斷官方 API 連線" });
window.dispatchEvent(new CustomEvent("haixun:accounts-updated"));
loadConnections();
} catch {
notify({ type: "error", title: "中斷失敗", message: "網路連線異常,請稍後再試" });
}
}
const sessionSynced = session?.synced ?? session?.valid;
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Threads Chrome API OAuth
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
className={cn(
"rounded-lg border p-4",
sessionSynced ? "border-success-border bg-success-bg" : "border-warning-border bg-warning-bg"
)}
>
<div className="flex items-start gap-3">
{sessionSynced ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
) : (
<XCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
)}
<div className="min-w-0 flex-1 space-y-3">
<div>
<p className="text-sm font-semibold">Chrome Session </p>
<p className="mt-0.5 text-xs text-muted-foreground">
{sessionSynced
? `已同步${session?.username ? ` · @${session.username}` : ""}`
: "尚未同步 — 請在 Chrome 登入 threads.com 後按下方按鈕"}
</p>
{session?.message && (
<p className="mt-1 text-[11px] text-muted-foreground">{session.message}</p>
)}
</div>
<ChromeSessionSync onSynced={loadConnections} />
<p className="text-[11px] leading-relaxed text-muted-foreground">
Chrome {" "}
<code className="rounded bg-muted px-1">extension/haixun-threads-sync</code>
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3.5">
<div className="flex items-center gap-3">
{threads?.connected ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-success" />
) : (
<XCircle className="h-5 w-5 shrink-0 text-warning" />
)}
<div className="min-w-0">
<p className="text-sm font-semibold">Threads API</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{threads?.connected
? `已授權${threads.accountName ? ` · @${threads.accountName}` : ""}`
: "尚未授權選用API 模式才需要)"}
</p>
</div>
</div>
{threads?.connected ? (
<Button size="sm" variant="outline" onClick={handleDisconnectApi}>
<Unlink className="h-4 w-4" />
</Button>
) : (
<Button size="sm" variant="outline" onClick={handleBindApi}>
<Link2 className="h-4 w-4" />
OAuth
</Button>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground">
session API token
</p>
<Button
size="sm"
variant="ghost"
disabled={clearingSession}
onClick={handleClearSession}
>
{clearingSession ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eraser className="h-4 w-4" />
)}
session
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { notify } from "@/lib/notifications/store";
interface DeleteAccountCardProps {
accountId: string;
accountName: string;
onDeleted?: () => void;
}
export function DeleteAccountCard({ accountId, accountName, onDeleted }: DeleteAccountCardProps) {
const [deleting, setDeleting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
async function handleDelete() {
setDeleting(true);
try {
const res = await fetch(`/api/accounts/${accountId}`, { method: "DELETE" });
const text = await res.text();
const data = text.trim() ? (JSON.parse(text) as { error?: string; activeAccountId?: string | null }) : {};
if (!res.ok) {
throw new Error(data.error ?? "刪除失敗");
}
notify({ type: "success", title: "已刪除帳號" });
if (data.activeAccountId) {
onDeleted?.();
return;
}
window.location.reload();
} catch (error) {
notify({
type: "error",
title: "刪除失敗",
message: error instanceof Error ? error.message : "未知錯誤",
});
} finally {
setDeleting(false);
}
}
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>
{accountName}稿 AI Key
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={() => setConfirmOpen(true)} disabled={deleting}>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="刪除帳號"
description={`確定要刪除「${accountName}」?會一併刪除這個帳號的主題、海巡紀錄、草稿與發文資料,且無法復原。`}
confirmText="永久刪除"
danger
onConfirm={handleDelete}
/>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { UserPlus } from "lucide-react";
import { SecretRegisterDialog } from "@/components/auth/secret-register-dialog";
import { Button } from "@/components/ui/button";
import { subscribeKonamiCode } from "@/lib/konami-code";
import { notify } from "@/lib/notifications/store";
export function KonamiRegisterGate() {
const [unlocked, setUnlocked] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const skipNotifyRef = useRef(true);
useEffect(() => {
return subscribeKonamiCode(() => {
setUnlocked((prev) => !prev);
});
}, []);
useEffect(() => {
if (!unlocked) setDialogOpen(false);
}, [unlocked]);
useEffect(() => {
if (skipNotifyRef.current) {
skipNotifyRef.current = false;
return;
}
notify({
type: "info",
title: unlocked ? "秘密選單已解鎖" : "秘密選單已關閉",
});
}, [unlocked]);
if (!unlocked) return null;
return (
<>
<Button
type="button"
size="sm"
variant="outline"
className="fixed bottom-20 right-4 z-[70] shadow-lg lg:bottom-6 lg:right-6"
onClick={() => setDialogOpen(true)}
>
<UserPlus className="h-4 w-4" />
</Button>
<SecretRegisterDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { notify } from "@/lib/notifications/store";
interface SecretRegisterDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SecretRegisterDialog({ open, onOpenChange }: SecretRegisterDialogProps) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
name: name.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) {
notify({ type: "error", title: data.error ?? "註冊失敗" });
setLoading(false);
return;
}
notify({ type: "success", title: "新帳號已建立", message: "已切換至新帳號" });
onOpenChange(false);
setEmail("");
setPassword("");
setName("");
if (data.needsThreadsBind) {
router.replace("/matrix?bind_threads=1");
return;
}
router.replace("/matrix");
} catch {
notify({ type: "error", title: "連線失敗,請稍後再試" });
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>使</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="secret-register-name"></Label>
<Input
id="secret-register-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="你的名字"
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret-register-email">Email</Label>
<Input
id="secret-register-email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret-register-password"></Label>
<Input
id="secret-register-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 個字元"
autoComplete="new-password"
required
/>
</div>
<Button className="w-full" type="submit" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
</Button>
</form>
</DialogContent>
</Dialog>
);
}

55
components/brand/logo.tsx Normal file
View File

@ -0,0 +1,55 @@
import Image from "next/image";
import { BRAND_ASSETS, BRAND_NAME } from "@/lib/brand";
import { cn } from "@/lib/utils";
interface BrandLogoProps {
size?: "sm" | "md" | "lg";
className?: string;
}
const sizes = {
sm: 32,
md: 40,
lg: 56,
};
export function BrandLogo({ size = "md", className }: BrandLogoProps) {
const px = sizes[size];
return (
<div
className={cn("brand-logo relative shrink-0 overflow-hidden", className)}
style={{ width: px, height: px }}
>
<Image
src={BRAND_ASSETS.logo}
alt={BRAND_NAME}
width={px}
height={px}
className="h-full w-full object-cover object-center"
priority
/>
</div>
);
}
interface BrandMarkProps {
subtitle?: string;
className?: string;
}
export function BrandMark({ subtitle, className }: BrandMarkProps) {
return (
<div className={cn("min-w-0", className)}>
<p className="brand-title">
<span className="brand-title-accent">{BRAND_NAME.slice(0, 1)}</span>
{BRAND_NAME.slice(1)}
</p>
{subtitle && (
<p className="mt-0.5 text-[10px] leading-snug tracking-wide text-muted-foreground">
{subtitle}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,102 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Chrome, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { notify } from "@/lib/notifications/store";
interface ChromeSessionSyncProps {
onSynced?: () => void;
}
export function ChromeSessionSync({ onSynced }: ChromeSessionSyncProps) {
const [extensionReady, setExtensionReady] = useState(false);
const [syncing, setSyncing] = useState(false);
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
useEffect(() => {
fetch("/api/accounts")
.then((res) => res.json())
.then((data) => setActiveAccountId(data.activeAccountId ?? null))
.catch(() => undefined);
}, []);
const handleMessage = useCallback(
(event: MessageEvent) => {
if (event.source !== window) return;
if (event.data?.type === "HAIXUN_EXTENSION_READY") {
setExtensionReady(true);
return;
}
if (event.data?.type !== "HAIXUN_THREADS_SYNC_RESULT") return;
setSyncing(false);
const data = event.data as {
valid?: boolean;
message?: string;
};
notify({
type: data.valid ? "success" : "error",
title: data.valid ? "已同步到 server" : "同步失敗",
message: data.message,
});
if (data.valid) {
window.dispatchEvent(new CustomEvent("haixun:accounts-updated"));
onSynced?.();
}
},
[onSynced]
);
useEffect(() => {
window.addEventListener("message", handleMessage);
window.postMessage({ type: "HAIXUN_PING_EXTENSION" }, "*");
return () => window.removeEventListener("message", handleMessage);
}, [handleMessage]);
function handleSync() {
setSyncing(true);
window.postMessage(
{
type: "HAIXUN_REQUEST_THREADS_SYNC",
serverUrl: window.location.origin,
accountId: activeAccountId ?? undefined,
},
"*"
);
window.setTimeout(() => {
setSyncing((current) => {
if (current) {
notify({
type: "warning",
title: "尚未收到擴充功能回應",
message: "請在 Chrome 安裝並啟用 extension/haixun-threads-sync 擴充功能。",
});
}
return false;
});
}, 12000);
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{extensionReady ? (
<span className="text-success"></span>
) : (
<span className="text-warning"> extension/haixun-threads-sync</span>
)}
</p>
<Button onClick={handleSync} disabled={syncing} className="w-full sm:w-auto">
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Chrome className="h-4 w-4" />}
{syncing ? "同步中…" : "從 Chrome 同步到目前帳號"}
</Button>
</div>
);
}

854
components/draft-card.tsx Normal file
View File

@ -0,0 +1,854 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ExternalLink,
ImageIcon,
Loader2,
Pencil,
Send,
ShieldCheck,
Sparkles,
Upload,
Wand2,
X,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/notifications/store";
import { useCapabilities } from "@/lib/capabilities/context";
import { MAX_DRAFT_IMAGES } from "@/lib/drafts/constants";
import { THREADS_MAX_CHARS, cn } from "@/lib/utils";
export interface DraftData {
id: string;
text: string;
angle?: string | null;
hook?: string | null;
imageBrief?: string | null;
imagePath?: string | null;
imagePaths?: string | null;
draftType?: string | null;
rationale?: string | null;
sources?: string | null;
status: string;
factCheckResult?: string | null;
createdAt: string;
}
interface DraftCardProps {
draft: DraftData;
onUpdate: () => void;
index?: number;
selectable?: boolean;
selected?: boolean;
onSelectedChange?: (selected: boolean) => void;
}
type OptimizeMode = "polish" | "hook" | "shorter" | "engaging" | "custom";
const OPTIMIZE_MODES: { value: OptimizeMode; label: string }[] = [
{ value: "polish", label: "整體潤飾" },
{ value: "hook", label: "強化開頭" },
{ value: "shorter", label: "精簡篇幅" },
{ value: "engaging", label: "提升互動" },
{ value: "custom", label: "自訂指令" },
];
const statusLabels: Record<string, { label: string; variant: "warning" | "secondary" | "success" }> = {
PENDING: { label: "待審核", variant: "warning" },
EDITED: { label: "已編輯", variant: "secondary" },
APPROVED: { label: "已核准", variant: "success" },
};
interface FactCheckData {
isKnowledgeContent: boolean;
passed: boolean;
summary: string;
issues: string[];
verifiedPoints: string[];
sources: Array<{ title: string; link: string }>;
searchProviderLabel?: string;
}
function parseFactCheckResult(raw: string | null | undefined): FactCheckData | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && "isKnowledgeContent" in parsed) {
return parsed as FactCheckData;
}
} catch {
// ignore
}
return null;
}
function parseDraftImagePaths(source: Pick<DraftData, "imagePath" | "imagePaths">): string[] {
if (source.imagePaths) {
try {
const parsed = JSON.parse(source.imagePaths) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === "string");
}
} catch {
// Fall back to the legacy single-image field.
}
}
return source.imagePath ? [source.imagePath] : [];
}
export function DraftCard({
draft,
onUpdate,
index = 0,
selectable = false,
selected = false,
onSelectedChange,
}: DraftCardProps) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(draft.text);
const [loading, setLoading] = useState<string | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [showOptimize, setShowOptimize] = useState(false);
const [optimizeMode, setOptimizeMode] = useState<OptimizeMode>("polish");
const [customInstruction, setCustomInstruction] = useState("");
const [optimizeSummary, setOptimizeSummary] = useState<string | null>(null);
const [previousText, setPreviousText] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isReady } = useCapabilities();
const canPublish = isReady("publish");
const [imagePaths, setImagePaths] = useState<string[]>(() => parseDraftImagePaths(draft));
const [confirmDialog, setConfirmDialog] = useState<{
title: string;
description?: string;
confirmText?: string;
danger?: boolean;
onConfirm: () => void | Promise<void>;
} | null>(null);
useEffect(() => {
setImagePaths(
parseDraftImagePaths({ imagePath: draft.imagePath, imagePaths: draft.imagePaths })
);
}, [draft.imagePath, draft.imagePaths]);
const [factCheck, setFactCheck] = useState<FactCheckData | null>(() =>
parseFactCheckResult(draft.factCheckResult)
);
useEffect(() => {
setFactCheck(parseFactCheckResult(draft.factCheckResult));
}, [draft.factCheckResult]);
const sources: string[] = (() => {
if (!draft.sources) return [];
try {
const parsed = JSON.parse(draft.sources);
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
} catch {
return [];
}
})();
const charCount = text.length;
const overLimit = charCount > THREADS_MAX_CHARS;
const statusInfo = statusLabels[draft.status] ?? { label: draft.status, variant: "secondary" as const };
const angleInitial = (draft.angle ?? "稿").charAt(0);
function imagePreviewUrl(imagePath: string) {
return `/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`;
}
async function handleUploadImages(files: File[]) {
if (files.length === 0) return;
if (imagePaths.length + files.length > MAX_DRAFT_IMAGES) {
notify({
type: "error",
title: "超過配圖上限",
message: `每篇草稿最多 ${MAX_DRAFT_IMAGES} 張配圖`,
});
return;
}
setLoading("upload-image");
const formData = new FormData();
for (const file of files) formData.append("file", file);
try {
const res = await fetch(`/api/drafts/${draft.id}/image`, {
method: "POST",
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "上傳失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
notify({
type: "success",
title: "配圖已上傳",
message: files.length > 1 ? `已新增 ${files.length} 張圖片` : undefined,
});
onUpdate();
} catch {
notify({ type: "error", title: "上傳失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleGenerateImage() {
setLoading("gen-image");
try {
const res = await fetch(`/api/drafts/${draft.id}/generate-image`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "AI 生圖失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
notify({ type: "success", title: "AI 配圖已生成" });
onUpdate();
} catch {
notify({ type: "error", title: "AI 生圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleRemoveImage(imagePath: string) {
setLoading("remove-image");
try {
const res = await fetch(
`/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`,
{ method: "DELETE" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "移除配圖失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? []);
onUpdate();
} catch {
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleClearImages() {
setConfirmDialog({
title: "移除全部配圖",
description: "確定要移除這篇草稿的全部配圖?",
confirmText: "移除",
danger: true,
onConfirm: async () => {
setLoading("remove-image");
try {
const res = await fetch(`/api/drafts/${draft.id}/image`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "移除配圖失敗", message: data.error });
return;
}
setImagePaths([]);
onUpdate();
} catch {
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
async function handleSave() {
setLoading("save");
try {
const res = await fetch(`/api/drafts/${draft.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, status: "EDITED" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "儲存失敗", message: data.error });
return;
}
setEditing(false);
setOptimizeSummary(null);
setPreviousText(null);
onUpdate();
} catch {
notify({ type: "error", title: "儲存失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleOptimize(apply = false) {
setOptimizing(true);
setOptimizeSummary(null);
try {
const res = await fetch("/api/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draftId: draft.id,
text,
mode: optimizeMode,
instruction: optimizeMode === "custom" ? customInstruction : undefined,
save: apply,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "AI 優化失敗", message: data.error });
return;
}
if (apply) {
setText(data.text);
setEditing(false);
setShowOptimize(false);
setPreviousText(null);
setOptimizeSummary(data.summary);
onUpdate();
return;
}
setPreviousText(text);
setText(data.text);
setEditing(true);
setShowOptimize(false);
setOptimizeSummary(data.summary);
} catch {
notify({ type: "error", title: "AI 優化失敗", message: "網路連線異常,請稍後再試" });
} finally {
setOptimizing(false);
}
}
function handleUndoOptimize() {
if (previousText) {
setText(previousText);
setPreviousText(null);
setOptimizeSummary(null);
}
}
async function handleFactCheck() {
setLoading("factcheck");
setFactCheck(null);
try {
const res = await fetch("/api/fact-check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draftId: draft.id, text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "查證失敗", message: data.error });
return;
}
setFactCheck(data.factCheck);
const fc = data.factCheck;
if (fc.isKnowledgeContent) {
notify({
type: fc.passed ? "success" : "warning",
title: fc.passed ? "知識查證通過" : "知識查證未通過",
message: fc.summary,
});
} else {
notify({ type: "info", title: "非知識型內容", message: fc.summary });
}
onUpdate();
} catch {
notify({ type: "error", title: "查證失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handlePublish() {
setConfirmDialog({
title: "發布到 Threads",
description: "確定要發布這篇貼文到 Threads知識型內容會先經網路搜尋查證。",
confirmText: "發布",
onConfirm: async () => {
setLoading("publish");
try {
const res = await fetch("/api/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draftId: draft.id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.factCheck) setFactCheck(data.factCheck);
notify({
type: "error",
title: res.status === 422 ? "知識型內容未通過查證" : "發布失敗",
message: data.factCheck?.issues?.join("") ?? data.error,
href: data.debugRunId ? `/debug` : undefined,
});
return;
}
if (data.factCheck) setFactCheck(data.factCheck);
notify({
type: "success",
title: "已發布到你的 Threads",
message: [
data.method === "api" ? "已透過官方 API 發布。" : "貼文已送出。",
data.warning,
"草稿已移至已發布。",
]
.filter(Boolean)
.join(" "),
href: "/published",
});
onUpdate();
} catch {
notify({ type: "error", title: "發布失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
async function handleReject() {
setConfirmDialog({
title: "拒絕草稿",
description: "確定要拒絕這篇草稿?此操作無法復原。",
confirmText: "拒絕",
danger: true,
onConfirm: async () => {
setLoading("reject");
try {
const res = await fetch(`/api/drafts/${draft.id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify({ type: "error", title: "拒絕失敗", message: data.error });
return;
}
onUpdate();
} catch {
notify({ type: "error", title: "拒絕失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
return (
<article
className="animate-fade-in-up py-7 first:pt-0"
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex gap-3">
{selectable && (
<label className="mt-2 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-border accent-primary"
checked={selected}
onChange={(event) => onSelectedChange?.(event.target.checked)}
aria-label={`選取 ${draft.angle ?? "草稿"}`}
/>
</label>
)}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-muted-foreground">
{angleInitial}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="text-[15px] font-semibold">{draft.angle ?? "草稿"}</span>
<span className="text-muted-foreground">·</span>
<span className="text-[13px] text-muted-foreground">
{new Date(draft.createdAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
})}
</span>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
{draft.draftType === "viral-replica" && (
<Badge variant="outline"></Badge>
)}
{factCheck && (factCheck.passed || !factCheck.isKnowledgeContent) && (
<span
className="relative inline-flex items-center gap-1 rounded-md border border-success-border bg-success-bg px-2 py-0.5 text-[11px] font-medium text-success"
title={
factCheck.isKnowledgeContent
? `已查證通過${factCheck.searchProviderLabel ? `${factCheck.searchProviderLabel}` : ""}\n${factCheck.summary}${factCheck.verifiedPoints.length > 0 ? `\n\n查證重點\n${factCheck.verifiedPoints.map((p) => `${p}`).join("\n")}` : ""}`
: "非知識型內容,無需查證"
}
>
<ShieldCheck className="h-3 w-3" />
</span>
)}
{factCheck && factCheck.isKnowledgeContent && !factCheck.passed && (
<span
className="relative inline-flex items-center gap-1 rounded-md border border-warning-border bg-warning-bg px-2 py-0.5 text-[11px] font-medium text-warning"
title={`查證未通過\n${factCheck.issues.join("")}`}
>
<ShieldCheck className="h-3 w-3" />
</span>
)}
</div>
{draft.rationale && (
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">
{draft.rationale}
</p>
)}
</div>
</div>
{showOptimize && (
<div className="mt-3 space-y-3 rounded-lg border border-border bg-muted p-3.5">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4" />
<p className="text-sm font-semibold">AI </p>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={optimizeMode} onValueChange={(v) => setOptimizeMode(v as OptimizeMode)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPTIMIZE_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{optimizeMode === "custom" && (
<div className="space-y-2">
<Label></Label>
<Input
value={customInstruction}
onChange={(e) => setCustomInstruction(e.target.value)}
placeholder="例:語氣更口語、加入一個反問句"
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => handleOptimize(false)} disabled={optimizing}>
<Wand2 className="h-3.5 w-3.5" />
{optimizing ? "優化中…" : "預覽優化"}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleOptimize(true)}
disabled={optimizing}
>
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowOptimize(false)}>
</Button>
</div>
</div>
)}
{optimizeSummary && (
<div className="mt-3 rounded-lg border border-border bg-muted px-3.5 py-2.5">
<p className="text-[12px] font-medium text-muted-foreground">AI 調</p>
<p className="mt-1 text-[14px] leading-relaxed">{optimizeSummary}</p>
</div>
)}
{factCheck && (
<div
className={cn(
"mt-3 rounded-lg border px-3.5 py-2.5 text-[13px]",
factCheck.passed || !factCheck.isKnowledgeContent
? "border-border bg-muted"
: "border-warning-border bg-warning-bg text-warning"
)}
>
<p className="font-medium">
{factCheck.isKnowledgeContent
? factCheck.passed
? "知識查證通過"
: "知識查證未通過"
: "非知識型,可直接發布"}
{factCheck.searchProviderLabel && ` · ${factCheck.searchProviderLabel}`}
</p>
<p className="mt-1 leading-relaxed text-muted-foreground">{factCheck.summary}</p>
{factCheck.issues.length > 0 && (
<ul className="mt-2 list-inside list-disc text-warning">
{factCheck.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{factCheck.sources.length > 0 && (
<div className="mt-2 space-y-1">
{factCheck.sources.map((s) => (
<a
key={s.link}
href={s.link}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-[12px] underline"
>
{s.title}
</a>
))}
</div>
)}
</div>
)}
{draft.hook && !editing && (
<p className="mt-2 text-[14px] font-medium leading-snug">{draft.hook}</p>
)}
<div className="mt-2">
{editing ? (
<div className="space-y-2">
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={6} />
<p
className={cn(
"font-mono text-xs",
overLimit ? "text-destructive" : "text-muted-foreground"
)}
>
{charCount} / {THREADS_MAX_CHARS}
</p>
</div>
) : (
<p className="font-readable whitespace-pre-wrap">{text}</p>
)}
</div>
<div className="mt-3 rounded-lg border border-border bg-muted/50 p-3.5">
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[13px] font-semibold">
<ImageIcon className="h-3.5 w-3.5" />
</p>
<div className="flex flex-wrap gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) void handleUploadImages(files);
e.target.value = "";
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={!!loading || optimizing}
>
{loading === "upload-image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
</Button>
{imagePaths.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={handleClearImages}
disabled={!!loading || optimizing}
>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleGenerateImage}
disabled={!!loading || optimizing}
>
{loading === "gen-image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI
</Button>
</div>
</div>
{imagePaths.length > 0 ? (
<div
className={cn(
"grid gap-2.5",
imagePaths.length > 1 ? "sm:grid-cols-2" : "grid-cols-1"
)}
>
{imagePaths.map((imagePath, index) => (
<div
key={imagePath}
className="overflow-hidden rounded-lg border border-border bg-background"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreviewUrl(imagePath)}
alt={`草稿配圖 ${index + 1}`}
className="max-h-80 w-full object-contain"
/>
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<span className="text-[12px] text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-7 text-destructive hover:text-destructive"
onClick={() => handleRemoveImage(imagePath)}
disabled={!!loading || optimizing}
>
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{MAX_DRAFT_IMAGES} AI
</p>
)}
{draft.imageBrief && (
<div className="mt-2.5 rounded-md bg-background px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground"></p>
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-relaxed">
{draft.imageBrief}
</p>
</div>
)}
</div>
{sources.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{sources.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
</a>
))}
</div>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
{editing ? (
<>
<Button size="sm" onClick={handleSave} disabled={!!loading || overLimit || optimizing}>
</Button>
{previousText && (
<Button size="sm" variant="outline" onClick={handleUndoOptimize} disabled={optimizing}>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(false);
setText(draft.text);
setPreviousText(null);
setOptimizeSummary(null);
}}
>
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowOptimize((v) => !v)}
disabled={!!loading || optimizing}
>
<Wand2 className="h-3.5 w-3.5" />
AI
</Button>
<Button
size="sm"
variant="outline"
onClick={handleFactCheck}
disabled={!!loading || overLimit || optimizing}
>
<ShieldCheck className="h-3.5 w-3.5" />
{loading === "factcheck" ? "查證中…" : "知識查證"}
</Button>
<Button size="sm" onClick={handlePublish} disabled={!!loading || overLimit || optimizing || !canPublish} title={!canPublish ? "需先在連線設定綁定 Threads 或同步瀏覽器 Session" : undefined}>
<Send className="h-3.5 w-3.5" />
{loading === "publish" ? "發布中…" : "發布"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={handleReject}
disabled={!!loading || optimizing}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
<ConfirmDialog
open={confirmDialog !== null}
onOpenChange={(open) => !open && setConfirmDialog(null)}
title={confirmDialog?.title ?? ""}
description={confirmDialog?.description}
confirmText={confirmDialog?.confirmText}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm ?? (() => {})}
/>
</article>
);
}

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