thread-master/backend/web/src/components/DevToolsPanel.tsx

200 lines
7.1 KiB
TypeScript
Raw Normal View History

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