200 lines
7.1 KiB
TypeScript
200 lines
7.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { api, ThreadsConnection } from "../api/haixun";
|
||
import { getAccessToken } from "../api/client";
|
||
import { readActiveThreadsAccountId } from "../lib/activeAccount";
|
||
import {
|
||
isExtensionBridgePresent,
|
||
pingExtensionBridge,
|
||
requestExtensionSync,
|
||
waitForExtensionBridge,
|
||
} from "../lib/extensionSync";
|
||
import { ExtensionInstallCard } from "./ExtensionInstallCard";
|
||
import { Badge, Button, Card, Field, Textarea } from "./ui";
|
||
|
||
type DevToolsPanelProps = {
|
||
accountId: string;
|
||
connection: ThreadsConnection;
|
||
onConnectionChange: (data: ThreadsConnection) => void;
|
||
onMessage: (message: string) => void;
|
||
onError: (message: string) => void;
|
||
};
|
||
|
||
export function DevToolsPanel({
|
||
accountId,
|
||
connection,
|
||
onConnectionChange,
|
||
onMessage,
|
||
onError,
|
||
}: DevToolsPanelProps) {
|
||
const [open, setOpen] = useState(true);
|
||
const [extensionReady, setExtensionReady] = useState(false);
|
||
const [syncBusy, setSyncBusy] = useState(false);
|
||
const [manualState, setManualState] = useState("");
|
||
const [importBusy, setImportBusy] = useState(false);
|
||
const [showManualImport, setShowManualImport] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const onBridgeMessage = (event: MessageEvent) => {
|
||
if (event.source !== window) return;
|
||
if (event.data?.type === "HAIXUN_EXTENSION_READY") setExtensionReady(true);
|
||
};
|
||
window.addEventListener("message", onBridgeMessage);
|
||
const timer = window.setInterval(() => {
|
||
if (cancelled) return;
|
||
if (isExtensionBridgePresent()) {
|
||
setExtensionReady(true);
|
||
return;
|
||
}
|
||
pingExtensionBridge();
|
||
}, 1500);
|
||
pingExtensionBridge();
|
||
void waitForExtensionBridge(6000).then((ready) => {
|
||
if (!cancelled && ready) setExtensionReady(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
window.removeEventListener("message", onBridgeMessage);
|
||
window.clearInterval(timer);
|
||
};
|
||
}, []);
|
||
|
||
const reloadConnection = async () => {
|
||
const data = await api.getThreadsConnection(accountId);
|
||
onConnectionChange(data);
|
||
return data;
|
||
};
|
||
|
||
const syncChromeSession = async () => {
|
||
setSyncBusy(true);
|
||
onError("");
|
||
onMessage("");
|
||
try {
|
||
const ready = extensionReady || isExtensionBridgePresent() || (await waitForExtensionBridge(4000));
|
||
if (!ready) {
|
||
throw new Error(
|
||
"找不到巡樓 Chrome 擴充。請到 chrome://extensions 載入 extension/haixun-threads-sync,按「重新載入」後刷新此頁(F5)",
|
||
);
|
||
}
|
||
setExtensionReady(true);
|
||
|
||
await api.memberMe();
|
||
const resolvedAccountId = accountId || readActiveThreadsAccountId();
|
||
if (!resolvedAccountId) {
|
||
throw new Error("請先在頂部選擇經營帳號,或到 Threads 帳號頁設為使用中後再同步");
|
||
}
|
||
|
||
const result = await requestExtensionSync({
|
||
accountId: resolvedAccountId,
|
||
serverUrl: window.location.origin,
|
||
});
|
||
if (result.success === false || result.valid === false) {
|
||
throw new Error(result.message || "Chrome session 同步失敗");
|
||
}
|
||
onMessage(result.message || "Chrome session 已同步");
|
||
await reloadConnection();
|
||
} catch (err) {
|
||
onError(err instanceof Error ? err.message : "Chrome session 同步失敗");
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
};
|
||
|
||
const importManualSession = async () => {
|
||
const trimmed = manualState.trim();
|
||
if (!trimmed) {
|
||
onError("請貼上 Playwright storage state JSON");
|
||
return;
|
||
}
|
||
setImportBusy(true);
|
||
onError("");
|
||
onMessage("");
|
||
try {
|
||
JSON.parse(trimmed);
|
||
const data = await api.importThreadsAccountSession(accountId, trimmed);
|
||
if (!data.valid) throw new Error(data.message || "Session 驗證失敗");
|
||
setManualState("");
|
||
onMessage(data.message || "Session 已匯入");
|
||
await reloadConnection();
|
||
} catch (err) {
|
||
if (err instanceof SyntaxError) {
|
||
onError("JSON 格式不正確,請貼上完整的 Playwright storage state");
|
||
} else {
|
||
onError(err instanceof Error ? err.message : "匯入失敗");
|
||
}
|
||
} finally {
|
||
setImportBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card title="Chrome Session(測試海巡)" tone="lavender">
|
||
<div className="page-grid">
|
||
<button
|
||
type="button"
|
||
className="button-row"
|
||
style={{ justifyContent: "space-between", border: "none", background: "transparent", padding: 0, cursor: "pointer", width: "100%" }}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
<p className="muted hx-text-sm" style={{ margin: 0, textAlign: "left" }}>
|
||
僅「測試海巡(爬蟲)」會用 Chrome 爬蟲;正式海巡、擴圖、發文與獲客留言一律走 Threads API。
|
||
</p>
|
||
<span className="muted hx-text-sm">{open ? "收起 ▴" : "展開 ▾"}</span>
|
||
</button>
|
||
|
||
{open ? (
|
||
<div className="page-grid">
|
||
<div className="button-row">
|
||
<Badge variant={connection.browser_connected ? "success" : "warning"}>
|
||
{connection.browser_connected ? "Chrome Session 已同步" : "Chrome Session 未同步"}
|
||
</Badge>
|
||
<Badge variant={extensionReady ? "success" : "warning"}>
|
||
{extensionReady ? "擴充已偵測" : "尚未偵測擴充"}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="button-row">
|
||
<Button variant="primary" loading={syncBusy} onClick={() => void syncChromeSession()}>
|
||
從 Chrome 同步 Session
|
||
</Button>
|
||
<Button variant="soft" onClick={() => setShowManualImport((v) => !v)}>
|
||
{showManualImport ? "收起手動匯入" : "手動貼 JSON"}
|
||
</Button>
|
||
</div>
|
||
|
||
{showManualImport ? (
|
||
<div className="page-grid">
|
||
<Field label="Playwright storageState JSON">
|
||
<Textarea
|
||
rows={6}
|
||
value={manualState}
|
||
onChange={(e) => setManualState(e.target.value)}
|
||
placeholder='{"cookies":[...],"origins":[...]}'
|
||
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||
/>
|
||
</Field>
|
||
<Button variant="primary" loading={importBusy} onClick={() => void importManualSession()}>
|
||
匯入 Session
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{!extensionReady ? (
|
||
<>
|
||
<p className="muted hx-text-sm">
|
||
安裝擴充後到 chrome://extensions 重新載入,再按 F5 刷新此頁。巡樓網址須與擴充選項一致(localhost 與 127.0.0.1 也要一致)。
|
||
</p>
|
||
<ExtensionInstallCard compact showSteps={false} />
|
||
</>
|
||
) : null}
|
||
|
||
<p className="muted hx-text-sm" style={{ margin: 0 }}>
|
||
診斷:頁面 {window.location.origin}|JWT {getAccessToken() ? "有" : "無"}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|