chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

467
web/src/SyncPage.tsx Normal file
View 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>
);
}