Files
ai_site/web/src/SyncPage.tsx
whm 4f9087bd38 feat: Z36 host_meta-only publish + Z37 SyncPage LWW UX
Allow display-name-only publish via host_meta; stop treating company conflicts 403 as a fault and ship rebuilt web dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 12:01:18 +08:00

1187 lines
42 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 {
Alert,
App as AntApp,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import {
PlusOutlined,
ReloadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
ApiOutlined,
TableOutlined,
EyeOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import {
AgentAccount,
BindCode,
Session,
SyncBinding,
SyncChannel,
SyncInspectResult,
SyncPreviewResult,
createSyncChannel,
deleteSyncChannel,
dropSyncTable,
getSyncCheckpointMeta,
inspectSyncChannel,
listAgents,
listApps,
listSyncBindings,
listSyncChannels,
listBindCodes,
createBindCode,
revokeBindCode,
previewSyncTable,
reconcileSyncChannel,
restoreSyncChannel,
startSyncChannel,
stopSyncChannel,
testSyncEndpoints,
updateSyncChannel,
} from "./api";
type EndpointForm = {
driver: string;
dsn: string;
tables: string;
};
type FormValues = {
name: string;
direction: string;
conflict_policy: string;
poll_interval_ms: number;
agent_id?: number | null;
app_slug?: string;
local: EndpointForm;
remote: EndpointForm;
pk_columns: string; // table:pk,table:pk
};
function parsePKs(s: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of (s || "").split(",")) {
const [t, p] = part.split(":").map((x) => x.trim());
if (t && p) out[t] = p;
}
return out;
}
/** DSN 脱敏file 库尽量只露文件名 */
function formatDsnHint(dsn: string): string {
const masked = (dsn || "").replace(/:[^:@/]+@/, ":***@");
const fileMatch = masked.match(/(?:^|[\\/])([^\\/?#]+\.db)(?:\?|$)/i);
if (fileMatch) return fileMatch[1];
if (masked.length > 56) return `${masked.slice(0, 56)}`;
return masked;
}
function bindingLocalLabel(b: SyncBinding): string {
return (b.database_name || "").trim() || b.local_database_id;
}
function bindingOnlineLabel(b: SyncBinding): string {
return (b.display_name || "").trim() || b.online_db_id;
}
/** 联调烟雾 Binding占位 id易被误认为「库名对不上」 */
function isSmokeBinding(b: SyncBinding): boolean {
const note = (b.note || "").toLowerCase();
if (note.includes("smoke") || note.includes("user-jwt")) return true;
const local = (b.local_database_id || "").toLowerCase();
const online = (b.online_db_id || "").toLowerCase();
return local.startsWith("local_") || online.startsWith("online_");
}
/** 产品:与本地一致的库名优先,勿用落库文件名当主标题 */
function channelOnlineTitle(ch: SyncChannel, bindings: SyncBinding[]): string {
const linked = bindings.filter((b) => b.channel_id === ch.id);
const localNames = linked.map((b) => (b.database_name || "").trim()).filter(Boolean);
if (localNames.length) return localNames[0];
const onlineNames = linked.map((b) => (b.display_name || "").trim()).filter(Boolean);
if (onlineNames.length) return onlineNames[0];
if ((ch.name || "").trim()) return ch.name.trim();
return ch.remote?.driver || "线上库";
}
function channelOnlineIds(ch: SyncChannel, bindings: SyncBinding[]): string {
const linked = bindings.filter((b) => b.channel_id === ch.id);
const ids = linked.map((b) => b.online_db_id).filter(Boolean);
const uniq = [...new Set(ids)];
if (!uniq.length) return "";
// 有可读本地名时副文案带上 local↔online id便于对账
const localName = linked.map((b) => (b.database_name || "").trim()).find(Boolean);
if (localName && linked[0]?.local_database_id) {
return `${linked[0].local_database_id}${uniq.join(" · ")}`;
}
return uniq.join(" · ");
}
function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
return {
id,
name: v.name,
direction: v.direction,
conflict_policy: v.conflict_policy,
poll_interval_ms: v.poll_interval_ms || 500,
agent_id: v.agent_id || undefined,
app_slug: (v.app_slug || "").trim() || undefined,
local: {
driver: v.local.driver,
dsn: v.local.dsn,
tables: (v.local.tables || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean),
},
remote: {
driver: v.remote.driver,
dsn: v.remote.dsn,
tables: (v.remote.tables || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean),
},
pk_columns: parsePKs(v.pk_columns),
};
}
export function SyncPage(props: {
session: Session;
busy: boolean;
setBusy: (v: boolean) => void;
setError: (v: string) => void;
setInfo: (v: string) => void;
}) {
const { session, busy, setBusy, setError, setInfo } = props;
const { message } = AntApp.useApp();
const [items, setItems] = useState<SyncChannel[]>([]);
const [bindings, setBindings] = useState<SyncBinding[]>([]);
const [bindCodes, setBindCodes] = useState<BindCode[]>([]);
const [agents, setAgents] = useState<AgentAccount[]>([]);
const [appOptions, setAppOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<SyncChannel | null>(null);
const [showSmokeBindings, setShowSmokeBindings] = useState(false);
const [filterAgentId, setFilterAgentId] = useState<number | undefined>(undefined);
const [inspectOpen, setInspectOpen] = useState(false);
const [inspectCh, setInspectCh] = useState<SyncChannel | null>(null);
const [inspectSide, setInspectSide] = useState<"remote" | "local">("remote");
const [inspectData, setInspectData] = useState<SyncInspectResult | null>(null);
const [inspectLoading, setInspectLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<SyncPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [form] = Form.useForm<FormValues>();
const visibleBindings = showSmokeBindings
? bindings
: bindings.filter((b) => !isSmokeBinding(b));
const smokeHidden = bindings.length - visibleBindings.length;
const visibleChannels = filterAgentId
? items.filter((c) => c.agent_id === filterAgentId || agents.find((a) => a.agent_id === filterAgentId)?.channel_id === c.id)
: items;
async function refresh() {
setLoading(true);
try {
const [ch, bind, ag, apps, codes] = await Promise.all([
listSyncChannels(session),
listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })),
listAgents(session).catch(() => ({ items: [] as AgentAccount[] })),
listApps(session).catch(() => ({ items: [] as { slug: string; name: string; status: string }[] })),
listBindCodes(session).catch(() => ({ items: [] as BindCode[] })),
]);
setItems(ch.items || []);
setBindings(bind.items || []);
setAgents(ag.items || []);
setBindCodes(codes.items || []);
setAppOptions(
(apps.items || [])
.filter((a) => a.status === "published" || !a.status)
.map((a) => ({ value: a.slug, label: `${a.name || a.slug} (${a.slug})` }))
);
} catch (e: any) {
const msg = e.message || String(e);
setError(msg);
message.error(msg);
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.accessToken]);
function openCreate() {
setEditing(null);
form.setFieldsValue({
name: "本地 B ↔ 线上 A",
direction: "local_to_remote",
conflict_policy: "lww_source",
poll_interval_ms: 500,
agent_id: undefined,
app_slug: undefined,
local: { driver: "sqlite", dsn: "file:./data/local.db", tables: "article" },
remote: {
driver: "postgres",
dsn: "postgres://user:pass@127.0.0.1:5432/app?sslmode=disable",
tables: "article",
},
pk_columns: "article:id",
});
setOpen(true);
}
function openEdit(ch: SyncChannel) {
setEditing(ch);
form.setFieldsValue({
name: ch.name,
direction: ch.direction,
conflict_policy: ch.conflict_policy,
poll_interval_ms: ch.poll_interval_ms,
agent_id: ch.agent_id || undefined,
app_slug: ch.app_slug || undefined,
local: {
driver: ch.local.driver,
dsn: ch.local.dsn,
tables: (ch.local.tables || []).join(","),
},
remote: {
driver: ch.remote.driver,
dsn: ch.remote.dsn,
tables: (ch.remote.tables || []).join(","),
},
pk_columns: Object.entries(ch.pk_columns || {})
.map(([t, p]) => `${t}:${p}`)
.join(","),
});
setOpen(true);
}
async function openInspect(ch: SyncChannel, side: "remote" | "local" = "remote") {
setInspectCh(ch);
setInspectSide(side);
setInspectOpen(true);
setInspectData(null);
setInspectLoading(true);
try {
const data = await inspectSyncChannel(session, ch.id, { side, include_sync_meta: true });
setInspectData(data);
} catch (e: any) {
message.error(e.message || String(e));
setInspectOpen(false);
} finally {
setInspectLoading(false);
}
}
async function openPreview(table: string) {
if (!inspectCh) return;
setPreviewLoading(true);
setPreviewOpen(true);
setPreviewData(null);
try {
const data = await previewSyncTable(session, inspectCh.id, {
table,
side: inspectSide,
limit: 50,
});
setPreviewData(data);
} catch (e: any) {
message.error(e.message || String(e));
setPreviewOpen(false);
} finally {
setPreviewLoading(false);
}
}
function formatCheckpointTime(iso?: string) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
async function openRestore(r: SyncChannel) {
setBusy(true);
try {
const meta = await getSyncCheckpointMeta(session, r.id);
if (!meta.latest && !meta.previous) {
message.warning("尚无成功同步快照。请先完成一次同步(含自动推送),约 30 秒后生成。");
return;
}
let which: "latest" | "previous" = meta.latest ? "latest" : "previous";
const options: { value: "latest" | "previous"; label: string }[] = [];
if (meta.latest) {
options.push({
value: "latest",
label: `最近一次 · ${formatCheckpointTime(meta.latest.synced_at)}${meta.latest.row_count ?? 0} 行 / ${meta.latest.table_count ?? 0} 表)`,
});
}
if (meta.previous) {
options.push({
value: "previous",
label: `上一代 · ${formatCheckpointTime(meta.previous.synced_at)}${meta.previous.row_count ?? 0} 行 / ${meta.previous.table_count ?? 0} 表)`,
});
}
Modal.confirm({
title: `数据恢复 · ${r.name || r.id}`,
width: 560,
content: (
<div>
<p style={{ marginTop: 0 }}>
<strong>线</strong>upsert /pull 线
</p>
<p style={{ color: "rgba(0,0,0,0.45)", fontSize: 13 }}>
</p>
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
<Select
style={{ width: "100%", marginTop: 6 }}
defaultValue={which}
options={options}
onChange={(v) => {
which = v;
}}
/>
</div>
</div>
),
okText: "确认恢复",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const res = await restoreSyncChannel(session, r.id, { which, confirm: true });
message.success(
`已恢复:写入 ${res.upserted ?? 0} 行,清理多余 ${res.deleted ?? 0} 行(快照 ${formatCheckpointTime(res.synced_at)}`
);
setInfo("线上库已按同步快照恢复");
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
throw e;
}
},
});
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}
function confirmDropTable(table: string) {
if (!inspectCh) return;
const sideLabel = inspectSide === "remote" ? "线上库" : "本机端(通道 local";
Modal.confirm({
title: `删除表「${table}」?`,
content: (
<div>
<p>
<strong>{sideLabel}</strong>
</p>
<p style={{ marginBottom: 0 }}>
线<strong></strong> push/ensure 线
</p>
</div>
),
okText: "确认删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const res = await dropSyncTable(session, inspectCh.id, {
table,
side: inspectSide,
});
message.success(res.message || (res.dropped ? "已删除" : "表不存在"));
if (previewData?.table === table) setPreviewOpen(false);
await openInspect(inspectCh, inspectSide);
} catch (e: any) {
message.error(e.message || String(e));
throw e;
}
},
});
}
async function onSave() {
const v = await form.validateFields();
setBusy(true);
try {
const body = toChannelBody(v, editing?.id);
const saved = editing
? await updateSyncChannel(session, editing.id, body)
: await createSyncChannel(session, body);
const id = (saved as SyncChannel)?.id || editing?.id;
if (id) {
try {
await startSyncChannel(session, id);
setInfo("同步通道已保存并启动");
} catch {
setInfo("同步通道已保存(启动失败时可手动点「启动」)");
}
} else {
setInfo("同步通道已保存");
}
setOpen(false);
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}
async function onTest() {
const v = await form.validateFields();
setBusy(true);
try {
const body = toChannelBody(v);
const res = await testSyncEndpoints(session, {
local: body.local!,
remote: body.remote!,
side: "both",
});
const lok = (res.local as any)?.ok;
const rok = (res.remote as any)?.ok;
if (lok && rok) message.success("本地与线上数据库均可连接");
else message.warning(JSON.stringify(res));
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}
return (
<div className="panel">
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary">
<strong></strong> DSN / 线
<Typography.Text code>Binding</Typography.Text> + JWT
<strong></strong><strong></strong>
<strong> + </strong>/线/
TokenZ8f
线/ pull·bootstrap 线<strong></strong>线
线 A <Typography.Text code>postgres</Typography.Text>
</Typography.Paragraph>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="冲突已自动按 LWW 处理;覆盖审计请到「平台超管 · 租户/公司 · LWW 覆盖日志」查看。公司侧无冲突台。"
/>
<Space style={{ marginBottom: 12 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void refresh()}>
</Button>
<Select
allowClear
placeholder="按智能体筛选通道"
style={{ minWidth: 220 }}
value={filterAgentId}
onChange={(v) => setFilterAgentId(v)}
options={agents.map((a) => ({
value: a.agent_id,
label: `${a.name}${a.channel_id ? " · 已绑通道" : ""}`,
}))}
/>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={visibleChannels}
pagination={false}
locale={{
emptyText: filterAgentId
? "该智能体暂无同步通道"
: "该公司暂无同步通道(未绑定智能体或尚未创建通道)",
}}
columns={[
{
title: "通道 ID",
dataIndex: "id",
width: 280,
render: (id: string) => (
<Typography.Text copyable={{ text: id }} style={{ fontSize: 12 }}>
{id}
</Typography.Text>
),
},
{ title: "名称", dataIndex: "name" },
{
title: "方向",
dataIndex: "direction",
render: (d: string) => <Tag>{d}</Tag>,
},
{
title: "本地",
render: (_: unknown, r: SyncChannel) => `${r.local.driver}`,
},
{
title: "线上",
render: (_: unknown, r: SyncChannel) => {
const title = channelOnlineTitle(r, bindings);
const ids = channelOnlineIds(r, bindings);
const path = formatDsnHint(r.remote?.dsn || "");
return (
<span>
<Typography.Text strong>{title}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.remote?.driver}
{ids ? ` · ${ids}` : ""}
{path ? ` · ${path}` : ""}
</Typography.Text>
</span>
);
},
},
{
title: "智能体/模块",
render: (_: unknown, r: SyncChannel) => {
const ag = agents.find((a) => a.agent_id === r.agent_id);
const app = appOptions.find((o) => o.value === r.app_slug);
const moduleLabel = r.app_slug
? app
? `${(app.label.split(" (")[0] || app.label).trim()}`
: r.app_slug
: "未绑模块";
return (
<span>
{ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{moduleLabel}
{r.app_slug && app ? (
<>
<br />
<span style={{ opacity: 0.75 }}>{r.app_slug}</span>
</>
) : null}
</Typography.Text>
</span>
);
},
},
{
title: "状态",
render: (_: unknown, r: SyncChannel) =>
r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: "推送次数",
render: (_: unknown, r: SyncChannel) => (
<span>
{r.stats?.pushed_ok || 0}
{r.stats?.pushed_skipped ? `(跳过 ${r.stats.pushed_skipped}` : ""}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
push
</Typography.Text>
</span>
),
},
{
title: "操作",
render: (_: unknown, r: SyncChannel) => (
<Space wrap>
<Button size="small" icon={<TableOutlined />} onClick={() => void openInspect(r, "remote")}>
线
</Button>
<Button size="small" onClick={() => openEdit(r)}>
</Button>
{r.enabled ? (
<Button
size="small"
icon={<PauseCircleOutlined />}
onClick={async () => {
await stopSyncChannel(session, r.id);
await refresh();
}}
>
</Button>
) : (
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
onClick={async () => {
try {
await startSyncChannel(session, r.id);
setInfo("同步已启动");
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
}
}}
>
</Button>
)}
<Button
size="small"
onClick={async () => {
setBusy(true);
try {
const res = await reconcileSyncChannel(session, r.id);
const parts = (res.reports || []).map(
(x) =>
`${x.table}: 补推${x.patched_push} 补拉${x.patched_pull}(仅本地${(x.only_local || []).length} 仅线上${(x.only_remote || []).length}`
);
message.success(parts.length ? parts.join("") : "同步修复完成,无差异");
setInfo("同步修复完成");
await refresh();
} catch (e: any) {
const msg = e.message || String(e);
if (/过于频繁|429|Too Many/i.test(msg)) {
message.warning(msg || "对账过于频繁,请稍后再试");
} else {
message.error(msg);
}
} finally {
setBusy(false);
}
}}
>
</Button>
<Button size="small" onClick={() => void openRestore(r)}>
</Button>
<Button
size="small"
danger
onClick={() => {
Modal.confirm({
title: `删除通道「${r.name || r.id}」?`,
content: r.is_system_default
? "这是公司默认同步通道。删除后将自动重建并重挂库绑定,账号落点不会丢失。"
: "删除后,仍挂在此通道上的绑定会自动改挂公司默认同步通道。",
okText: "确认删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
setBusy(true);
try {
const res = await deleteSyncChannel(session, r.id);
if (res.heal_error) {
message.warning(`通道已删,自愈未完成:${res.heal_error}`);
} else if (res.recreated_default) {
message.success("已删除;公司默认同步通道已自动重建并重挂绑定");
} else {
message.success("已删除");
}
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
throw e;
} finally {
setBusy(false);
}
},
});
}}
>
</Button>
</Space>
),
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 28 }}>
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
<Typography.Text code>POST /auth/bind-code/redeem</Typography.Text>{" "}
<Typography.Text code>13531041944</Typography.Text>
</Typography.Paragraph>
<Space style={{ marginBottom: 12 }}>
<Button
type="primary"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
const bc = await createBindCode(session, { max_uses: 1, expires_hours: 168 });
message.success(`已生成绑定码 ${bc.code}`);
setInfo(`绑定码 ${bc.code}(可复制发给用户)`);
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}}
>
</Button>
</Space>
<Table
rowKey="code"
loading={loading}
dataSource={bindCodes}
pagination={false}
locale={{ emptyText: "暂无绑定码" }}
columns={[
{
title: "绑定码",
dataIndex: "code",
render: (c: string) => <Typography.Text copyable code>{c}</Typography.Text>,
},
{
title: "通道",
dataIndex: "channel_id",
render: (id: string) =>
id ? <Typography.Text copyable={{ text: id }}>{id.slice(0, 8)}</Typography.Text> : "—",
},
{
title: "次数",
render: (_: unknown, r: BindCode) => `${r.used_count}/${r.max_uses}`,
width: 80,
},
{
title: "过期",
dataIndex: "expires_at",
render: (t: string) => (t ? new Date(t).toLocaleString() : "—"),
},
{
title: "状态",
width: 90,
render: (_: unknown, r: BindCode) => {
if (r.revoked) return <Tag color="default"></Tag>;
if (r.used_count >= r.max_uses) return <Tag></Tag>;
if (r.expires_at && new Date(r.expires_at).getTime() < Date.now())
return <Tag color="warning"></Tag>;
return <Tag color="success"></Tag>;
},
},
{
title: "操作",
width: 90,
render: (_: unknown, r: BindCode) => (
<Button
size="small"
danger
disabled={!!r.revoked || busy}
onClick={async () => {
setBusy(true);
try {
await revokeBindCode(session, r.code);
setInfo(`已撤销 ${r.code}`);
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}}
>
</Button>
),
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 28 }}>
线
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
<Typography.Text code>local_database_id</Typography.Text> {" "}
<Typography.Text code>online_db_id</Typography.Text>{" "}
<strong></strong>
ensure <Typography.Text code>database_name</Typography.Text> /{" "}
<Typography.Text code>display_name</Typography.Text>
线 Binding
<Typography.Text code>local_*</Typography.Text> / note=smoke
</Typography.Paragraph>
<Space style={{ marginBottom: 8 }}>
<Checkbox
checked={showSmokeBindings}
onChange={(e) => setShowSmokeBindings(e.target.checked)}
>
Binding
{smokeHidden > 0 ? `(已隐藏 ${smokeHidden} 条)` : ""}
</Checkbox>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={visibleBindings}
pagination={false}
locale={{ emptyText: "暂无 Binding客户端选「同步」并登记后出现" }}
columns={[
{
title: "本地库名",
render: (_: unknown, b: SyncBinding) => (
<span>
<Typography.Text strong>{bindingLocalLabel(b)}</Typography.Text>
{b.database_name ? (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{b.local_database_id}
</Typography.Text>
</>
) : null}
</span>
),
},
{
title: "线上库名",
render: (_: unknown, b: SyncBinding) => (
<span>
<Typography.Text strong>{bindingOnlineLabel(b)}</Typography.Text>
{b.display_name ? (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{b.online_db_id}
</Typography.Text>
</>
) : null}
</span>
),
},
{
title: "映射",
render: (_: unknown, b: SyncBinding) => (
<Typography.Text style={{ fontSize: 13 }}>
{bindingLocalLabel(b)} {bindingOnlineLabel(b)}
</Typography.Text>
),
},
{
title: "通道",
dataIndex: "channel_id",
render: (id: string) => {
if (!id) return "—";
const ch = items.find((c) => c.id === id);
if (!ch) {
return (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
· {id.slice(0, 8)}
</Typography.Text>
);
}
const tables = (ch.remote?.tables || []).join(", ");
return (
<span>
{ch.name || id}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{tables
? `策略表(参考,非整库限制):${tables}`
: "整库同步中Binding 任意表可 push"}
</Typography.Text>
</span>
);
},
},
{ title: "备注", dataIndex: "note", ellipsis: true },
{
title: "共享",
width: 70,
render: (_: unknown, b: SyncBinding) =>
b.shared ? <Tag color="blue"></Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
5
= agent push
线 push Binding
Binding <Typography.Text code>shared=true</Typography.Text>
</Typography.Paragraph>
<Modal
title={
inspectCh
? `线上库表 · ${channelOnlineTitle(inspectCh, bindings)}${inspectSide === "local" ? "(本机端)" : ""}`
: "库表"
}
open={inspectOpen}
onCancel={() => setInspectOpen(false)}
width={820}
footer={
<Space>
<Button
loading={inspectLoading}
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide === "remote" ? "local" : "remote")}
>
{inspectSide === "remote" ? "本机端" : "线上"}
</Button>
<Button
loading={inspectLoading}
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide)}
>
</Button>
<Button type="primary" onClick={() => setInspectOpen(false)}>
</Button>
</Space>
}
>
{inspectData?.dsn_hint ? (
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{inspectData.driver} · {inspectData.dsn_hint}
{inspectData.message ? ` · ${inspectData.message}` : ""}
</Typography.Paragraph>
) : null}
<Table
rowKey="name"
loading={inspectLoading}
dataSource={(inspectData?.tables || []).filter((t) => !String(t.name || "").startsWith("_ajz_"))}
pagination={false}
size="small"
locale={{ emptyText: "暂无业务表(尚未 push 或连不上库)" }}
columns={[
{ title: "表名", dataIndex: "name" },
{
title: "行数",
dataIndex: "row_count",
width: 90,
render: (n: number) => <Typography.Text strong>{n}</Typography.Text>,
},
{
title: "字段数",
dataIndex: "column_count",
width: 90,
},
{
title: "字段",
dataIndex: "columns",
ellipsis: true,
render: (cols: string[]) => (cols || []).join(", "),
},
{
title: "操作",
width: 168,
render: (_: unknown, t: { name: string; row_count: number }) => (
<Space size={4}>
<Button size="small" icon={<EyeOutlined />} onClick={() => void openPreview(t.name)}>
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => confirmDropTable(t.name)}
>
</Button>
</Space>
),
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
{(inspectData?.tables || []).some((t) => String(t.name || "").startsWith("_ajz_")) ? (
<>
<Typography.Text code>_ajz_*</Typography.Text>
/outbox
</>
) : null}
= push/ensure
</Typography.Paragraph>
</Modal>
<Modal
title={previewData ? `预览 · ${previewData.table}(共 ${previewData.total} 行,显示前 ${previewData.limit}` : "预览"}
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
width={960}
footer={
<Button type="primary" onClick={() => setPreviewOpen(false)}>
</Button>
}
>
<Table
loading={previewLoading}
dataSource={(previewData?.rows || []).map((row, i) => ({ ...row, __k: i }))}
rowKey="__k"
size="small"
scroll={{ x: true }}
pagination={false}
columns={(previewData?.columns || []).map((c) => ({
title: c,
dataIndex: c,
ellipsis: true,
render: (v: unknown) =>
v === null || v === undefined ? (
<Typography.Text type="secondary">null</Typography.Text>
) : (
String(v)
),
}))}
/>
</Modal>
<Modal
title={editing ? "编辑同步通道" : "新建同步通道"}
open={open}
onCancel={() => setOpen(false)}
width={720}
footer={
<Space>
<Button icon={<ApiOutlined />} loading={busy} onClick={() => void onTest()}>
</Button>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" loading={busy} onClick={() => void onSave()}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="agent_id"
label="所属智能体"
extra="哪个智能体的库就选哪个;模块数据应落在该智能体对应线上库"
>
<Select
allowClear
placeholder="可选"
options={agents.map((a) => ({
value: a.agent_id,
label: `${a.name} (#${a.agent_id})`,
}))}
/>
</Form.Item>
<Form.Item
name="app_slug"
label="关联模块"
extra="已发布模块;本地上传/智能体管理的模块数据与此对照"
>
<Select allowClear showSearch placeholder="可选" options={appOptions} />
</Form.Item>
<Form.Item name="direction" label="方向" rules={[{ required: true }]}>
<Select
options={[
{ value: "local_to_remote", label: "本地 → 线上" },
{ value: "remote_to_local", label: "线上 → 本地" },
{ value: "bidirectional", label: "双向" },
]}
/>
</Form.Item>
<Form.Item
name="conflict_policy"
label="冲突策略"
extra="自动 LWW覆盖日志仅平台超级管理员可查公司侧无冲突台"
>
<Select
options={[
{ value: "lww_source", label: "源端覆盖推荐B→A" },
{ value: "lww_target", label: "保留目标" },
{ value: "queue", label: "入队(调试用,租户不可见)" },
]}
/>
</Form.Item>
<Form.Item name="poll_interval_ms" label="轮询间隔(ms)">
<InputNumber min={100} max={60000} style={{ width: "100%" }} />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Form.Item name={["local", "driver"]} label="驱动" rules={[{ required: true }]}>
<Select options={[{ value: "sqlite" }, { value: "mysql" }, { value: "postgres" }]} />
</Form.Item>
<Form.Item
name={["local", "dsn"]}
label="DSN"
rules={[{ required: true }]}
extra="SQLite 例file:./data/local.db"
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item
name={["local", "tables"]}
label="策略表(参考)"
rules={[{ required: true }]}
extra="Z4不按此名单拒收用户 JWT + Binding 下任意表可 push。此处仅运维提示。"
>
<Input placeholder="article,order" />
</Form.Item>
<Typography.Text strong>线 A Postgres</Typography.Text>
<Form.Item
name={["remote", "driver"]}
label="驱动"
rules={[{ required: true }]}
extra="生产推荐 postgresmysql 兼容存量sqlite 仅联调临时 A不宜多 writer"
>
<Select
options={[
{ value: "postgres", label: "postgres推荐" },
{ value: "mysql", label: "mysql" },
{ value: "sqlite", label: "sqlite仅联调" },
]}
/>
</Form.Item>
<Form.Item
name={["remote", "dsn"]}
label="线上 DSN"
rules={[{ required: true }]}
extra="Postgres 例postgres://user:pass@127.0.0.1:5432/dbname?sslmode=disable"
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item
name={["remote", "tables"]}
label="策略表(参考)"
rules={[{ required: true }]}
extra="非整库限制;与本地提示一致即可。验同步请用「查看线上表」"
>
<Input />
</Form.Item>
<Form.Item
name="pk_columns"
label="主键映射"
extra="格式 table:pk多个用逗号。默认每表 id"
>
<Input placeholder="article:id" />
</Form.Item>
</Form>
</Modal>
</div>
);
}