web端增加微信绑定管理
This commit is contained in:
@@ -29,6 +29,7 @@ import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
import StoreLogsPage from './pages/StoreLogsPage';
|
||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -48,6 +49,7 @@ export default function App() {
|
||||
>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
|
||||
@@ -23,6 +23,7 @@ const { Header, Sider, Content } = Layout;
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||
{
|
||||
key: 'products-group',
|
||||
icon: <ShoppingOutlined />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user