feat: harden loose-offline sync for user JWT, schema, and console ops

Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 09:47:35 +08:00
parent 76cdcd760e
commit b04b180d30
59 changed files with 4762 additions and 308 deletions

View File

@@ -26,10 +26,14 @@ import {
AgentAccount,
Role,
Session,
SyncBinding,
SyncChannel,
createAgent,
deleteAgent,
listAgents,
listRoles,
listSyncBindings,
listSyncChannels,
rotateAgentSecret,
updateAgent,
} from "./api";
@@ -39,6 +43,9 @@ type FormValues = {
name: string;
role_id: number;
app_slugs: string[];
channel_id?: string;
online_db_id?: string;
database_name?: string;
};
export function AgentUsersPage(props: {
@@ -52,6 +59,8 @@ export function AgentUsersPage(props: {
const { message, modal } = AntApp.useApp();
const [agents, setAgents] = useState<AgentAccount[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [channels, setChannels] = useState<SyncChannel[]>([]);
const [bindings, setBindings] = useState<SyncBinding[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AgentAccount | null>(null);
@@ -66,9 +75,16 @@ export function AgentUsersPage(props: {
async function refresh() {
setLoading(true);
try {
const [a, r] = await Promise.all([listAgents(session), listRoles(session)]);
const [a, r, ch, bind] = await Promise.all([
listAgents(session),
listRoles(session),
listSyncChannels(session).catch(() => ({ items: [] as SyncChannel[] })),
listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })),
]);
setAgents(a.items || []);
setRoles(r.items || []);
setChannels(ch.items || []);
setBindings(bind.items || []);
} catch (e: any) {
const msg = e.message || String(e);
setError(msg);
@@ -94,6 +110,9 @@ export function AgentUsersPage(props: {
name: "",
role_id: pref?.role_id,
app_slugs: [],
channel_id: undefined,
online_db_id: undefined,
database_name: undefined,
});
setOpen(true);
}
@@ -104,6 +123,9 @@ export function AgentUsersPage(props: {
name: a.name || "",
role_id: a.role_id || undefined,
app_slugs: a.app_slugs || [],
channel_id: a.channel_id || undefined,
online_db_id: a.online_db_id || undefined,
database_name: a.database_name || undefined,
});
setOpen(true);
}
@@ -112,11 +134,17 @@ export function AgentUsersPage(props: {
const values = await form.validateFields();
setBusy(true);
try {
const bindBody = {
channel_id: values.channel_id || "",
online_db_id: values.online_db_id || "",
database_name: (values.database_name || "").trim() || "",
};
if (!editing) {
const res = await createAgent(session, {
name: values.name.trim(),
role_id: values.role_id,
app_slugs: values.app_slugs || [],
...bindBody,
});
modal.success({
title: "用户已创建",
@@ -135,6 +163,7 @@ export function AgentUsersPage(props: {
name: values.name.trim(),
role_id: values.role_id,
app_slugs: values.app_slugs || [],
...bindBody,
...(activate ? { status: "active" } : {}),
});
message.success(activate ? "已保存并启用" : "已保存");
@@ -173,7 +202,7 @@ export function AgentUsersPage(props: {
>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
#{session.tenantId}
<strong> / 线</strong>线Z8
</Typography.Paragraph>
<Table
@@ -222,6 +251,23 @@ export function AgentUsersPage(props: {
"—"
),
},
{
title: "同步绑定",
render: (_: unknown, r: AgentAccount) => {
if (!r.channel_id && !r.online_db_id && !r.database_name) return "—";
const ch = channels.find((c) => c.id === r.channel_id);
return (
<span style={{ fontSize: 12 }}>
{ch?.name || r.channel_id || "无通道"}
<br />
<Typography.Text type="secondary">
{r.online_db_id || "—"}
{r.database_name ? ` · ${r.database_name}` : ""}
</Typography.Text>
</span>
);
},
},
{
title: "操作",
width: 320,
@@ -363,6 +409,53 @@ export function AgentUsersPage(props: {
allowClear
/>
</Form.Item>
<Typography.Text strong>线 / Z8</Typography.Text>
<Form.Item
name="channel_id"
label="同步通道"
style={{ marginTop: 8 }}
extra="该智能体的模块与本机 sync 应对准同一通道线上库"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选"
options={channels.map((c) => ({
value: c.id,
label: `${c.name} (${c.id.slice(0, 8)}…)`,
}))}
onChange={(cid?: string) => {
if (!cid) return;
const b = bindings.find((x) => x.channel_id === cid);
if (b) {
form.setFieldsValue({
online_db_id: b.online_db_id,
database_name: b.display_name || b.database_name || form.getFieldValue("database_name"),
});
}
}}
/>
</Form.Item>
<Form.Item
name="online_db_id"
label="线上库 ID"
extra="与 Binding.online_db_id 对齐;宇恒 push 须带此 id。可选从 Binding 带出。"
>
<Input placeholder="online_db_id" allowClear list="agent-online-db-options" />
</Form.Item>
<datalist id="agent-online-db-options">
{[...new Set(bindings.map((b) => b.online_db_id).filter(Boolean))].map((id) => (
<option key={id} value={id} />
))}
</datalist>
<Form.Item
name="database_name"
label="模块落库名"
extra="智能体新建发布时优先写入此 Postgres 库database_per_app与通道 remote 对齐为佳"
>
<Input placeholder="例如 appdb_t1_aisite" allowClear />
</Form.Item>
</Form>
</Modal>
</Card>

View File

@@ -265,7 +265,7 @@ export function LoginShell(props: LoginProps) {
type="link"
size="small"
onClick={() => {
props.onUsername("13800000000");
props.onUsername("13531041945");
props.onPassword("ljk_admin");
}}
>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import {
App as AntApp,
Button,
Checkbox,
Form,
Input,
InputNumber,
@@ -18,13 +19,26 @@ import {
PlayCircleOutlined,
PauseCircleOutlined,
ApiOutlined,
TableOutlined,
EyeOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import {
AgentAccount,
Session,
SyncBinding,
SyncChannel,
SyncInspectResult,
SyncPreviewResult,
createSyncChannel,
deleteSyncChannel,
dropSyncTable,
inspectSyncChannel,
listAgents,
listApps,
listSyncBindings,
listSyncChannels,
previewSyncTable,
reconcileSyncChannel,
startSyncChannel,
stopSyncChannel,
@@ -43,6 +57,8 @@ type FormValues = {
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
@@ -57,6 +73,56 @@ function parsePKs(s: string): Record<string, string> {
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,
@@ -64,6 +130,8 @@ function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
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,
@@ -94,16 +162,48 @@ export function SyncPage(props: {
const { session, busy, setBusy, setError, setInfo } = props;
const { message } = AntApp.useApp();
const [items, setItems] = useState<SyncChannel[]>([]);
const [bindings, setBindings] = useState<SyncBinding[]>([]);
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 = await listSyncChannels(session);
const [ch, bind, ag, apps] = 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 }[] })),
]);
setItems(ch.items || []);
setBindings(bind.items || []);
setAgents(ag.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);
@@ -125,10 +225,12 @@ export function SyncPage(props: {
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: "mysql",
dsn: "user:pass@tcp(127.0.0.1:3306)/app?parseTime=true&charset=utf8mb4",
driver: "postgres",
dsn: "postgres://user:pass@127.0.0.1:5432/app?sslmode=disable",
tables: "article",
},
pk_columns: "article:id",
@@ -143,6 +245,8 @@ export function SyncPage(props: {
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,
@@ -160,6 +264,78 @@ export function SyncPage(props: {
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 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);
@@ -204,25 +380,39 @@ export function SyncPage(props: {
</Typography.Title>
<Typography.Paragraph type="secondary">
<strong></strong>
<strong></strong> <Typography.Text code>local_dbsync</Typography.Text> 线
线
/--
<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>
<Space style={{ marginBottom: 12 }}>
<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={items}
dataSource={visibleChannels}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name" },
@@ -237,15 +427,37 @@ export function SyncPage(props: {
},
{
title: "线上",
render: (_: unknown, r: SyncChannel) => (
<span>
{r.remote.driver}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{(r.remote.dsn || "").replace(/:[^:@/]+@/, ":***@")}
</Typography.Text>
</span>
),
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);
return (
<span>
{ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{r.app_slug || "未绑模块"}
</Typography.Text>
</span>
);
},
},
{
title: "状态",
@@ -253,14 +465,25 @@ export function SyncPage(props: {
r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: "统计",
render: (_: unknown, r: SyncChannel) =>
`${r.stats?.pushed_ok || 0}${r.stats?.pulled_ok || 0}`,
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>
@@ -336,10 +559,234 @@ export function SyncPage(props: {
]}
/>
<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(", ") || "(按 Binding 任意表)";
return (
<span>
{ch.name || id}
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{tables}
</Typography.Text>
</span>
);
},
},
{ title: "备注", dataIndex: "note", ellipsis: true },
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
LWW 5
= agent push
线 push Binding
</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}
@@ -361,6 +808,27 @@ export function SyncPage(props: {
<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={[
@@ -403,20 +871,31 @@ export function SyncPage(props: {
name={["local", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="同步表白名单表须为 UUID TEXT 主键(不可自增);外键闭包表须一并列入"
extra="通道表名单仅作提示;用户 JWT + Binding 下任意表可 push"
>
<Input placeholder="article,order" />
</Form.Item>
<Typography.Text strong>线</Typography.Text>
<Form.Item name={["remote", "driver"]} label="驱动" rules={[{ required: true }]}>
<Select options={[{ value: "mysql" }, { value: "postgres" }, { value: "sqlite" }]} />
<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="MySQL 例user:pass@tcp(host:3306)/dbname?parseTime=true"
extra="Postgres 例postgres://user:pass@127.0.0.1:5432/dbname?sslmode=disable"
>
<Input.TextArea rows={2} />
</Form.Item>
@@ -424,7 +903,7 @@ export function SyncPage(props: {
name={["remote", "tables"]}
label="表(逗号分隔)"
rules={[{ required: true }]}
extra="与本地表白名单一致;须满足 UUID TEXT PK + FK 闭包"
extra="与本地提示表一致即可;验同步请用「查看线上表」"
>
<Input />
</Form.Item>

View File

@@ -537,6 +537,12 @@ export type AgentAccount = {
status: string;
permissions: string[];
app_slugs: string[];
/** Z8b绑定同步通道 */
channel_id?: string;
/** Z8b绑定线上库 id */
online_db_id?: string;
/** Z8b模块落库名database_per_app */
database_name?: string;
created_by: number;
created_at: string;
last_token_at?: string;
@@ -563,7 +569,15 @@ export async function listAgents(session: Session) {
export async function createAgent(
session: Session,
body: { name: string; role_id: number; app_slugs: string[]; permissions?: string[] }
body: {
name: string;
role_id: number;
app_slugs: string[];
permissions?: string[];
channel_id?: string;
online_db_id?: string;
database_name?: string;
}
) {
const res = await apiFetch(`/api/v1/admin/agents`, {
method: "POST",
@@ -587,6 +601,9 @@ export async function updateAgent(
role_id?: number;
permissions?: string[];
app_slugs?: string[];
channel_id?: string;
online_db_id?: string;
database_name?: string;
}
) {
const res = await apiFetch(`/api/v1/admin/agents/${id}`, {
@@ -831,10 +848,16 @@ export type SyncChannel = {
local: SyncEndpoint;
remote: SyncEndpoint;
pk_columns: Record<string, string>;
/** 绑定智能体:模块/库归属对照 */
agent_id?: number;
/** 绑定已发布模块 slug */
app_slug?: string;
last_error?: string;
last_sync_at?: string;
stats?: {
pushed_ok: number;
pushed_applied?: number;
pushed_skipped?: number;
pulled_ok: number;
conflicts: number;
retries: number;
@@ -842,6 +865,32 @@ export type SyncChannel = {
};
};
export type SyncTableInspect = {
name: string;
row_count: number;
columns: string[];
column_count: number;
};
export type SyncInspectResult = {
ok: boolean;
side: string;
driver: string;
dsn_hint?: string;
tables: SyncTableInspect[];
message?: string;
};
export type SyncPreviewResult = {
ok: boolean;
table: string;
columns: string[];
rows: Record<string, unknown>[];
total: number;
limit: number;
message?: string;
};
export type SyncConflict = {
id: string;
channel_id: string;
@@ -1002,12 +1051,77 @@ export async function ingestSyncRows(
return data as { ok: boolean; ingested: number; hint?: string };
}
/** 控制台验同步:列出通道线上/本机库的表、行数、字段 */
export async function inspectSyncChannel(
session: Session,
id: string,
opts?: { side?: "remote" | "local"; include_sync_meta?: boolean }
) {
const side = opts?.side || "remote";
const q = new URLSearchParams({ side });
if (opts?.include_sync_meta) q.set("include_sync_meta", "1");
const res = await apiFetch(
`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/inspect?${q}`,
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
);
const data = await readJson(res);
throwIfBad(res, data, "inspect failed");
return data as SyncInspectResult;
}
/** 预览单表内容 */
export async function previewSyncTable(
session: Session,
id: string,
body: { table: string; side?: "remote" | "local"; limit?: number }
) {
const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/preview`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await readJson(res);
throwIfBad(res, data, "preview failed");
return data as SyncPreviewResult;
}
/** 控制台删业务表(仅当前 side不同步 DDL 到另一侧) */
export async function dropSyncTable(
session: Session,
id: string,
body: { table: string; side?: "remote" | "local" }
) {
const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/drop-table`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await readJson(res);
throwIfBad(res, data, "drop table failed");
return data as {
ok: boolean;
side: string;
table: string;
dropped: boolean;
meta_cleared?: number;
message?: string;
};
}
export async function ensureSyncBinding(
session: Session,
body: {
local_database_id: string;
online_db_id: string;
channel_id?: string;
database_name?: string;
display_name?: string;
note?: string;
user_id?: number;
}
@@ -1022,9 +1136,23 @@ export async function ensureSyncBinding(
});
const data = await readJson(res);
throwIfBad(res, data, "ensure binding failed");
return data;
return data as SyncBinding;
}
export type SyncBinding = {
id: string;
tenant_id: number;
user_id?: number;
local_database_id: string;
online_db_id: string;
channel_id?: string;
database_name?: string;
display_name?: string;
note?: string;
created_at?: string;
updated_at?: string;
};
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
const q = localDatabaseId
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
@@ -1034,7 +1162,7 @@ export async function listSyncBindings(session: Session, localDatabaseId?: strin
});
const data = await readJson(res);
throwIfBad(res, data, "list bindings failed");
return data as { items: Array<Record<string, unknown>> };
return data as { items: SyncBinding[] };
}
export async function listPlatformLwwOverrides(