627 lines
20 KiB
TypeScript
627 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Descriptions,
|
|
Drawer,
|
|
Image,
|
|
Input,
|
|
Modal,
|
|
Space,
|
|
Table,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import type {
|
|
StoreInfoChangeFieldDiff,
|
|
StoreInfoChangeRequestDto,
|
|
StoreInfoChangeStatus,
|
|
StorePackageAuditSummaryDto,
|
|
StorePackageChangeRequestDto,
|
|
StorePackageChangeStatus,
|
|
} from '@dukang/shared-types';
|
|
import {
|
|
STORE_INFO_CHANGE_STATUS_LABELS,
|
|
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
|
} from '@dukang/shared-types';
|
|
import { request, type Paginated } from '../lib/api';
|
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
|
import { fmtTime } from '../lib/constants';
|
|
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
|
|
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
|
PENDING: '待审核',
|
|
APPROVED: '已通过',
|
|
REJECTED: '已驳回',
|
|
};
|
|
|
|
function fmtFieldValue(field: string, v: unknown): string {
|
|
if (v == null || String(v).trim() === '') return '(空)';
|
|
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
|
return String(v);
|
|
}
|
|
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
|
return `${v.length} 张`;
|
|
}
|
|
if (field === 'coverUrl') return '见对比图';
|
|
return String(v);
|
|
}
|
|
|
|
function InfoChangeImageDiff({
|
|
field,
|
|
live,
|
|
proposed,
|
|
imageSize = 72,
|
|
}: {
|
|
field: string;
|
|
live: unknown;
|
|
proposed: unknown;
|
|
imageSize?: number;
|
|
}) {
|
|
const liveUrls =
|
|
field === 'coverUrl'
|
|
? [String(live || '').trim()].filter(Boolean)
|
|
: Array.isArray(live)
|
|
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
|
: [];
|
|
const proposedUrls =
|
|
field === 'coverUrl'
|
|
? [String(proposed || '').trim()].filter(Boolean)
|
|
: Array.isArray(proposed)
|
|
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
|
: [];
|
|
|
|
return (
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<div>
|
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
|
变更前
|
|
</Typography.Text>
|
|
{liveUrls.length ? (
|
|
<Image.PreviewGroup>
|
|
<Space wrap size={8}>
|
|
{liveUrls.map((url) => (
|
|
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
|
))}
|
|
</Space>
|
|
</Image.PreviewGroup>
|
|
) : (
|
|
<Typography.Text type="secondary">(空)</Typography.Text>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
|
变更后
|
|
</Typography.Text>
|
|
{proposedUrls.length ? (
|
|
<Image.PreviewGroup>
|
|
<Space wrap size={8}>
|
|
{proposedUrls.map((url) => (
|
|
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
|
))}
|
|
</Space>
|
|
</Image.PreviewGroup>
|
|
) : (
|
|
<Typography.Text type="secondary">(空)</Typography.Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoChangeAuditPanel({
|
|
initialRequestId,
|
|
}: {
|
|
initialRequestId?: string | null;
|
|
}) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [status, setStatus] = useState<string>('PENDING');
|
|
const [pendingCount, setPendingCount] = useState(0);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [rejectReason, setRejectReason] = useState('');
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [infoFullscreen, setInfoFullscreen] = useState(false);
|
|
|
|
async function reload(nextPage = page, nextStatus = status) {
|
|
setLoading(true);
|
|
try {
|
|
const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' });
|
|
if (nextStatus) qs.set('status', nextStatus);
|
|
const [data, summary] = await Promise.all([
|
|
request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
|
|
`/admin/store-info-change-requests?${qs}`,
|
|
),
|
|
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
|
|
]);
|
|
setItems(data.items);
|
|
setTotal(data.total);
|
|
setPage(data.page ?? nextPage);
|
|
setPendingCount(summary.pendingCount ?? 0);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void reload(1, status);
|
|
}, [status]);
|
|
|
|
useEffect(() => {
|
|
if (initialRequestId) void openDetail(initialRequestId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function openDetail(id: string) {
|
|
setDetailOpen(true);
|
|
setDetailLoading(true);
|
|
setDetail(null);
|
|
try {
|
|
const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
|
|
`/admin/store-info-change-requests/${id}`,
|
|
);
|
|
setDetail(data);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '加载详情失败');
|
|
setDetailOpen(false);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}
|
|
|
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
|
try {
|
|
await request(`/admin/store-info-change-requests/${id}/audit`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(
|
|
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
|
),
|
|
});
|
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
|
setDetailOpen(false);
|
|
notifyPackageAuditChanged();
|
|
void reload(page, status);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '操作失败');
|
|
}
|
|
}
|
|
|
|
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (v: StoreInfoChangeStatus) => (
|
|
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '变更字段',
|
|
render: (_, row) =>
|
|
row.changedFields?.length
|
|
? row.changedFields.map((f) => (
|
|
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
|
|
))
|
|
: '—',
|
|
},
|
|
{
|
|
title: '提交方',
|
|
render: (_, row) =>
|
|
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
|
|
},
|
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
|
{
|
|
title: '操作',
|
|
render: (_, row) => (
|
|
<Space>
|
|
<Button type="link" onClick={() => void openDetail(row.id)}>
|
|
查看
|
|
</Button>
|
|
{row.status === 'PENDING' ? (
|
|
<>
|
|
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
|
通过
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
danger
|
|
onClick={() => {
|
|
setActiveId(row.id);
|
|
setRejectReason('');
|
|
setRejectOpen(true);
|
|
}}
|
|
>
|
|
驳回
|
|
</Button>
|
|
</>
|
|
) : (
|
|
row.rejectReason || null
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
|
<Button
|
|
key={s || 'all'}
|
|
type={status === s ? 'primary' : 'default'}
|
|
onClick={() => setStatus(s)}
|
|
>
|
|
{s === 'PENDING' ? (
|
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
|
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
|
|
</Badge>
|
|
) : s ? (
|
|
STORE_INFO_CHANGE_STATUS_LABELS[s]
|
|
) : (
|
|
'全部'
|
|
)}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={items}
|
|
pagination={{
|
|
current: page,
|
|
total,
|
|
pageSize: 20,
|
|
onChange: (p) => void reload(p, status),
|
|
}}
|
|
/>
|
|
|
|
<Drawer
|
|
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
|
|
width={infoFullscreen ? '100%' : 680}
|
|
open={detailOpen}
|
|
onClose={() => {
|
|
setDetailOpen(false);
|
|
setInfoFullscreen(false);
|
|
}}
|
|
extra={
|
|
<Space>
|
|
<Button onClick={() => setInfoFullscreen((v) => !v)}>
|
|
{infoFullscreen ? '退出全屏' : '全屏查看'}
|
|
</Button>
|
|
{detail?.status === 'PENDING' ? (
|
|
<>
|
|
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
|
<Button
|
|
danger
|
|
onClick={() => {
|
|
setActiveId(detail.id);
|
|
setRejectReason('');
|
|
setRejectOpen(true);
|
|
}}
|
|
>
|
|
驳回
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</Space>
|
|
}
|
|
>
|
|
{detailLoading ? (
|
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
|
) : detail ? (
|
|
<>
|
|
<Space style={{ marginBottom: 16 }} wrap>
|
|
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
|
<Typography.Text type="secondary">
|
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
|
|
</Typography.Text>
|
|
</Space>
|
|
{detail.rejectReason ? (
|
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
|
) : null}
|
|
{detail.diffs && detail.diffs.length ? (
|
|
<Descriptions column={1} bordered size="small">
|
|
{detail.diffs.map((d) => (
|
|
<Descriptions.Item
|
|
key={d.field}
|
|
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
|
>
|
|
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
|
<InfoChangeImageDiff
|
|
field={d.field}
|
|
live={d.live}
|
|
proposed={d.proposed}
|
|
imageSize={infoFullscreen ? 160 : 72}
|
|
/>
|
|
) : (
|
|
<span>
|
|
<Typography.Text delete type="secondary">
|
|
{fmtFieldValue(d.field, d.live)}
|
|
</Typography.Text>
|
|
<Typography.Text type="secondary"> → </Typography.Text>
|
|
<Typography.Text strong>
|
|
{fmtFieldValue(d.field, d.proposed)}
|
|
</Typography.Text>
|
|
</span>
|
|
)}
|
|
</Descriptions.Item>
|
|
))}
|
|
</Descriptions>
|
|
) : (
|
|
<Typography.Text type="secondary">无变更字段明细</Typography.Text>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title="驳回信息变更"
|
|
open={rejectOpen}
|
|
onCancel={() => setRejectOpen(false)}
|
|
onOk={() => {
|
|
if (!activeId) return;
|
|
if (!rejectReason.trim()) {
|
|
message.warning('请填写驳回原因');
|
|
return;
|
|
}
|
|
void audit(activeId, 'REJECT', rejectReason.trim());
|
|
setRejectOpen(false);
|
|
}}
|
|
>
|
|
<Input.TextArea
|
|
rows={3}
|
|
value={rejectReason}
|
|
placeholder="驳回原因"
|
|
onChange={(e) => setRejectReason(e.target.value)}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function StorePackageAuditsPage() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [status, setStatus] = useState<string>('PENDING');
|
|
const [pendingCount, setPendingCount] = useState(0);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [rejectReason, setRejectReason] = useState('');
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
|
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
|
const [packageFullscreen, setPackageFullscreen] = useState(false);
|
|
|
|
async function reload(nextPage = page, nextStatus = status) {
|
|
setLoading(true);
|
|
try {
|
|
const qs = new URLSearchParams({
|
|
page: String(nextPage),
|
|
pageSize: '20',
|
|
});
|
|
if (nextStatus) qs.set('status', nextStatus);
|
|
const [data, summary] = await Promise.all([
|
|
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
|
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
|
]);
|
|
setItems(data.items);
|
|
setTotal(data.total);
|
|
setPage(data.page);
|
|
setPendingCount(summary.pendingCount ?? 0);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void reload(1, status);
|
|
}, [status]);
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
|
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
|
const infoRequestId = searchParams.get('infoRequestId');
|
|
useEffect(() => {
|
|
const rid = searchParams.get('requestId');
|
|
if (rid) {
|
|
setActiveRequestId(rid);
|
|
setDetailOpen(true);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
function openDetail(id: string) {
|
|
setActiveRequestId(id);
|
|
setDrawerTitle('套餐变更详情');
|
|
setDetailOpen(true);
|
|
}
|
|
|
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
|
try {
|
|
await request(`/admin/store-package-audits/${id}/audit`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(
|
|
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
|
),
|
|
});
|
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
|
setDetailOpen(false);
|
|
notifyPackageAuditChanged();
|
|
void reload(page, status);
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '操作失败');
|
|
}
|
|
}
|
|
|
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (v: StorePackageChangeRequestDto['status']) => (
|
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '套餐数',
|
|
render: (_, row) => row.packages?.length ?? 0,
|
|
},
|
|
{
|
|
title: '提交方',
|
|
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
|
|
},
|
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
|
{
|
|
title: '操作',
|
|
render: (_, row) => (
|
|
<Space>
|
|
<Button type="link" onClick={() => openDetail(row.id)}>
|
|
查看变更
|
|
</Button>
|
|
{row.status === 'PENDING' ? (
|
|
<>
|
|
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
|
通过
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
danger
|
|
onClick={() => {
|
|
setActiveId(row.id);
|
|
setRejectReason('');
|
|
setRejectOpen(true);
|
|
}}
|
|
>
|
|
驳回
|
|
</Button>
|
|
</>
|
|
) : (
|
|
row.rejectReason || null
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Typography.Title level={4}>审核通知</Typography.Title>
|
|
<Tabs
|
|
activeKey={activeTab}
|
|
onChange={setActiveTab}
|
|
items={[
|
|
{
|
|
key: 'package',
|
|
label: '套餐审核',
|
|
children: (
|
|
<>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
|
{s === 'PENDING' ? (
|
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
|
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
|
</Badge>
|
|
) : s ? (
|
|
HQ_PACKAGE_STATUS_LABELS[s]
|
|
) : (
|
|
'全部'
|
|
)}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={items}
|
|
pagination={{
|
|
current: page,
|
|
total,
|
|
pageSize: 20,
|
|
onChange: (p) => void reload(p, status),
|
|
}}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: 'info',
|
|
label: '信息变更',
|
|
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Drawer
|
|
title={drawerTitle}
|
|
width={packageFullscreen ? '100%' : 880}
|
|
open={detailOpen}
|
|
onClose={() => {
|
|
setDetailOpen(false);
|
|
setPackageFullscreen(false);
|
|
}}
|
|
destroyOnClose
|
|
extra={
|
|
<Button onClick={() => setPackageFullscreen((v) => !v)}>
|
|
{packageFullscreen ? '退出全屏' : '全屏查看'}
|
|
</Button>
|
|
}
|
|
>
|
|
{activeRequestId ? (
|
|
<StorePackageAuditPanel
|
|
requestId={activeRequestId}
|
|
fullscreen={packageFullscreen}
|
|
onAudited={() => {
|
|
setDetailOpen(false);
|
|
void reload(page, status);
|
|
}}
|
|
onDetailLoaded={(d) => {
|
|
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title="驳回套餐变更"
|
|
open={rejectOpen}
|
|
onCancel={() => setRejectOpen(false)}
|
|
onOk={() => {
|
|
if (!activeId) return;
|
|
if (!rejectReason.trim()) {
|
|
message.warning('请填写驳回原因');
|
|
return;
|
|
}
|
|
void audit(activeId, 'REJECT', rejectReason.trim());
|
|
setRejectOpen(false);
|
|
}}
|
|
>
|
|
<Input.TextArea
|
|
rows={3}
|
|
value={rejectReason}
|
|
placeholder="驳回原因"
|
|
onChange={(e) => setRejectReason(e.target.value)}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|