web端增加微信绑定管理

This commit is contained in:
2026-07-07 18:25:15 +08:00
parent 887d728496
commit 98e9652865
7 changed files with 644 additions and 0 deletions
@@ -0,0 +1,317 @@
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
type Identity = {
actorType: ActorType;
actorId: string;
phone: string | null;
name: string | null;
wxOpenId: string;
wxUnionId: string | null;
phoneVerified?: boolean;
refLabel: string | null;
refId: string | null;
lastLoginAt: string | null;
status: string | number;
};
type GroupRow = {
groupKey: string;
unionId: string | null;
identityCount: number;
actorTypes: ActorType[];
multiRole: boolean;
primaryPhone: string | null;
latestLoginAt: string | null;
identities: Identity[];
};
const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
USER: 'C 端用户',
STORE: '门店账号',
PARTNER: '合伙人账号',
HQ: 'HQ 账号',
};
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
USER: 'blue',
STORE: 'green',
PARTNER: 'orange',
HQ: 'purple',
};
function renderActorTags(types: ActorType[]) {
return types.map((t) => (
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
{ACTOR_TYPE_LABELS[t]}
</Tag>
));
}
export default function WechatBindingsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
actorType: '',
phone: '',
unionId: '',
openId: '',
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
'/admin/wechat-bindings',
() => {
const qs = new URLSearchParams();
if (filters.actorType) qs.set('actorType', filters.actorType);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.unionId) qs.set('unionId', filters.unionId);
if (filters.openId) qs.set('openId', filters.openId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<GroupRow | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
async function openDetail(row: GroupRow) {
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
setDetail(res);
setDrawerOpen(true);
}
const columns: ColumnsType<GroupRow> = [
{
title: 'unionId',
dataIndex: 'unionId',
width: 180,
ellipsis: true,
render: (v) => v || <Tag> unionId</Tag>,
},
{
title: '身份数',
dataIndex: 'identityCount',
width: 90,
render: (v, r) => (
<Space size={4}>
<span>{v}</span>
{r.multiRole ? <Tag color="red"></Tag> : null}
</Space>
),
},
{
title: '端类型',
dataIndex: 'actorTypes',
width: 220,
render: (types: ActorType[]) => renderActorTags(types),
},
{
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v) => v || '—',
},
{
title: '身份摘要',
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities
.map((i) => i.refLabel || i.name || i.phone)
.filter(Boolean)
.join(' · ')}
/>
),
},
{
title: '最近登录',
dataIndex: 'latestLoginAt',
width: 160,
render: fmtTime,
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
),
},
];
const identityColumns: ColumnsType<Identity> = [
{
title: '端类型',
dataIndex: 'actorType',
width: 120,
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
},
{
title: '账号',
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.name || '—'}
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
/>
),
},
{
title: '归属',
dataIndex: 'refLabel',
width: 160,
ellipsis: true,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
},
{
title: 'wxOpenId',
dataIndex: 'wxOpenId',
width: 160,
ellipsis: true,
},
{
title: '手机验证',
width: 90,
render: (_, r) =>
r.actorType === 'USER' ? (
r.phoneVerified ? <Tag color="blue"></Tag> : <Tag></Tag>
) : (
'—'
),
},
{
title: '最近登录',
dataIndex: 'lastLoginAt',
width: 160,
render: fmtTime,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) => String(v),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
unionId C HQ unionId
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setPage(1);
setFilters({
actorType: values.actorType ?? '',
phone: values.phone?.trim() ?? '',
unionId: values.unionId?.trim() ?? '',
openId: values.openId?.trim() ?? '',
});
}}
>
<Form.Item name="actorType" label="端类型">
<Select
allowClear
placeholder="全部"
style={{ width: 140 }}
options={[
{ value: 'USER', label: 'C 端用户' },
{ value: 'STORE', label: '门店账号' },
{ value: 'PARTNER', label: '合伙人账号' },
{ value: 'HQ', label: 'HQ 账号' },
]}
/>
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="unionId" label="unionId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="openId" label="openId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
}}
>
</Button>
<Button onClick={() => void reload()}></Button>
</Space>
</Form.Item>
</Form>
<Table<GroupRow>
rowKey="groupKey"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="微信绑定详情"
width={960}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
<Descriptions.Item label="一人多角色">
{detail.multiRole ? <Tag color="red"></Tag> : <Tag></Tag>}
</Descriptions.Item>
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
</Descriptions>
<Table<Identity>
rowKey={(r) => `${r.actorType}-${r.actorId}`}
size="small"
columns={identityColumns}
dataSource={detail.identities}
pagination={false}
/>
</>
) : null}
</Drawer>
</div>
);
}