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

200 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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