@@ -16,37 +16,37 @@ body,
|
||||
}
|
||||
|
||||
/*
|
||||
* 后台内容行:不换行 + 超出省略
|
||||
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
||||
* 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
|
||||
* 描述列表仍省略,避免详情抽屉被长字段撑破
|
||||
*/
|
||||
.admin-layout .ant-table-cell,
|
||||
.ant-drawer .ant-table-cell,
|
||||
.ant-modal .ant-table-cell,
|
||||
.ant-modal .ant-table-cell {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-layout .ant-descriptions-item-content,
|
||||
.ant-drawer .ant-descriptions-item-content,
|
||||
.ant-modal .ant-descriptions-item-content {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.admin-layout .ant-descriptions-item-content,
|
||||
.ant-drawer .ant-descriptions-item-content,
|
||||
.ant-modal .ant-descriptions-item-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 表头同样不换行 */
|
||||
/* 表头不换行 */
|
||||
.admin-layout .ant-table-thead > tr > th,
|
||||
.ant-drawer .ant-table-thead > tr > th,
|
||||
.ant-modal .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* 操作列、含交互控件的不截断 */
|
||||
/* 操作列保持可见 */
|
||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||
@@ -71,10 +71,9 @@ body,
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 表格单元格双行信息合并为单行省略 */
|
||||
/* 表格单元格主/副信息同一行 */
|
||||
.admin-cell-line {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.admin-cell-line-secondary {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
type AdminCellLineProps = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||
/** 表格单元格:主/副信息同一行完整展示 */
|
||||
export function AdminCellLine({
|
||||
primary,
|
||||
secondary,
|
||||
@@ -14,14 +12,9 @@ export function AdminCellLine({
|
||||
}: AdminCellLineProps) {
|
||||
const main = primary?.trim() || '—';
|
||||
const sub = secondary?.trim();
|
||||
const full = sub ? `${main}${separator}${sub}` : main;
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
className="admin-cell-line"
|
||||
ellipsis={{ tooltip: full }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<span className="admin-cell-line">
|
||||
{main}
|
||||
{sub ? (
|
||||
<span className="admin-cell-line-secondary">
|
||||
@@ -29,6 +22,6 @@ export function AdminCellLine({
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
items: ListColumnSettingItem[];
|
||||
saving?: boolean;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
onSave: (items: ListColumnSettingItem[]) => void;
|
||||
};
|
||||
|
||||
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
|
||||
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(items);
|
||||
}, [open, items]);
|
||||
|
||||
function move(from: number, to: number) {
|
||||
if (to < 0 || to >= draft.length) return;
|
||||
const next = [...draft];
|
||||
const [row] = next.splice(from, 1);
|
||||
next.splice(to, 0, row);
|
||||
setDraft(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="列设置"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button onClick={onReset}>重置默认</Button>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
勾选显示列,拖拽或上下箭头调整顺序。序号与操作列固定,不在此列表中。
|
||||
</Typography.Paragraph>
|
||||
<div>
|
||||
{draft.map((item, index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
draggable
|
||||
onDragStart={() => setDragKey(item.key)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (!dragKey || dragKey === item.key) return;
|
||||
const from = draft.findIndex((r) => r.key === dragKey);
|
||||
if (from >= 0) move(from, index);
|
||||
setDragKey(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'move',
|
||||
}}
|
||||
>
|
||||
<HolderOutlined style={{ color: '#999' }} />
|
||||
<Checkbox
|
||||
checked={item.visible}
|
||||
onChange={(e) => {
|
||||
const next = [...draft];
|
||||
next[index] = { ...item, visible: e.target.checked };
|
||||
setDraft(next);
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Checkbox>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
|
||||
上移
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={index === draft.length - 1}
|
||||
onClick={() => move(index, index + 1)}
|
||||
>
|
||||
下移
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,459 +1,458 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,92 +1,91 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
AccountBookOutlined,
|
||||
ProjectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
@@ -156,6 +157,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
if (key === 'tickets-group') {
|
||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||
}
|
||||
if (key === 'stores-group') {
|
||||
return hasAnyStoreMenuPermission(permissionKeys);
|
||||
}
|
||||
if (key === 'dev-plan-group') {
|
||||
return permissionKeys.includes('dev_plan');
|
||||
}
|
||||
@@ -175,11 +179,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-package-audits': 'stores',
|
||||
'/store-ratings': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
'/store-accounts': 'stores',
|
||||
'/store-media': 'stores',
|
||||
'/store-package-audits': 'store_audits',
|
||||
'/store-ratings': 'store_ratings',
|
||||
'/store-categories': 'store_categories',
|
||||
'/store-accounts': 'store_accounts',
|
||||
'/store-media': 'store_media',
|
||||
'partners-group': 'partners',
|
||||
'/cities': 'partners',
|
||||
'/city-partners': 'partners',
|
||||
@@ -413,7 +417,9 @@ export default function AdminLayout() {
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<Outlet />
|
||||
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
|
||||
<Outlet />
|
||||
</ListColumnPrefsProvider>
|
||||
</Content>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
type Ctx = {
|
||||
prefs: HqListColumnPrefsMap;
|
||||
ready: boolean;
|
||||
setPrefs: (next: HqListColumnPrefsMap) => void;
|
||||
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const ListColumnPrefsContext = createContext<Ctx>({
|
||||
prefs: {},
|
||||
ready: false,
|
||||
setPrefs: () => {},
|
||||
save: async () => {},
|
||||
});
|
||||
|
||||
export function ListColumnPrefsProvider({
|
||||
initial,
|
||||
children,
|
||||
}: {
|
||||
initial?: HqListColumnPrefsMap;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
|
||||
const [ready] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) setPrefs(initial);
|
||||
}, [initial]);
|
||||
|
||||
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
|
||||
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
|
||||
`/admin/me/list-columns/${listKey}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }),
|
||||
},
|
||||
);
|
||||
setPrefs(res.listColumnPrefs ?? {});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
|
||||
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useListColumnPrefs() {
|
||||
return useContext(ListColumnPrefsContext);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
export const CLIENT_APP = 'HQ_WEB';
|
||||
|
||||
@@ -8,6 +10,9 @@ export type HqProfile = {
|
||||
adminRole: string;
|
||||
status: string;
|
||||
permissionKeys?: string[];
|
||||
cityIds?: string[];
|
||||
cityScoped?: boolean;
|
||||
listColumnPrefs?: HqListColumnPrefsMap;
|
||||
};
|
||||
|
||||
export function getToken() {
|
||||
@@ -87,6 +92,9 @@ export type DashboardStats = {
|
||||
openTickets?: number;
|
||||
pendingStoreWithdrawals?: number;
|
||||
overdueStoreWithdrawals?: number;
|
||||
pendingStoreOnboard?: number;
|
||||
pendingStorePackageAudits?: number;
|
||||
pendingStoreInfoChanges?: number;
|
||||
ordersByStatus: Array<{ status: string; count: number }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ColumnType } from 'antd/es/table';
|
||||
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
|
||||
|
||||
export const SERIAL_COLUMN_KEY = '__serial';
|
||||
export const ACTIONS_COLUMN_KEY = '__actions';
|
||||
|
||||
export function columnKey<T>(col: ColumnType<T>, index: number): string {
|
||||
if (col.key != null && String(col.key).length) return String(col.key);
|
||||
const dataIndex = col.dataIndex;
|
||||
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
|
||||
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
|
||||
if (typeof col.title === 'string' && col.title) return col.title;
|
||||
return `col-${index}`;
|
||||
}
|
||||
|
||||
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
|
||||
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
|
||||
return col.title === '操作';
|
||||
}
|
||||
|
||||
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||
const known = new Set(defaultKeys);
|
||||
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
|
||||
for (const key of defaultKeys) {
|
||||
if (ordered.includes(key)) continue;
|
||||
const defaultIdx = defaultKeys.indexOf(key);
|
||||
let insertAt = ordered.length;
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
|
||||
insertAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ordered.splice(insertAt, 0, key);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||
const hidden = new Set(pref?.hidden ?? []);
|
||||
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
|
||||
}
|
||||
|
||||
export function applyColumnPrefs<T>(
|
||||
columns: ColumnType<T>[],
|
||||
pref: HqListColumnPref | undefined,
|
||||
): ColumnType<T>[] {
|
||||
const keyed = columns.map((col, i) => {
|
||||
const key = columnKey(col, i);
|
||||
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
|
||||
});
|
||||
const configurable = keyed.filter((c) => !c.actions);
|
||||
const actions = keyed.filter((c) => c.actions);
|
||||
const defaultKeys = configurable.map((c) => c.key);
|
||||
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||
return [...orderedKeys.map((k) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => c.col)];
|
||||
}
|
||||
|
||||
export type ListColumnSettingItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function settingItems<T>(
|
||||
columns: ColumnType<T>[],
|
||||
pref?: HqListColumnPref,
|
||||
): ListColumnSettingItem[] {
|
||||
const keyed = columns
|
||||
.map((col, i) => ({ col, key: columnKey(col, i) }))
|
||||
.filter(({ col, key }) => !isActionsColumn(col, key));
|
||||
const hidden = new Set(pref?.hidden ?? []);
|
||||
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
|
||||
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
|
||||
return order.map((key) => {
|
||||
const col = byKey.get(key);
|
||||
const title = typeof col?.title === 'string' ? col.title : key;
|
||||
return { key, title, visible: !hidden.has(key) };
|
||||
});
|
||||
}
|
||||
|
||||
export type { HqListColumnKey };
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, message } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import type { HqListColumnKey } from '@dukang/shared-types';
|
||||
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
|
||||
import { useListColumnPrefs } from './ListColumnPrefsContext';
|
||||
import {
|
||||
ACTIONS_COLUMN_KEY,
|
||||
SERIAL_COLUMN_KEY,
|
||||
applyColumnPrefs,
|
||||
settingItems,
|
||||
type ListColumnSettingItem,
|
||||
} from './list-column-prefs';
|
||||
|
||||
type Options = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export function useAdminListColumns<T>(
|
||||
listKey: HqListColumnKey,
|
||||
allColumns: ColumnsType<T>,
|
||||
options: Options = {},
|
||||
) {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
const { prefs, save } = useListColumnPrefs();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pref = prefs[listKey];
|
||||
|
||||
const configured = useMemo(
|
||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
|
||||
const serialCol: ColumnType<T> = useMemo(
|
||||
() => ({
|
||||
key: SERIAL_COLUMN_KEY,
|
||||
title: '序号',
|
||||
width: 64,
|
||||
fixed: 'left' as const,
|
||||
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
|
||||
}),
|
||||
[page, pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<T>>(
|
||||
() => [
|
||||
serialCol,
|
||||
...configured.map((col) =>
|
||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||
: col,
|
||||
),
|
||||
],
|
||||
[configured, serialCol],
|
||||
);
|
||||
|
||||
async function persist(next: ListColumnSettingItem[] | null) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!next) {
|
||||
await save(listKey, null);
|
||||
} else {
|
||||
await save(listKey, {
|
||||
order: next.map((i) => i.key),
|
||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const settingsButton = (
|
||||
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
|
||||
列设置
|
||||
</Button>
|
||||
);
|
||||
|
||||
const settingsModal = (
|
||||
<AdminListColumnsModal
|
||||
open={open}
|
||||
items={items}
|
||||
saving={saving}
|
||||
onCancel={() => setOpen(false)}
|
||||
onReset={() => void persist(null)}
|
||||
onSave={(draft) => void persist(draft)}
|
||||
/>
|
||||
);
|
||||
|
||||
return { columns, settingsButton, settingsModal };
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -99,7 +101,7 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
@@ -113,7 +115,7 @@ export default function BenefitCouponsPage() {
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
||||
{ title: '来源', dataIndex: 'sourceProduct' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -155,15 +157,21 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
好客权益券
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||
手动发放
|
||||
</Button>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||
手动发放
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
@@ -190,7 +198,7 @@ export default function BenefitCouponsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -316,7 +324,7 @@ export default function BenefitCouponsPage() {
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
||||
{ title: '核销号', dataIndex: 'redeemNo' },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -327,7 +335,7 @@ export default function BenefitCouponsPage() {
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
|
||||
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||
@@ -24,19 +26,23 @@ export default function BenefitLedgersPage() {
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
||||
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
||||
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'remark' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,8 @@ import {
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -70,6 +72,7 @@ function FulfillmentFields({
|
||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||
}) {
|
||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -219,7 +222,7 @@ export default function CityWarehousesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
@@ -229,8 +232,8 @@ export default function CityWarehousesPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||
{ title: '仓库', dataIndex: 'name', width: 140 },
|
||||
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
@@ -242,7 +245,6 @@ export default function CityWarehousesPage() {
|
||||
title: '合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) =>
|
||||
v && row.partnerAccountId ? (
|
||||
<Link to="/city-partners">{v}</Link>
|
||||
@@ -298,10 +300,17 @@ export default function CityWarehousesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -367,7 +376,7 @@ export default function CityWarehousesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1400 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -64,6 +64,20 @@ export default function DashboardPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
const keys = profile?.permissionKeys ?? [];
|
||||
const has = (k: string) => keys.includes(k);
|
||||
const cityScoped = !!profile?.cityScoped;
|
||||
const canUsers = has('users');
|
||||
const canOrders = has('orders');
|
||||
const canStores = has('stores');
|
||||
const canStoreAudits = has('store_audits');
|
||||
const canPartners = has('partners');
|
||||
const canPromo = has('promo_codes');
|
||||
const canFinance = has('finance');
|
||||
const canBenefit = has('benefit');
|
||||
const canDeliveries = has('deliveries');
|
||||
const canTickets = has('tickets');
|
||||
const permsReady = profile !== null;
|
||||
|
||||
const [filterForm] = Form.useForm<{
|
||||
range: [Dayjs, Dayjs];
|
||||
@@ -115,18 +129,26 @@ export default function DashboardPage() {
|
||||
.catch(() => {
|
||||
setVersionLoading(false);
|
||||
});
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setCities(res.items ?? []))
|
||||
.catch(() => setCities([]));
|
||||
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setPromos(res.items ?? []))
|
||||
.catch(() => setPromos([]));
|
||||
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => setPartners(res.items ?? []))
|
||||
.catch(() => setPartners([]));
|
||||
}, [loadVersion]);
|
||||
if ((profile.permissionKeys ?? []).includes('promo_codes')) {
|
||||
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setPromos(res.items ?? []))
|
||||
.catch(() => setPromos([]));
|
||||
}
|
||||
if ((profile.permissionKeys ?? []).includes('partners')) {
|
||||
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => setPartners(res.items ?? []))
|
||||
.catch(() => setPartners([]));
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics(filters);
|
||||
@@ -206,9 +228,19 @@ export default function DashboardPage() {
|
||||
|
||||
const byDateOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byDate ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: EChartsOption['series'] = [];
|
||||
if (canUsers) {
|
||||
legend.push('新增用户');
|
||||
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
|
||||
}
|
||||
if (canOrders) {
|
||||
legend.push('订单数');
|
||||
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增用户', '订单数'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -216,18 +248,25 @@ export default function DashboardPage() {
|
||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
|
||||
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
|
||||
const byCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: EChartsOption['series'] = [];
|
||||
if (canUsers) {
|
||||
legend.push('用户');
|
||||
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
||||
}
|
||||
if (canOrders) {
|
||||
legend.push('订单');
|
||||
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -235,18 +274,25 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
|
||||
const byPromoOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byPromo ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: EChartsOption['series'] = [];
|
||||
if (canUsers) {
|
||||
legend.push('用户');
|
||||
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
||||
}
|
||||
if (canOrders) {
|
||||
legend.push('订单');
|
||||
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -254,18 +300,36 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
|
||||
const opsByDateOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byDate ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: NonNullable<EChartsOption['series']> = [];
|
||||
if (canPartners) {
|
||||
legend.push('新增合伙人');
|
||||
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
|
||||
}
|
||||
if (canStores) {
|
||||
legend.push('新签门店');
|
||||
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
|
||||
}
|
||||
if (canBenefit) {
|
||||
legend.push('核销笔数', '核销金额');
|
||||
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
|
||||
series.push({
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
});
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -276,26 +340,29 @@ export default function DashboardPage() {
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额', minInterval: 1 },
|
||||
],
|
||||
series: [
|
||||
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
|
||||
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
|
||||
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canPartners, canStores, canBenefit]);
|
||||
|
||||
const opsByCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: EChartsOption['series'] = [];
|
||||
if (canPartners) {
|
||||
legend.push('合伙人');
|
||||
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
|
||||
}
|
||||
if (canStores) {
|
||||
legend.push('门店');
|
||||
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
||||
}
|
||||
if (canBenefit) {
|
||||
legend.push('核销笔数');
|
||||
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['合伙人', '门店', '核销笔数'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -303,19 +370,31 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canPartners, canStores, canBenefit]);
|
||||
|
||||
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byPartner ?? [];
|
||||
const legend: string[] = [];
|
||||
const series: NonNullable<EChartsOption['series']> = [];
|
||||
if (canStores) {
|
||||
legend.push('门店');
|
||||
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
||||
}
|
||||
if (canBenefit) {
|
||||
legend.push('核销笔数', '核销金额');
|
||||
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
||||
series.push({
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
});
|
||||
}
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['门店', '核销笔数', '核销金额'] },
|
||||
legend: { data: legend },
|
||||
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -326,18 +405,9 @@ export default function DashboardPage() {
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额' },
|
||||
],
|
||||
series: [
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
series,
|
||||
};
|
||||
}, [analytics]);
|
||||
}, [analytics, canStores, canBenefit]);
|
||||
|
||||
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
@@ -368,7 +438,9 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
<Typography.Title level={4}>
|
||||
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
|
||||
</Typography.Title>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<Card
|
||||
@@ -411,46 +483,94 @@ export default function DashboardPage() {
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
{!permsReady ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading />
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canUsers ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canUsers ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canUsers ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canOrders ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canStores ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canStores ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING">去处理</Link>}>
|
||||
<Statistic
|
||||
title="待入驻审核"
|
||||
value={stats?.pendingStoreOnboard ?? 0}
|
||||
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canStoreAudits ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading} extra={<Link to="/store-package-audits">去处理</Link>}>
|
||||
<Statistic
|
||||
title="待套餐/信息审核"
|
||||
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
|
||||
valueStyle={{
|
||||
color:
|
||||
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
|
||||
? '#fa8c16'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canPartners ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canBenefit ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{permsReady && canDeliveries ? (
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
@@ -478,62 +598,73 @@ export default function DashboardPage() {
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部开城"
|
||||
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ value: 'none', label: '未选城' },
|
||||
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
|
||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="promoCodeId" label="推广码">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部来源"
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ value: 'none', label: '自然量 / 无推广码' },
|
||||
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerAccountId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部合伙人"
|
||||
style={{ width: 200 }}
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{canPromo ? (
|
||||
<Form.Item name="promoCodeId" label="推广码">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部来源"
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ value: 'none', label: '自然量 / 无推广码' },
|
||||
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{canPartners ? (
|
||||
<Form.Item name="partnerAccountId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部合伙人"
|
||||
style={{ width: 200 }}
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{canUsers || canOrders ? (
|
||||
<Card
|
||||
title="用户 / 订单统计"
|
||||
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Link to={`/orders?${ordersDrillQs}`}>查看订单</Link>
|
||||
<Link to="/users">查看用户</Link>
|
||||
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}>查看订单</Link> : null}
|
||||
{canUsers ? <Link to="/users">查看用户</Link> : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
||||
</Col>
|
||||
{canUsers ? (
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
{canOrders ? (
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
{canOrders ? (
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -542,7 +673,7 @@ export default function DashboardPage() {
|
||||
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Col xs={24} lg={canPromo ? 12 : 24}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
@@ -562,6 +693,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{canPromo ? (
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -584,42 +716,53 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canPartners || canStores || canBenefit ? (
|
||||
<Card
|
||||
title="合伙人 / 门店 / 核销统计"
|
||||
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Link to="/city-partners">查看合伙人</Link>
|
||||
<Link to="/stores">查看门店</Link>
|
||||
<Link to="/redeem-records">查看核销</Link>
|
||||
{canPartners ? <Link to="/city-partners">查看合伙人</Link> : null}
|
||||
{canStores ? <Link to="/stores">查看门店</Link> : null}
|
||||
{canBenefit ? <Link to="/redeem-records">查看核销</Link> : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
||||
</Col>
|
||||
{canPartners ? (
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
{canStores ? (
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
{canBenefit ? (
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
||||
</Col>
|
||||
) : null}
|
||||
{canBenefit ? (
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24}>
|
||||
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
|
||||
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
||||
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Col xs={24} lg={canBenefit ? 12 : 24}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
@@ -639,6 +782,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{canBenefit ? (
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -659,6 +803,8 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canPartners && (canStores || canBenefit) ? (
|
||||
<Col xs={24}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -681,10 +827,14 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canFinance || canTickets ? (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{canFinance ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
@@ -704,6 +854,8 @@ export default function DashboardPage() {
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canFinance ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
@@ -717,6 +869,8 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canFinance ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
@@ -737,20 +891,28 @@ export default function DashboardPage() {
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canTickets ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={loading} title="待处理工单">
|
||||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{canUsers || canOrders ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
{canUsers ? (
|
||||
<Col xs={24} lg={canOrders ? 12 : 24}>
|
||||
<Card title="已合并访客账号" loading={loading}>
|
||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
) : null}
|
||||
{canOrders ? (
|
||||
<Col xs={24} lg={canUsers ? 12 : 24}>
|
||||
<Card title="订单状态分布" loading={loading}>
|
||||
<Table
|
||||
size="small"
|
||||
@@ -768,7 +930,9 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -59,7 +61,7 @@ export default function DeliveriesPage() {
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
@@ -83,16 +85,20 @@ export default function DeliveriesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
|
||||
@@ -30,6 +30,8 @@ import { fmtTime } from '../lib/constants';
|
||||
import { downloadBase64File } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||
value: v,
|
||||
@@ -240,7 +242,7 @@ export default function DevPlanTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanTaskDto> = [
|
||||
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -256,7 +258,7 @@ export default function DevPlanTasksPage() {
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '内容', dataIndex: 'content' },
|
||||
{
|
||||
title: '附件',
|
||||
width: 100,
|
||||
@@ -294,12 +296,16 @@ export default function DevPlanTasksPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 任务列表
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={exportFormat}
|
||||
@@ -361,7 +367,7 @@ export default function DevPlanTasksPage() {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||
@@ -119,7 +121,7 @@ export default function DevPlanVersionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanVersionDto> = [
|
||||
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -129,7 +131,7 @@ export default function DevPlanVersionsPage() {
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '内容', dataIndex: 'content', render: (v) => v || '—' },
|
||||
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
@@ -158,12 +160,16 @@ export default function DevPlanVersionsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 版本列表
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建版本
|
||||
</Button>
|
||||
@@ -190,7 +196,7 @@ export default function DevPlanVersionsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -55,7 +57,7 @@ export default function DomainEventsPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
@@ -76,8 +78,13 @@ export default function DomainEventsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const page = Number(params.get('page') ?? 1);
|
||||
const pageSize = data?.pageSize ?? 20;
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-domain-events', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<Card title="领域事件">
|
||||
<Card title="领域事件" extra={settingsButton}>
|
||||
{settingsModal}
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
@@ -173,7 +175,7 @@ export default function FulfillmentProvidersPage() {
|
||||
void load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
@@ -230,13 +232,20 @@ export default function FulfillmentProvidersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
|
||||
page: 1,
|
||||
pageSize: rows.length || 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
@@ -253,7 +262,7 @@ export default function FulfillmentProvidersPage() {
|
||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||
/>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||
|
||||
@@ -3,9 +3,12 @@ import {
|
||||
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
|
||||
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -17,14 +20,13 @@ type Row = {
|
||||
status: string;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
cityIds?: string[];
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
OPS: '运营',
|
||||
FINANCE: '财务',
|
||||
CUSTOMER_SERVICE: '客服',
|
||||
};
|
||||
type CityOption = { id: string; name: string };
|
||||
|
||||
const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
|
||||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||
|
||||
export default function HqAccountsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
@@ -46,14 +48,27 @@ export default function HqAccountsPage() {
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
||||
const editRole = Form.useWatch('adminRole', editForm);
|
||||
const createRole = Form.useWatch('adminRole', createForm);
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return;
|
||||
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setCities(res.items))
|
||||
.catch(() => {});
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||
@@ -67,7 +82,18 @@ export default function HqAccountsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
||||
{ title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r },
|
||||
{
|
||||
title: '城市',
|
||||
width: 160,
|
||||
render: (_, r) => {
|
||||
if (!r.cityIds?.length) return <Typography.Text type="secondary">全国</Typography.Text>;
|
||||
const names = r.cityIds
|
||||
.map((id) => cities.find((c) => c.id === id)?.name || id)
|
||||
.join('、');
|
||||
return names;
|
||||
},
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -81,6 +107,7 @@ export default function HqAccountsPage() {
|
||||
loginName: row.loginName,
|
||||
adminRole: row.adminRole,
|
||||
status: row.status,
|
||||
cityIds: row.cityIds ?? [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
@@ -88,16 +115,20 @@ export default function HqAccountsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||
{settingsButton}
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -108,23 +139,31 @@ export default function HqAccountsPage() {
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select allowClear style={{ width: 160 }} options={ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
<Table scroll={{ x: 'max-content' }} rowKey="id" 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="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={async () => {
|
||||
<Button type="primary" loading={saving} onClick={async () => {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const payload = { ...v };
|
||||
if (!payload.password) delete payload.password;
|
||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const payload: Record<string, unknown> = { ...v };
|
||||
if (!payload.password) delete payload.password;
|
||||
if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
|
||||
setSaving(true);
|
||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}>保存</Button>
|
||||
}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
@@ -144,7 +183,15 @@ export default function HqAccountsPage() {
|
||||
<Input.Password placeholder="至少 6 位" />
|
||||
</Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select options={ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cityIds"
|
||||
label="负责城市"
|
||||
extra={editRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||
rules={editRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||
>
|
||||
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
@@ -156,15 +203,19 @@ export default function HqAccountsPage() {
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }}>
|
||||
<Form.Item name="credentialType" label="创建方式">
|
||||
<Radio.Group>
|
||||
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||
@@ -173,7 +224,15 @@ export default function HqAccountsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select options={ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cityIds"
|
||||
label="负责城市"
|
||||
extra={createRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||
rules={createRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||
>
|
||||
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||
</Form.Item>
|
||||
{credentialType === 'phone' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
|
||||
@@ -1,199 +1,204 @@
|
||||
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 { useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||
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 { useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
hqAccountId: string | null;
|
||||
hqName: string | null;
|
||||
hqPhone: string | null;
|
||||
hqRole: string | null;
|
||||
action: string | null;
|
||||
actionLabel: string;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
status: string | null;
|
||||
remark: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function HqLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
hqAccountId: searchParams.get('hqAccountId') ?? '',
|
||||
action: searchParams.get('action') ?? '',
|
||||
refType: searchParams.get('refType') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/logs/hq',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
|
||||
if (filters.action) qs.set('action', filters.action);
|
||||
if (filters.refType) qs.set('refType', filters.refType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作人',
|
||||
width: 200,
|
||||
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.hqName}
|
||||
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '行为',
|
||||
dataIndex: 'actionLabel',
|
||||
width: 160,
|
||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
||||
},
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
|
||||
type Row = {
|
||||
id: string;
|
||||
hqAccountId: string | null;
|
||||
hqName: string | null;
|
||||
hqPhone: string | null;
|
||||
hqRole: string | null;
|
||||
action: string | null;
|
||||
actionLabel: string;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
status: string | null;
|
||||
remark: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function HqLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
hqAccountId: searchParams.get('hqAccountId') ?? '',
|
||||
action: searchParams.get('action') ?? '',
|
||||
refType: searchParams.get('refType') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/logs/hq',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
|
||||
if (filters.action) qs.set('action', filters.action);
|
||||
if (filters.refType) qs.set('refType', filters.refType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作人',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.hqName}
|
||||
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '行为',
|
||||
dataIndex: 'actionLabel',
|
||||
width: 160,
|
||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
||||
},
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
const qs = new URLSearchParams();
|
||||
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
|
||||
if (values.action) qs.set('action', values.action);
|
||||
if (values.refType) qs.set('refType', values.refType);
|
||||
setSearchParams(qs);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="hqAccountId" label="HQ 账户 ID">
|
||||
<Input allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="行为">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="refType" label="对象类型">
|
||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({ hqAccountId: '', action: '', refType: '' });
|
||||
setSearchParams({});
|
||||
setPage(1);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">
|
||||
<AdminCellLine
|
||||
primary={detail.hqName}
|
||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="行为">
|
||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||
<pre style={{
|
||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||
maxHeight: 400, overflow: 'auto', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(detail.detail, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
const qs = new URLSearchParams();
|
||||
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
|
||||
if (values.action) qs.set('action', values.action);
|
||||
if (values.refType) qs.set('refType', values.refType);
|
||||
setSearchParams(qs);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="hqAccountId" label="HQ 账户 ID">
|
||||
<Input allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="行为">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="refType" label="对象类型">
|
||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({ hqAccountId: '', action: '', refType: '' });
|
||||
setSearchParams({});
|
||||
setPage(1);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">
|
||||
<AdminCellLine
|
||||
primary={detail.hqName}
|
||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="行为">
|
||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||
<pre style={{
|
||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||
maxHeight: 400, overflow: 'auto', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(detail.detail, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
HQ_ADMIN_ROLES,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
computeHqEffectivePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
@@ -27,6 +28,8 @@ type AccountPermRes = {
|
||||
account: AccountOption;
|
||||
permissionKeys: string[];
|
||||
rolePermissionKeys: string[];
|
||||
grantKeys?: string[];
|
||||
denyKeys?: string[];
|
||||
userPermissionKeys: string[];
|
||||
effectivePermissionKeys: string[];
|
||||
};
|
||||
@@ -46,11 +49,17 @@ function PermissionChecklist({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
roleKeys,
|
||||
grantKeys,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (keys: string[]) => void;
|
||||
disabled?: boolean;
|
||||
roleKeys?: string[];
|
||||
grantKeys?: string[];
|
||||
}) {
|
||||
const roleSet = new Set(roleKeys ?? []);
|
||||
const grantSet = new Set(grantKeys ?? []);
|
||||
return (
|
||||
<Checkbox.Group
|
||||
style={{ width: '100%' }}
|
||||
@@ -81,6 +90,13 @@ function PermissionChecklist({
|
||||
{items.map((item) => (
|
||||
<Checkbox key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||
) : grantSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||
) : roleSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||
) : null}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
@@ -88,7 +104,16 @@ function PermissionChecklist({
|
||||
<Row gutter={[12, 10]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
<Checkbox value={item.key}>
|
||||
{item.label}
|
||||
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||
) : grantSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||
) : roleSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||
) : null}
|
||||
</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
@@ -100,6 +125,14 @@ function PermissionChecklist({
|
||||
);
|
||||
}
|
||||
|
||||
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
|
||||
const roleSet = new Set(roleKeys);
|
||||
const effectiveSet = new Set(effectiveKeys);
|
||||
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
|
||||
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
|
||||
return { grantKeys, denyKeys };
|
||||
}
|
||||
|
||||
export default function HqPermissionsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [role, setRole] = useState<string>('OPS');
|
||||
@@ -111,6 +144,8 @@ export default function HqPermissionsPage() {
|
||||
const [accountId, setAccountId] = useState<string>();
|
||||
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||||
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||||
const [grantKeys, setGrantKeys] = useState<string[]>([]);
|
||||
const [denyKeys, setDenyKeys] = useState<string[]>([]);
|
||||
const [accountLoading, setAccountLoading] = useState(false);
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
@@ -121,8 +156,8 @@ export default function HqPermissionsPage() {
|
||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const previewEffectiveKeys = useMemo(
|
||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||
[roleInheritedKeys, accountKeys],
|
||||
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
|
||||
[roleInheritedKeys, grantKeys, denyKeys],
|
||||
);
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
@@ -151,8 +186,10 @@ export default function HqPermissionsPage() {
|
||||
setAccountLoading(true);
|
||||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||
.then((res) => {
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||
setDenyKeys(res.denyKeys ?? []);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
setAccountKeys(res.effectivePermissionKeys);
|
||||
})
|
||||
.finally(() => setAccountLoading(false));
|
||||
}, [isSuperAdmin, accountId]);
|
||||
@@ -179,10 +216,12 @@ export default function HqPermissionsPage() {
|
||||
try {
|
||||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ permissionKeys: accountKeys }),
|
||||
body: JSON.stringify({ grantKeys, denyKeys }),
|
||||
});
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||
setDenyKeys(res.denyKeys ?? []);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
setAccountKeys(res.effectivePermissionKeys);
|
||||
message.success('用户权限已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
@@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
|
||||
<div>
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||
</Typography.Paragraph>
|
||||
|
||||
@@ -275,59 +315,51 @@ export default function HqPermissionsPage() {
|
||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{!roleInheritedKeys.length ? (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
||||
勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。
|
||||
切换角色会清空账号级追加/撤销。
|
||||
</Typography.Paragraph>
|
||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||
<PermissionChecklist
|
||||
value={accountKeys}
|
||||
roleKeys={roleInheritedKeys}
|
||||
grantKeys={grantKeys}
|
||||
disabled={selectedIsSuperAdmin}
|
||||
onChange={(nextEffective) => {
|
||||
if (selectedIsSuperAdmin) return;
|
||||
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
|
||||
setGrantKeys(next.grantKeys);
|
||||
setDenyKeys(next.denyKeys);
|
||||
setAccountKeys(nextEffective);
|
||||
}}
|
||||
/>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>基础权限(全部)</Tag>
|
||||
{accountKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key} color="orange">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
<Space wrap size={[4, 4]}>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={accountSaving}
|
||||
disabled={selectedIsSuperAdmin}
|
||||
onClick={() => void saveAccountPermissions()}
|
||||
>
|
||||
保存用户权限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -181,7 +183,7 @@ export default function InvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{
|
||||
@@ -202,7 +204,7 @@ export default function InvoicesPage() {
|
||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||
: '—',
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'titleName' },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
@@ -227,8 +229,11 @@ export default function InvoicesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -240,6 +245,7 @@ export default function InvoicesPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -286,7 +292,7 @@ export default function InvoicesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1000 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
|
||||
@@ -25,6 +25,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -124,9 +126,9 @@ export default function KnowledgeBasesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
@@ -184,7 +186,7 @@ export default function KnowledgeBasesPage() {
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{ title: '文件', dataIndex: 'fileName' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -250,12 +252,16 @@ export default function KnowledgeBasesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -153,7 +155,7 @@ export default function LlmConfigsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
@@ -161,7 +163,7 @@ export default function LlmConfigsPage() {
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{ title: '模型', dataIndex: 'modelName' },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
@@ -257,12 +259,16 @@ export default function LlmConfigsPage() {
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
@@ -245,7 +246,7 @@ export default function LogisticsBillsPage() {
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||
|
||||
const billColumns: ColumnsType<BillRow> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170 },
|
||||
{
|
||||
title: '承运商',
|
||||
width: 140,
|
||||
@@ -275,13 +276,11 @@ export default function LogisticsBillsPage() {
|
||||
{
|
||||
title: '收款户名',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (_, r) => providerBank(r)?.bankAccountName || '—',
|
||||
},
|
||||
{
|
||||
title: '收款账号',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
|
||||
},
|
||||
{
|
||||
@@ -339,7 +338,6 @@ export default function LogisticsBillsPage() {
|
||||
{
|
||||
title: '银行账户',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) =>
|
||||
r.bankAccountName
|
||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||
@@ -391,12 +389,19 @@ export default function LogisticsBillsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
物流对账
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||
</Typography.Text>
|
||||
@@ -537,7 +542,7 @@ export default function LogisticsBillsPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={billColumns}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { downloadBase64File } from '../lib/exportExcel';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
@@ -590,7 +591,7 @@ export default function OrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
const baseColumns: ColumnsType<AdminOrderRow> = [
|
||||
{
|
||||
title: '商品',
|
||||
width: 240,
|
||||
@@ -686,11 +687,18 @@ export default function OrdersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space size={12}>
|
||||
{settingsButton}
|
||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
@@ -830,7 +838,7 @@ export default function OrdersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
@@ -1014,7 +1022,7 @@ export default function OrdersPage() {
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -1025,7 +1033,6 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, row) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
@@ -1141,7 +1148,6 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -1436,7 +1442,6 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '商品',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.productName
|
||||
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type ParentAccount = { id: string; name: string; phone: string };
|
||||
@@ -169,11 +171,10 @@ export default function PartnerAccountsPage() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AccountTreeRow> = [
|
||||
const baseColumns: ColumnsType<AccountTreeRow> = [
|
||||
{
|
||||
title: '姓名 / 类型',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, row) => (
|
||||
<AdminCellLine
|
||||
primary={row.name}
|
||||
@@ -189,7 +190,6 @@ export default function PartnerAccountsPage() {
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
@@ -254,11 +254,18 @@ export default function PartnerAccountsPage() {
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('partner-accounts', baseColumns, {
|
||||
page: 1,
|
||||
pageSize: treeData.length || 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
@@ -305,7 +312,7 @@ export default function PartnerAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
@@ -399,7 +406,7 @@ export default function PartnerAccountsPage() {
|
||||
label: `账单 (${detail.bills?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -407,7 +414,7 @@ export default function PartnerAccountsPage() {
|
||||
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
||||
),
|
||||
},
|
||||
]} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,234 +1,237 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
type PartnerStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
type PartnerStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
isSubAccount?: boolean;
|
||||
staffRole?: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function PartnerLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
partnerId: searchParams.get('partnerId') ?? '',
|
||||
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
companyName: searchParams.get('companyName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
|
||||
render: (_, r) => (
|
||||
r.isSubAccount ? (
|
||||
<Tag color="blue">子账号</Tag>
|
||||
) : (
|
||||
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
secondary={
|
||||
r.isSubAccount && r.staffRole
|
||||
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
|
||||
: r.accountPhone || r.partnerAccountId
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
type Row = {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
isSubAccount?: boolean;
|
||||
staffRole?: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function PartnerLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
partnerId: searchParams.get('partnerId') ?? '',
|
||||
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
companyName: searchParams.get('companyName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
r.isSubAccount ? (
|
||||
<Tag color="blue">子账号</Tag>
|
||||
) : (
|
||||
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
secondary={
|
||||
r.isSubAccount && r.staffRole
|
||||
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
|
||||
: r.accountPhone || r.partnerAccountId
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({
|
||||
partnerId: '',
|
||||
partnerAccountId: '',
|
||||
phone: '',
|
||||
companyName: '',
|
||||
eventName: '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({
|
||||
partnerId: '',
|
||||
partnerAccountId: '',
|
||||
phone: '',
|
||||
companyName: '',
|
||||
eventName: '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,405 +1,410 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CityPartnerScopeType,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CityPartnerScopeType,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type WarehouseOption = { id: string; name: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
||||
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
if (d.cityId) await loadWarehouses(d.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus,
|
||||
managedWarehouseId: d.managedWarehouseId,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type WarehouseOption = { id: string; name: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
||||
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
if (d.cityId) await loadWarehouses(d.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus,
|
||||
managedWarehouseId: d.managedWarehouseId,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
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={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: '子账号',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
||||
添加子账号
|
||||
</Button>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.children ?? []}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
void loadWarehouses(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
{createCityId && (
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="添加子账号"
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Select mode="multiple" options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
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={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: '子账号',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
||||
添加子账号
|
||||
</Button>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.children ?? []}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
void loadWarehouses(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
{createCityId && (
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="添加子账号"
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Select mode="multiple" options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,266 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
REDEEM_PENDING_STATUS_LABELS,
|
||||
type RedeemPendingItem,
|
||||
type RedeemPendingStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
REDEEM_PENDING_STATUS_LABELS,
|
||||
type RedeemPendingItem,
|
||||
type RedeemPendingStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
COMPLETED: 'green',
|
||||
REJECTED: 'default',
|
||||
};
|
||||
|
||||
export default function PendingRedeemPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
|
||||
'/admin/redeem-pending',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
|
||||
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
|
||||
message.success('已补核销');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '补核销失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!detail || !rejectReason.trim()) {
|
||||
message.error('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '驳回失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||
{
|
||||
title: '核销码 ID',
|
||||
dataIndex: 'redeemToken',
|
||||
width: 160,
|
||||
|
||||
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||
{
|
||||
title: '用户',
|
||||
width: 120,
|
||||
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: RedeemPendingStatus) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
|
||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
COMPLETED: 'green',
|
||||
REJECTED: 'default',
|
||||
};
|
||||
|
||||
export default function PendingRedeemPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
|
||||
'/admin/redeem-pending',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
|
||||
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
|
||||
message.success('已补核销');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '补核销失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!detail || !rejectReason.trim()) {
|
||||
message.error('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '驳回失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RedeemPendingItem> = [
|
||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||
{
|
||||
title: '核销码 ID',
|
||||
dataIndex: 'redeemToken',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||
{
|
||||
title: '用户',
|
||||
width: 120,
|
||||
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: RedeemPendingStatus) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="pendingNo" label="待处理单号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemToken" label="核销码 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="待处理核销详情"
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' && (
|
||||
<Space>
|
||||
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
|
||||
驳回
|
||||
</Button>
|
||||
<Button type="primary" loading={acting} onClick={() => void complete()}>
|
||||
人工补核销
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{detail.photoUrl && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
|
||||
</div>
|
||||
)}
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销码 ID">
|
||||
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status]}>
|
||||
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
|
||||
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{detail.store?.name || '—'}({detail.store?.id})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联核销单">
|
||||
{detail.redeemRecord?.redeemNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="处理时间">
|
||||
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="驳回待处理单"
|
||||
open={rejectOpen}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
|
||||
onOk={() => void reject()}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="pendingNo" label="待处理单号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemToken" label="核销码 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="待处理核销详情"
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' && (
|
||||
<Space>
|
||||
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
|
||||
驳回
|
||||
</Button>
|
||||
<Button type="primary" loading={acting} onClick={() => void complete()}>
|
||||
人工补核销
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{detail.photoUrl && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
|
||||
</div>
|
||||
)}
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销码 ID">
|
||||
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status]}>
|
||||
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
|
||||
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{detail.store?.name || '—'}({detail.store?.id})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联核销单">
|
||||
{detail.redeemRecord?.redeemNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="处理时间">
|
||||
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="驳回待处理单"
|
||||
open={rejectOpen}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
|
||||
onOk={() => void reject()}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = ProductDetailTemplateDto;
|
||||
|
||||
@@ -136,10 +138,10 @@ export default function ProductDetailTemplatesPage() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ title: '说明', dataIndex: 'description', width: 200 },
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
||||
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
@@ -158,10 +160,17 @@ export default function ProductDetailTemplatesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品详情模板</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建模板</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
@@ -175,7 +184,7 @@ export default function ProductDetailTemplatesPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
|
||||
@@ -14,6 +14,8 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
@@ -387,9 +389,9 @@ export default function ProductsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = useMemo(() => [
|
||||
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
|
||||
{ title: '品名', dataIndex: 'name', width: 200 },
|
||||
{
|
||||
title: '累计销售',
|
||||
dataIndex: 'soldBottles',
|
||||
@@ -458,7 +460,6 @@ export default function ProductsPage() {
|
||||
title: '规格',
|
||||
dataIndex: 'specText',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (v: string, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v || '默认'}</span>
|
||||
@@ -468,7 +469,7 @@ export default function ProductsPage() {
|
||||
),
|
||||
},
|
||||
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
|
||||
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true },
|
||||
{ title: '69码', dataIndex: 'barcode69', width: 160 },
|
||||
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{
|
||||
@@ -493,10 +494,17 @@ export default function ProductsPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('products', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
@@ -517,7 +525,7 @@ export default function ProductsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1080 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
expandable={{
|
||||
rowExpandable: () => true,
|
||||
expandedRowRender: (row) => (
|
||||
@@ -528,7 +536,7 @@ export default function ProductsPage() {
|
||||
pagination={false}
|
||||
columns={skuColumns}
|
||||
dataSource={row.skus ?? []}
|
||||
scroll={{ x: 980 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
@@ -74,8 +76,8 @@ export default function PromoCodesPage() {
|
||||
void loadScenes();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
{
|
||||
title: '场景',
|
||||
@@ -160,10 +162,14 @@ export default function PromoCodesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +200,7 @@ export default function PromoCodesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -7,6 +7,8 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -75,7 +77,7 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
}, [data, initialRedeemNo, loading]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
@@ -133,9 +135,13 @@ export default function RedeemRecordsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -175,7 +181,7 @@ export default function RedeemRecordsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1300 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -58,7 +60,7 @@ export default function ResourcesPage() {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: '归属',
|
||||
@@ -84,8 +86,8 @@ export default function ResourcesPage() {
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
|
||||
{ title: 'URL', dataIndex: 'url' },
|
||||
{ title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -114,12 +116,16 @@ export default function ResourcesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
OSS 资源库
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
登记资源
|
||||
</Button>
|
||||
@@ -170,7 +176,7 @@ export default function ResourcesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
|
||||
@@ -74,7 +76,7 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
@@ -138,11 +140,15 @@ export default function StoreAccountsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||
</Typography.Text>
|
||||
@@ -185,7 +191,7 @@ export default function StoreAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,9 @@ import {
|
||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
@@ -38,7 +40,9 @@ export default function StoreCategoriesPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete');
|
||||
|
||||
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||
const rootOptions = useMemo(
|
||||
@@ -60,6 +64,7 @@ export default function StoreCategoriesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
@@ -112,7 +117,7 @@ export default function StoreCategoriesPage() {
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
const baseColumns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
@@ -141,45 +146,56 @@ export default function StoreCategoriesPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
width: canDelete ? 220 : 160,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.level === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
{canDelete ? (
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, {
|
||||
page: 1,
|
||||
pageSize: rows.length || 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -80,14 +82,14 @@ export default function StoreLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '门店', width: 180, ellipsis: true,
|
||||
title: '门店', width: 180,
|
||||
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
||||
},
|
||||
{
|
||||
title: '账号', width: 150, ellipsis: true,
|
||||
title: '账号', width: 150,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
@@ -111,7 +113,7 @@ export default function StoreLogsPage() {
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要', ellipsis: true,
|
||||
title: '摘要',
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -127,9 +129,13 @@ export default function StoreLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||
</Typography.Paragraph>
|
||||
@@ -164,7 +170,7 @@ export default function StoreLogsPage() {
|
||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||
@@ -40,7 +42,7 @@ export default function StoreMediaPage() {
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||
{
|
||||
@@ -49,7 +51,7 @@ export default function StoreMediaPage() {
|
||||
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
|
||||
: <a href={url} target="_blank" rel="noreferrer">视频</a>,
|
||||
},
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: 'URL', dataIndex: 'url' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -73,10 +75,14 @@ export default function StoreMediaPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
@@ -86,7 +92,7 @@ export default function StoreMediaPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
|
||||
@@ -32,6 +32,7 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -469,7 +470,7 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -519,9 +520,16 @@ export default function StorePackageAuditsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
|
||||
page,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||
{settingsButton}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -20,22 +23,30 @@ export default function StoreRatingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
const [form] = Form.useForm();
|
||||
const [cities, setCities] = useState<{ id: string; name: string }[]>([]);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
...(initialStoreId ? { storeId: initialStoreId } : {}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<Paginated<{ id: string; name: string }>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setCities(res.items))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/store-ratings',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '门店',
|
||||
@@ -53,11 +64,15 @@ export default function StoreRatingsPage() {
|
||||
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店评价
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -66,11 +81,22 @@ export default function StoreRatingsPage() {
|
||||
onFinish={(v) => {
|
||||
setFilters({
|
||||
storeId: v.storeId || '',
|
||||
cityId: v.cityId || '',
|
||||
redeemNo: v.redeemNo || '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID">
|
||||
<Input allowClear placeholder="storeId" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -147,7 +149,7 @@ export default function StoreWithdrawalsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '提现单号',
|
||||
dataIndex: 'withdrawNo',
|
||||
@@ -229,11 +231,15 @@ export default function StoreWithdrawalsPage() {
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店提现审
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
{overdueSummary ? (
|
||||
<Typography.Paragraph type="secondary">
|
||||
待审 {overdueSummary.pendingCount} 笔
|
||||
@@ -299,7 +305,7 @@ export default function StoreWithdrawalsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type StoreCreateForm,
|
||||
} from '../lib/storeCreate';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
@@ -198,6 +199,9 @@ type StoreRow = {
|
||||
pendingPackageAuditId?: string | null;
|
||||
/** 该门店当前待审核信息变更的 requestId(无则为空) */
|
||||
pendingInfoChangeId?: string | null;
|
||||
/** 累计核销好客权益(券面额合计) */
|
||||
totalRedeemedBenefitAmount?: number;
|
||||
contactPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
@@ -720,47 +724,60 @@ export default function StoresPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreRow> = [
|
||||
const baseColumns: ColumnsType<StoreRow> = [
|
||||
{
|
||||
key: 'coverUrl',
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: '门店名',
|
||||
dataIndex: 'name',
|
||||
width: 180,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (v: string, row) => {
|
||||
const name = v || '—';
|
||||
return (
|
||||
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
|
||||
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
<Space size={4} wrap={false}>
|
||||
<span>{name}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
title: '分类',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.category?.name || '—',
|
||||
},
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
|
||||
{ title: '登录号', dataIndex: 'phone', width: 120 },
|
||||
{ key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
key: 'contactPhone',
|
||||
title: '联系电话',
|
||||
dataIndex: 'contactPhone',
|
||||
width: 120,
|
||||
render: (v: string | null | undefined, row) => v || row.phone,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '营业状态', dataIndex: 'status', width: 90,
|
||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'totalRedeemedBenefitAmount',
|
||||
title: '累计核销好客权益',
|
||||
dataIndex: 'totalRedeemedBenefitAmount',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number | undefined) => {
|
||||
const n = Number(v ?? 0);
|
||||
return n > 0 ? `¥${n.toFixed(2)}` : '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'auditStatus',
|
||||
title: '审核', dataIndex: 'auditStatus', width: 100,
|
||||
render: (s, row) => {
|
||||
const status = s || 'APPROVED';
|
||||
@@ -769,7 +786,7 @@ export default function StoresPage() {
|
||||
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
||||
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||
{status === 'REJECTED' && row.rejectReason ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.rejectReason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
@@ -778,32 +795,29 @@ export default function StoresPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'partner',
|
||||
title: '开城合伙人',
|
||||
dataIndex: 'partner',
|
||||
width: 140,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (partner: StoreRow['partner']) => {
|
||||
if (!partner) return '—';
|
||||
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||
return (
|
||||
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
return partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'visibilityWhitelistEnabled',
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ key: 'intro', title: '介绍', dataIndex: 'intro', width: 160, render: (v) => v || '—' },
|
||||
{ key: 'sortOrder', title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ key: 'accountName', title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
{ key: 'createdAt', title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
@@ -850,14 +864,23 @@ export default function StoresPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
@@ -918,7 +941,7 @@ export default function StoresPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1720 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -46,6 +46,8 @@ import { request, type HqProfile } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||
@@ -470,7 +472,7 @@ export default function SupportTicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<TicketRow> = [
|
||||
const baseColumns: ColumnsType<TicketRow> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -503,7 +505,7 @@ export default function SupportTicketsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -546,8 +548,11 @@ export default function SupportTicketsPage() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -559,6 +564,7 @@ export default function SupportTicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
@@ -619,7 +625,7 @@ export default function SupportTicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={
|
||||
isSuperAdmin
|
||||
? {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
@@ -257,7 +258,6 @@ export default function TestWhitelistPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -334,7 +334,7 @@ export default function TestWhitelistPage() {
|
||||
if (accountType === 'partner') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||
{ title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
@@ -347,7 +347,7 @@ export default function TestWhitelistPage() {
|
||||
}
|
||||
if (accountType === 'store') {
|
||||
return [
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '门店名', dataIndex: 'name', width: 160 },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
@@ -386,11 +386,18 @@ export default function TestWhitelistPage() {
|
||||
];
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
|
||||
page: phonePage,
|
||||
pageSize: phonePageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
@@ -472,7 +479,7 @@ export default function TestWhitelistPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={phoneColumns}
|
||||
columns={columns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -69,7 +71,7 @@ export default function ThirdPartyLogsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
@@ -87,12 +89,10 @@ export default function ThirdPartyLogsPage() {
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
ellipsis: true,
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '请求摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeJson(r.requestBody),
|
||||
},
|
||||
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
|
||||
@@ -114,9 +114,16 @@ export default function ThirdPartyLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -143,7 +150,7 @@ export default function ThirdPartyLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -115,7 +117,7 @@ export default function TicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -132,7 +134,7 @@ export default function TicketsPage() {
|
||||
),
|
||||
},
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'remark' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -154,8 +156,11 @@ export default function TicketsPage() {
|
||||
|
||||
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -167,6 +172,7 @@ export default function TicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
工单中心
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
@@ -205,7 +211,7 @@ export default function TicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -61,10 +63,10 @@ export default function UserLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '用户', width: 180, ellipsis: true,
|
||||
title: '用户', width: 180,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.nickname || r.userNo}
|
||||
@@ -84,7 +86,7 @@ export default function UserLogsPage() {
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要', ellipsis: true,
|
||||
title: '摘要',
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -98,9 +100,13 @@ export default function UserLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
@@ -131,7 +137,7 @@ export default function UserLogsPage() {
|
||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
|
||||
type UserOrderRow = {
|
||||
@@ -30,6 +31,77 @@ type UserOrderRow = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function NicknameCell({
|
||||
id,
|
||||
nickname,
|
||||
onSaved,
|
||||
}: {
|
||||
id: string;
|
||||
nickname: string | null;
|
||||
onSaved: (id: string, nickname: string | null) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(nickname ?? '');
|
||||
const savingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setValue(nickname ?? '');
|
||||
}, [nickname, editing]);
|
||||
|
||||
async function commit() {
|
||||
if (savingRef.current) return;
|
||||
const next = value.trim() || null;
|
||||
const prev = nickname?.trim() || null;
|
||||
setEditing(false);
|
||||
if (next === prev) return;
|
||||
savingRef.current = true;
|
||||
try {
|
||||
const res = await request<{ id: string; nickname: string | null }>(`/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ nickname: next ?? '' }),
|
||||
});
|
||||
onSaved(id, res.nickname);
|
||||
message.success('昵称已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
setValue(nickname ?? '');
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => void commit()}
|
||||
onPressEnter={() => void commit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') void commit();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
title="双击修改,离开后保存"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }}
|
||||
>
|
||||
{nickname || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
|
||||
function fmtBenefit(v: number | null | undefined) {
|
||||
const n = Number(v ?? 0);
|
||||
@@ -244,7 +316,7 @@ export default function UsersPage() {
|
||||
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
const baseColumns: ColumnsType<AdminUserRow> = [
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
@@ -256,12 +328,30 @@ export default function UsersPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 140,
|
||||
render: (v: string | null, row) => (
|
||||
<NicknameCell
|
||||
id={row.id}
|
||||
nickname={v}
|
||||
onSaved={(id, nickname) => {
|
||||
setData((prev) =>
|
||||
prev
|
||||
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, nickname } : u)) }
|
||||
: prev,
|
||||
);
|
||||
setDetail((d) => (d && d.id === id ? { ...d, nickname } : d));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机',
|
||||
dataIndex: 'phone',
|
||||
width: 120,
|
||||
render: (v) => maskPhone(v),
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '验手机',
|
||||
@@ -289,7 +379,6 @@ export default function UsersPage() {
|
||||
title: '来源 ID',
|
||||
dataIndex: 'sourceRefId',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (!v) return '—';
|
||||
if (row.sourceType === 'PROMO_CODE') {
|
||||
@@ -306,13 +395,11 @@ export default function UsersPage() {
|
||||
title: '来源标签',
|
||||
dataIndex: 'sourceLabel',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: 'deviceKey',
|
||||
dataIndex: 'deviceKey',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -377,10 +464,18 @@ export default function UsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
{canDeleteUsers ? (
|
||||
<Button
|
||||
danger
|
||||
@@ -390,6 +485,7 @@ export default function UsersPage() {
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
@@ -429,7 +525,7 @@ export default function UsersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1830 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={canDeleteUsers ? {
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
|
||||
@@ -1,317 +1,318 @@
|
||||
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, maskPhone } from '../lib/constants';
|
||||
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, maskPhone } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
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 baseColumns: ColumnsType<GroupRow> = [
|
||||
{
|
||||
title: 'unionId',
|
||||
dataIndex: 'unionId',
|
||||
width: 180,
|
||||
|
||||
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) => maskPhone(v),
|
||||
},
|
||||
{
|
||||
title: '身份摘要',
|
||||
|
||||
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 ? maskPhone(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: '账号',
|
||||
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.name || '—'}
|
||||
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '归属',
|
||||
dataIndex: 'refLabel',
|
||||
width: 160,
|
||||
|
||||
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||
},
|
||||
{
|
||||
title: 'wxOpenId',
|
||||
dataIndex: 'wxOpenId',
|
||||
width: 160,
|
||||
|
||||
},
|
||||
{
|
||||
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),
|
||||
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) => maskPhone(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 ? maskPhone(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 ? maskPhone(r.phone) : null, `#${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 (
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||
{settingsButton}
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
export default function WecomBotLogsPage() {
|
||||
const [form] = Form.useForm();
|
||||
@@ -24,12 +26,12 @@ export default function WecomBotLogsPage() {
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<WecomBotLogDto> = [
|
||||
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' },
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true },
|
||||
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '机器人', dataIndex: 'botName', width: 120, render: (v, r) => v || r.botKey || '—' },
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'success',
|
||||
@@ -37,7 +39,7 @@ export default function WecomBotLogsPage() {
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
|
||||
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
|
||||
{ title: '输入摘要', dataIndex: 'inputSummary' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
@@ -49,9 +51,13 @@ export default function WecomBotLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
||||
</Typography.Paragraph>
|
||||
@@ -96,7 +102,7 @@ export default function WecomBotLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -36,6 +36,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -280,7 +282,7 @@ export default function WecomBotsPage() {
|
||||
|
||||
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||||
|
||||
const columns: ColumnsType<WecomBotDto> = [
|
||||
const baseColumns: ColumnsType<WecomBotDto> = [
|
||||
{
|
||||
title: '头像',
|
||||
dataIndex: 'avatarUrl',
|
||||
@@ -291,18 +293,17 @@ export default function WecomBotsPage() {
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'name', width: 140 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||||
},
|
||||
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
||||
{ title: 'BotID', dataIndex: 'botId', width: 160 },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (perms: WecomBotPermission[]) =>
|
||||
perms.map((p) => (
|
||||
<Tag key={p} style={{ marginBottom: 2 }}>
|
||||
@@ -367,13 +368,20 @@ export default function WecomBotsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-bots', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
企微机器人
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
||||
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
||||
@@ -428,7 +436,7 @@ export default function WecomBotsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -34,6 +34,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -193,7 +195,7 @@ function PushRoutesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<WecomMessagePushDto> = [
|
||||
const baseColumns: ColumnsType<WecomMessagePushDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
@@ -207,7 +209,6 @@ function PushRoutesTab() {
|
||||
{
|
||||
title: 'Webhook',
|
||||
dataIndex: 'webhookUrlMasked',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '推送条件',
|
||||
@@ -262,8 +263,14 @@ function PushRoutesTab() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingsModal}
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||||
</Typography.Paragraph>
|
||||
@@ -304,6 +311,7 @@ function PushRoutesTab() {
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
{settingsButton}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -26,6 +26,8 @@ import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -235,8 +237,8 @@ export default function WineryBillsPage() {
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170 },
|
||||
{
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
@@ -290,8 +292,11 @@ export default function WineryBillsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -305,6 +310,7 @@ export default function WineryBillsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||
无需打款(灰),可展开订单明细
|
||||
@@ -398,7 +404,7 @@ export default function WineryBillsPage() {
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -456,7 +462,7 @@ export default function WineryBillsPage() {
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { useAdminList } from '../../lib/useAdminList';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
||||
|
||||
|
||||
export default function PromoCodeUsersPage() {
|
||||
const { id = '' } = useParams();
|
||||
@@ -21,7 +23,7 @@ export default function PromoCodeUsersPage() {
|
||||
[id],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<PromoCodeAttributedUser> = [
|
||||
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||
@@ -69,6 +71,8 @@ export default function PromoCodeUsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -76,7 +80,7 @@ export default function PromoCodeUsersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
Reference in New Issue
Block a user