chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
467
web/src/SyncPage.tsx
Normal file
467
web/src/SyncPage.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
App as AntApp,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ApiOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Session,
|
||||
SyncChannel,
|
||||
SyncConflict,
|
||||
createSyncChannel,
|
||||
deleteSyncChannel,
|
||||
listSyncChannels,
|
||||
listSyncConflicts,
|
||||
reconcileSyncChannel,
|
||||
resolveSyncConflict,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
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 [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SyncChannel | null>(null);
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ch, cf] = await Promise.all([
|
||||
listSyncChannels(session),
|
||||
listSyncConflicts(session, true),
|
||||
]);
|
||||
setItems(ch.items || []);
|
||||
setConflicts(cf.items || []);
|
||||
} 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: "bidirectional",
|
||||
conflict_policy: "queue",
|
||||
poll_interval_ms: 500,
|
||||
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",
|
||||
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,
|
||||
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 onSave() {
|
||||
const v = await form.validateFields();
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = toChannelBody(v, editing?.id);
|
||||
if (editing) await updateSyncChannel(session, editing.id, body);
|
||||
else await createSyncChannel(session, body);
|
||||
setOpen(false);
|
||||
setInfo("同步通道已保存");
|
||||
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>可配置;通道按公司隔离,看不到其他公司的服务器。
|
||||
典型拓扑:A 线上 ↔ B 本地(双向),额外源 C 写入 B 再推到 A。
|
||||
防回声 + 版本幂等避免「多」;触发器与对账避免「漏」。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建通道
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void refresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ 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) => (
|
||||
<span>
|
||||
{r.remote.driver}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{(r.remote.dsn || "").replace(/:[^:@/]+@/, ":***@")}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
render: (_: unknown, r: SyncChannel) =>
|
||||
r.enabled ? <Tag color="green">运行中</Tag> : <Tag>已停止</Tag>,
|
||||
},
|
||||
{
|
||||
title: "统计",
|
||||
render: (_: unknown, r: SyncChannel) =>
|
||||
`↑${r.stats?.pushed_ok || 0} ↓${r.stats?.pulled_ok || 0} 冲突${r.stats?.conflicts || 0}`,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
render: (_: unknown, r: SyncChannel) => (
|
||||
<Space wrap>
|
||||
<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) {
|
||||
message.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
对账
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteSyncChannel(session, r.id);
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||||
冲突队列(未解决)
|
||||
</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={conflicts}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "通道", dataIndex: "channel_id", ellipsis: true },
|
||||
{ title: "表", dataIndex: "table" },
|
||||
{ title: "主键", dataIndex: "row_pk" },
|
||||
{ title: "来源", dataIndex: "source" },
|
||||
{ title: "说明", dataIndex: "message", ellipsis: true },
|
||||
{
|
||||
title: "操作",
|
||||
render: (_: unknown, r: SyncConflict) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await resolveSyncConflict(session, r.id, "keep_target");
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
保留目标
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await resolveSyncConflict(session, r.id, "discard");
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
丢弃
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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="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="冲突策略">
|
||||
<Select
|
||||
options={[
|
||||
{ value: "queue", label: "入冲突队列(推荐)" },
|
||||
{ value: "lww_source", label: "源端覆盖" },
|
||||
{ value: "lww_target", 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 }]}>
|
||||
<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" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["remote", "dsn"]}
|
||||
label="线上 DSN"
|
||||
rules={[{ required: true }]}
|
||||
extra="MySQL 例:user:pass@tcp(host:3306)/dbname?parseTime=true"
|
||||
>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name={["remote", "tables"]} label="表(逗号分隔)" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="pk_columns"
|
||||
label="主键映射"
|
||||
extra="格式 table:pk,多个用逗号。默认每表 id"
|
||||
>
|
||||
<Input placeholder="article:id" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user