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>
|
||
);
|
||
} |