v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-19 15:54:51 +08:00
parent 7dd5fdfb12
commit 233ed0af3b
103 changed files with 5764 additions and 185 deletions
+466
View File
@@ -0,0 +1,466 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api';
import {
BIG_SCREEN_DEMO_CHANNEL,
clearPendingBigScreenDemo,
readPendingBigScreenDemo,
type BigScreenDemoPayload,
} from '../lib/admin-events';
export type BigScreenOrder = {
id: string;
orderNo: string;
payAmount: number;
items: string;
createdAt: string;
userPhoneMasked: string | null;
};
type AmountTier = 1 | 2 | 3;
const POLL_MS = 3000;
const ROW_MS = 2500;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const TIER_DURATION: Record<AmountTier, number> = { 1: 3000, 2: 4500, 3: 6000 };
function pad(n: number) {
return String(n).padStart(2, '0');
}
function formatClock(d: Date) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatDateLine(d: Date) {
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
}
function formatHm(iso: string): string {
try {
return formatClock(new Date(iso));
} catch {
return iso;
}
}
function formatAmount(n: number): string {
return Number(n || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export function amountTier(payAmount: number): AmountTier {
if (payAmount >= 1000) return 3;
if (payAmount >= 500) return 2;
return 1;
}
function LiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<div className="big-screen-hero">
<div className="big-screen-clock">{formatClock(now)}</div>
<div className="big-screen-date">{formatDateLine(now)}</div>
</div>
);
}
function OrderRow({
order,
latest,
}: {
order: BigScreenOrder;
latest?: boolean;
}) {
const tier = amountTier(order.payAmount);
return (
<div
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
>
{latest ? <span className="big-screen-row-mark" /> : null}
<span className="big-screen-amount">¥ {formatAmount(order.payAmount)}</span>
<span className="big-screen-items">{order.items || '—'}</span>
<span className="big-screen-time">{formatHm(order.createdAt)}</span>
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
</div>
);
}
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
rot: number;
vr: number;
w: number;
h: number;
color: string;
life: number;
kind: 'rect' | 'ribbon' | 'spark';
rain?: boolean;
};
const TIER_COLORS: Record<AmountTier, string[]> = {
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
};
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
const colors = TIER_COLORS[tier];
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
const out: Particle[] = [];
const cx = w / 2;
const cy = h * 0.42;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
const kind: Particle['kind'] =
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
out.push({
x: cx + (Math.random() - 0.5) * 80,
y: cy + (Math.random() - 0.5) * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 18,
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind,
});
}
if (tier >= 2) {
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
out.push({
x: Math.random() * w,
y: -20 - Math.random() * 80,
vx: (Math.random() - 0.5) * 1.4,
vy: 3 + Math.random() * 5,
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 10,
w: 5 + Math.random() * 8,
h: 10 + Math.random() * 14,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind: 'rect',
rain: true,
});
}
}
return out;
}
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const tier = amountTier(order.payAmount);
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
useEffect(() => {
const duration = TIER_DURATION[tier];
const timer = window.setTimeout(() => onDoneRef.current(), duration);
return () => window.clearTimeout(timer);
}, [order.id, tier]);
useEffect(() => {
if (tier !== 3) return;
const start = performance.now();
const dur = 900;
let raf = 0;
const tick = (now: number) => {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - (1 - p) ** 3;
setDisplayAmount(Math.round(order.payAmount * eased));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [order.payAmount, order.id, tier]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};
resize();
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
let raf = 0;
let last = performance.now();
const started = performance.now();
const duration = TIER_DURATION[tier];
const gravity = tier === 3 ? 0.18 : 0.14;
const loop = (now: number) => {
const dt = Math.min(32, now - last) / 16.6;
last = now;
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
for (const p of particles) {
p.vy += gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.life -= (tier === 3 ? 0.0032 : 0.005) * dt;
if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) {
p.x = Math.random() * canvas.clientWidth;
p.y = -16 - Math.random() * 60;
p.vx = (Math.random() - 0.5) * 1.4;
p.vy = 3 + Math.random() * 5;
p.life = 1;
}
if (p.life <= 0) continue;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rot * Math.PI) / 180);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
if (p.kind === 'spark') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const onResize = () => resize();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
}, [order.id, tier]);
return (
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
<div className="big-screen-fx-amount">¥ {formatAmount(displayAmount)}</div>
<div className="big-screen-fx-items">{order.items || '—'}</div>
<div className="big-screen-fx-meta">
<span>{order.userPhoneMasked || '—'}</span>
<span>{formatHm(order.createdAt)}</span>
</div>
</div>
</div>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null);
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const demoSeenRef = useRef<Set<string>>(new Set());
const [viewportH, setViewportH] = useState(0);
const playNext = useCallback(() => {
const next = queueRef.current.shift() ?? null;
celebratingRef.current = !!next;
setCelebrate(next);
setPaused(!!next);
}, []);
const enqueueNew = useCallback(
(fresh: BigScreenOrder[]) => {
if (!fresh.length) return;
const ranked = [...fresh].sort((a, b) => {
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
if (td !== 0) return td;
return +new Date(b.createdAt) - +new Date(a.createdAt);
});
queueRef.current.push(...ranked);
if (!celebratingRef.current) playNext();
},
[playNext],
);
const playDemo = useCallback(
(payload: BigScreenDemoPayload) => {
if (!payload?.orders?.length || demoSeenRef.current.has(payload.id)) return;
demoSeenRef.current.add(payload.id);
const now = new Date().toISOString();
const fake: BigScreenOrder[] = payload.orders.map((o, i) => ({
id: `${payload.id}-${i}`,
orderNo: `${payload.id}-${i}`,
payAmount: o.payAmount,
items: o.items,
createdAt: now,
userPhoneMasked: o.userPhoneMasked,
}));
setItems((prev) => [...fake, ...prev.filter((row) => !row.id.startsWith('demo-'))]);
enqueueNew(fake);
clearPendingBigScreenDemo();
},
[enqueueNew],
);
const fetchData = useCallback(() => {
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
.then((d) => {
const list = d.items ?? [];
setItems((prev) => {
const demos = prev.filter((o) => o.id.startsWith('demo-'));
return demos.length ? [...demos, ...list] : list;
});
setLoadError('');
const seen = seenIdsRef.current;
if (!seen) {
seenIdsRef.current = new Set(list.map((o) => o.id));
return;
}
const fresh = list.filter((o) => !seen.has(o.id));
for (const o of fresh) seen.add(o.id);
enqueueNew(fresh);
})
.catch((e) => {
setLoadError(e instanceof Error ? e.message : '加载失败');
});
}, [enqueueNew]);
useEffect(() => {
void fetchData();
const id = setInterval(() => void fetchData(), POLL_MS);
return () => clearInterval(id);
}, [fetchData]);
useEffect(() => {
const pending = readPendingBigScreenDemo();
if (pending) playDemo(pending);
let ch: BroadcastChannel | null = null;
try {
ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL);
ch.onmessage = (ev: MessageEvent<BigScreenDemoPayload>) => {
if (ev.data?.type === 'demo' && ev.data.orders?.length) playDemo(ev.data);
};
} catch {
ch = null;
}
return () => {
ch?.close();
};
}, [playDemo]);
useEffect(() => {
const html = document.documentElement;
const prevHtml = html.style.overflow;
const prevBody = document.body.style.overflow;
html.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
html.style.overflow = prevHtml;
document.body.style.overflow = prevBody;
};
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setViewportH(el.clientHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const unitItems = useMemo(() => {
if (!items.length) return [];
const rowH = 80;
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
const minCount = visible + 1;
const unit: BigScreenOrder[] = [];
while (unit.length < minCount) unit.push(...items);
return unit;
}, [items, viewportH]);
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
const rolling = items.length > 0 && unitItems.length > 0;
const latestId = items[0]?.id;
return (
<div className="big-screen-page">
<div className="big-screen-stars" aria-hidden />
<div className="big-screen-frame" aria-hidden>
<span className="big-screen-corner big-screen-corner--tl" />
<span className="big-screen-corner big-screen-corner--tr" />
<span className="big-screen-corner big-screen-corner--bl" />
<span className="big-screen-corner big-screen-corner--br" />
</div>
<header className="big-screen-header">
<div className="big-screen-brand">
<h1 className="big-screen-title"></h1>
<span className="big-screen-subtitle">· </span>
</div>
<div className="big-screen-live-wrap">
<span className="big-screen-live-label"></span>
<span className="big-screen-live">
<span className="big-screen-live-dot" />
LIVE
</span>
</div>
</header>
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
) : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
>
{trackItems.map((o, idx) => (
<OrderRow
key={`${o.id}-${idx}`}
order={o}
latest={idx % items.length === 0 && o.id === latestId}
/>
))}
</div>
)}
</div>
</div>
{celebrate ? (
<CelebrateFx
key={celebrate.id}
order={celebrate}
onDone={playNext}
/>
) : null}
</div>
);
}
+14
View File
@@ -21,6 +21,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { triggerBigScreenDemo } from '../lib/admin-events';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
@@ -191,6 +192,7 @@ export default function OrdersPage() {
const [trackOpen, setTrackOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -542,6 +544,18 @@ export default function OrdersPage() {
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{isSuperAdmin ? (
<Button
onClick={() => {
window.open('/orders/big-screen', 'dukang-big-screen');
triggerBigScreenDemo();
message.success('已发送测试成交动效(三级 / 二级 / 一级)');
}}
>
</Button>
) : null}
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
+5 -1
View File
@@ -63,10 +63,12 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -97,8 +99,9 @@ export default function StoreBillsPage() {
form.setFieldsValue({
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
});
}, [filters.kind, filters.status, form]);
}, [filters.kind, filters.status, filters.storeId, form]);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -359,6 +362,7 @@ export default function StoreBillsPage() {
initialValues={{
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
}}
onFinish={(v: {
kind?: string;
@@ -3,18 +3,23 @@ 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,
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
@@ -22,7 +27,10 @@ import type {
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
@@ -297,6 +305,289 @@ function PackageDetailCard({
);
}
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
return String(v);
}
return String(v);
}
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);
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}>{INFO_CHANGE_FIELD_LABELS[f] ?? 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={680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
}
>
{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={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
>
<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[]>([]);
@@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() {
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
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) void openDetail(rid);
@@ -438,33 +732,52 @@ export default function StorePackageAuditsPage() {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<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),
}}
<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
+28 -1
View File
@@ -192,6 +192,8 @@ type StoreRow = {
visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -767,7 +769,7 @@ export default function StoresPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
width: 280,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
@@ -791,6 +793,31 @@ export default function StoresPage() {
</Button>
</>
) : null}
{row.pendingInfoChangeId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
</Button>
</>
) : null}
<Button
type="link"
size="small"
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
>
</Button>
</Space>
),
},