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>
1187 lines
42 KiB
TypeScript
1187 lines
42 KiB
TypeScript
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>:在「用户管理」为智能体填写同步通道/线上库/落库名后,可按智能体筛通道;
|
||
模块发布(智能体 Token)优先写入绑定库。本机库管理模块表见宇恒侧(Z8f)。
|
||
仅线上/首启可用 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="生产推荐 postgres;mysql 兼容存量;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>
|
||
);
|
||
}
|