feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -0,0 +1,351 @@
/**
* 店铺管理员管理页面。
*
* 功能:列表(分页+搜索)、创建、编辑、重置密码、删除。
* 仅 admin_type='tenant_admin' 可访问(后端 + 前端双重守卫)。
*/
import React, { useCallback, useEffect, useState } from "react";
import {
Table, Button, Input, Space, Modal, Form, Select, Tag, Switch,
message, Popconfirm, Card,
} from "antd";
import {
PlusOutlined, SearchOutlined, ReloadOutlined, LockOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { tenantApi } from "../../services/api";
import { useAuth } from "../../hooks/useAuth";
/* ------------------------------------------------------------------ */
/* 类型 */
/* ------------------------------------------------------------------ */
interface SiteAdminItem {
id: number;
username: string;
displayName: string | null;
managedSiteIds: number[];
isActive: boolean;
createdAt: string | null;
lastLoginAt: string | null;
}
interface SiteOption {
siteId: number;
siteName: string;
siteCode: string;
}
/* ------------------------------------------------------------------ */
/* 主组件 */
/* ------------------------------------------------------------------ */
const SiteAdmins: React.FC = () => {
const { user } = useAuth();
/* ---- 状态 ---- */
const [items, setItems] = useState<SiteAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [keyword, setKeyword] = useState("");
const [loading, setLoading] = useState(false);
const [sites, setSites] = useState<SiteOption[]>([]);
// Modal 状态
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [resetPwdOpen, setResetPwdOpen] = useState(false);
const [currentAdmin, setCurrentAdmin] = useState<SiteAdminItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [resetPwdForm] = Form.useForm();
/* ---- 加载管辖店铺列表 ---- */
const loadSites = useCallback(async () => {
try {
const { data } = await tenantApi.get<SiteOption[]>("/my-sites");
setSites(Array.isArray(data) ? data : []);
} catch {
/* 静默 */
}
}, []);
/* ---- 加载列表 ---- */
const loadList = useCallback(async () => {
setLoading(true);
try {
const { data } = await tenantApi.get("/site-admins", {
params: { page, page_size: pageSize, keyword: keyword || undefined },
});
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch {
message.error("加载店铺管理员列表失败");
} finally {
setLoading(false);
}
}, [page, pageSize, keyword]);
useEffect(() => { loadSites(); }, [loadSites]);
useEffect(() => { loadList(); }, [loadList]);
/* ---- 创建 ---- */
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
setSubmitting(true);
await tenantApi.post("/site-admins", {
username: values.username,
password: values.password,
displayName: values.displayName || null,
managedSiteIds: values.managedSiteIds,
});
message.success("创建成功");
setCreateOpen(false);
createForm.resetFields();
loadList();
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 编辑 ---- */
const openEdit = (record: SiteAdminItem) => {
setCurrentAdmin(record);
editForm.setFieldsValue({
displayName: record.displayName,
managedSiteIds: record.managedSiteIds,
isActive: record.isActive,
});
setEditOpen(true);
};
const handleEdit = async () => {
if (!currentAdmin) return;
try {
const values = await editForm.validateFields();
setSubmitting(true);
await tenantApi.patch(`/site-admins/${currentAdmin.id}`, {
displayName: values.displayName || null,
managedSiteIds: values.managedSiteIds,
isActive: values.isActive,
});
message.success("修改成功");
setEditOpen(false);
loadList();
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 重置密码 ---- */
const openResetPwd = (record: SiteAdminItem) => {
setCurrentAdmin(record);
resetPwdForm.resetFields();
setResetPwdOpen(true);
};
const handleResetPwd = async () => {
if (!currentAdmin) return;
try {
const values = await resetPwdForm.validateFields();
setSubmitting(true);
await tenantApi.post(`/site-admins/${currentAdmin.id}/reset-password`, {
newPassword: values.newPassword,
});
message.success("密码已重置");
setResetPwdOpen(false);
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 删除 ---- */
const handleDelete = async (id: number) => {
try {
await tenantApi.delete(`/site-admins/${id}`);
message.success("已删除");
loadList();
} catch {
message.error("删除失败");
}
};
/* ---- 店铺名称映射 ---- */
const siteMap = new Map(sites.map((s) => [s.siteId, s]));
/* ---- 表格列 ---- */
const columns: ColumnsType<SiteAdminItem> = [
{ title: "用户名", dataIndex: "username", width: 160 },
{ title: "显示名称", dataIndex: "displayName", width: 120 },
{
title: "管辖门店", dataIndex: "managedSiteIds", width: 200,
render: (ids: number[]) =>
ids.map((id) => {
const s = siteMap.get(id);
return <Tag key={id}>{s ? `${s.siteCode} ${s.siteName}` : `#${id}`}</Tag>;
}),
},
{
title: "状态", dataIndex: "isActive", width: 80,
render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
},
{ title: "创建时间", dataIndex: "createdAt", width: 170 },
{ title: "最后登录", dataIndex: "lastLoginAt", width: 170 },
{
title: "操作", width: 220, fixed: "right",
render: (_, record) => (
<Space size="small">
<Button size="small" onClick={() => openEdit(record)}></Button>
<Button size="small" icon={<LockOutlined />} onClick={() => openResetPwd(record)}></Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
/* ---- 权限守卫 ---- */
if (user?.adminType !== "tenant_admin") {
return <Card><p></p></Card>;
}
/* ---- 用户名前缀提示 ---- */
const siteCodePrefix = sites.length > 0 ? sites[0].siteCode : "";
return (
<Card title="店铺管理员管理">
{/* 工具栏 */}
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="搜索用户名/名称"
prefix={<SearchOutlined />}
allowClear
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ width: 220 }}
/>
<Button icon={<ReloadOutlined />} onClick={loadList}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>
</Space>
{/* 表格 */}
<Table<SiteAdminItem>
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page, pageSize, total, showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
{/* 创建 Modal */}
<Modal
title="新建店铺管理员"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={handleCreate}
confirmLoading={submitting}
destroyOnClose
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item
name="managedSiteIds" label="管辖门店"
rules={[{ required: true, message: "请选择至少一个门店" }]}
>
<Select
mode="multiple" placeholder="选择门店"
options={sites.map((s) => ({ value: s.siteId, label: `${s.siteCode} ${s.siteName}` }))}
/>
</Form.Item>
<Form.Item
name="username" label="用户名"
rules={[{ required: true, message: "请输入用户名" }]}
extra={siteCodePrefix ? `用户名须以 ${siteCodePrefix} 开头` : ""}
>
<Input maxLength={56} placeholder={siteCodePrefix ? `${siteCodePrefix}xxx` : "用户名"} />
</Form.Item>
<Form.Item
name="password" label="初始密码"
rules={[{ required: true, message: "请输入密码" }, { min: 6, message: "至少 6 位" }]}
>
<Input.Password />
</Form.Item>
<Form.Item name="displayName" label="显示名称">
<Input maxLength={100} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
{/* 编辑 Modal */}
<Modal
title={`编辑 — ${currentAdmin?.username ?? ""}`}
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={handleEdit}
confirmLoading={submitting}
destroyOnClose
>
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="displayName" label="显示名称">
<Input maxLength={100} />
</Form.Item>
<Form.Item
name="managedSiteIds" label="管辖门店"
rules={[{ required: true, message: "请选择至少一个门店" }]}
>
<Select
mode="multiple" placeholder="选择门店"
options={sites.map((s) => ({ value: s.siteId, label: `${s.siteCode} ${s.siteName}` }))}
/>
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Form>
</Modal>
{/* 重置密码 Modal */}
<Modal
title={`重置密码 — ${currentAdmin?.username ?? ""}`}
open={resetPwdOpen}
onCancel={() => setResetPwdOpen(false)}
onOk={handleResetPwd}
confirmLoading={submitting}
destroyOnClose
>
<Form form={resetPwdForm} layout="vertical" preserve={false}>
<Form.Item
name="newPassword" label="新密码"
rules={[{ required: true, message: "请输入新密码" }, { min: 6, message: "至少 6 位" }]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default SiteAdmins;