feat: add Yuheng ticket bind, trial SMS off, shared bindings
Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
AgentAccount,
|
||||
BindCode,
|
||||
Session,
|
||||
SyncBinding,
|
||||
SyncChannel,
|
||||
@@ -38,6 +39,9 @@ import {
|
||||
listApps,
|
||||
listSyncBindings,
|
||||
listSyncChannels,
|
||||
listBindCodes,
|
||||
createBindCode,
|
||||
revokeBindCode,
|
||||
previewSyncTable,
|
||||
reconcileSyncChannel,
|
||||
startSyncChannel,
|
||||
@@ -163,6 +167,7 @@ export function SyncPage(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);
|
||||
@@ -190,15 +195,17 @@ export function SyncPage(props: {
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ch, bind, ag, apps] = await Promise.all([
|
||||
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)
|
||||
@@ -581,6 +588,101 @@ export function SyncPage(props: {
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -677,6 +779,12 @@ export function SyncPage(props: {
|
||||
},
|
||||
},
|
||||
{ title: "备注", dataIndex: "note", ellipsis: true },
|
||||
{
|
||||
title: "共享",
|
||||
width: 70,
|
||||
render: (_: unknown, b: SyncBinding) =>
|
||||
b.shared ? <Tag color="blue">共享</Tag> : <Typography.Text type="secondary">个人</Typography.Text>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -684,6 +792,7 @@ export function SyncPage(props: {
|
||||
冲突已改为自动 LWW;覆盖审计仅平台超级管理员可查。「同步修复」有最小间隔限流(默认 5 分钟)。
|
||||
↑ 推送次数 = agent push 成功次数,不是业务表数量。
|
||||
本地有、线上没有的表(如尚未 push 的业务表)属正常,不是 Binding 映射错了。
|
||||
Binding 标 <Typography.Text code>shared=true</Typography.Text> 为公司共享库(仅管理员可设)。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1143,6 +1143,7 @@ export async function ensureSyncBinding(
|
||||
display_name?: string;
|
||||
note?: string;
|
||||
user_id?: number;
|
||||
shared?: boolean;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/bindings`, {
|
||||
@@ -1167,11 +1168,70 @@ export type SyncBinding = {
|
||||
channel_id?: string;
|
||||
database_name?: string;
|
||||
display_name?: string;
|
||||
shared?: boolean;
|
||||
note?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type BindCode = {
|
||||
code: string;
|
||||
tenant_id: number;
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
max_uses: number;
|
||||
used_count: number;
|
||||
revoked?: boolean;
|
||||
expires_at?: string;
|
||||
created_by?: number;
|
||||
created_at?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export async function listBindCodes(session: Session) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list bind codes failed");
|
||||
return data as { items: BindCode[] };
|
||||
}
|
||||
|
||||
export async function createBindCode(
|
||||
session: Session,
|
||||
body?: {
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
max_uses?: number;
|
||||
expires_hours?: number;
|
||||
note?: string;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "create bind code failed");
|
||||
return data as BindCode;
|
||||
}
|
||||
|
||||
export async function revokeBindCode(session: Session, code: string) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes/${encodeURIComponent(code)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "revoke bind code failed");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
|
||||
const q = localDatabaseId
|
||||
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
|
||||
|
||||
Reference in New Issue
Block a user