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