feat init
This commit is contained in:
commit
813276d78a
|
|
@ -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.5~2)
|
||||
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
|
||||
# 偵測到 403/429/checkpoint 後暫停瀏覽器爬蟲(分鐘)
|
||||
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 API(MVP 過渡/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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# 巡樓 Haixun — 快速啟動與程序管理
|
||||
# 需求:Node.js 20+、npm、pm2(npm 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
|
||||
|
|
@ -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 SDK(OpenCode 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` → 查看發布結果
|
||||
|
||||
### 流程 B(API 獲客)
|
||||
|
||||
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 — 使用風險自負。
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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="設定定時海巡、生成、發文與互動任務。需另啟 worker(npm 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardShell>{children}</DashboardShell>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/matrix");
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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("請先填寫主題 Brief,AI 才能準確分析。", "尚無 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">
|
||||
這份策略會自動影響後續產文與回覆;D1–D8 欄位在帳號策略頁編輯。
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/accounts#style-8d">查看 D1–D8</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Suspense } from "react";
|
||||
|
||||
export default function ScansLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "每日上限需在 0–500 之間" }, { 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Suspense } from "react";
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue