205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
|
|
"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>
|
|||
|
|
);
|
|||
|
|
}
|