feat(admin,store): landline contact phone and package audit UX

Allow store contactPhone as landline, show pending package audit badge, and split live vs pending packages in HQ review.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 18:45:10 +08:00
parent 88053353ab
commit 0f48d7abda
27 changed files with 341 additions and 174 deletions
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0",
+18
View File
@@ -86,3 +86,21 @@ body,
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
.admin-package-audit-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.admin-package-audit-col {
min-width: 0;
}
.admin-package-audit-text {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
max-width: none;
}
+49 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd';
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
import type { MenuProps } from 'antd';
import {
RobotOutlined,
@@ -23,6 +23,7 @@ import {
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
const { Header, Sider, Content } = Layout;
@@ -243,6 +244,30 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.filter(Boolean) as MenuProps['items'];
}
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
if (!items) return items;
return items.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
if ('children' in item && Array.isArray(item.children)) {
return {
...item,
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
} as MenuItem;
}
if (String(item.key) === '/store-package-audits') {
return {
...item,
label: (
<Badge count={pendingCount} size="small" offset={[8, 0]}>
</Badge>
),
} as MenuItem;
}
return item;
});
}
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
export default function AdminLayout() {
@@ -250,11 +275,28 @@ export default function AdminLayout() {
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [packagePendingCount, setPackagePendingCount] = useState(0);
function refreshPackagePendingCount() {
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
.catch(() => {});
}
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
refreshPackagePendingCount();
}, [location.pathname]);
useEffect(() => {
const onChanged = () => refreshPackagePendingCount();
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
}, []);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]);
@@ -273,10 +315,12 @@ export default function AdminLayout() {
: location.pathname;
const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS;
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
}, [profile]);
const base =
!profile || profile.adminRole === 'SUPER_ADMIN'
? MENU_ITEMS
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachPackageAuditBadge(base, packagePendingCount);
}, [profile, packagePendingCount]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
+5
View File
@@ -0,0 +1,5 @@
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
export function notifyPackageAuditChanged() {
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
}
+7
View File
@@ -1,3 +1,5 @@
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreCreateForm = {
partnerAccountId: string;
cityId: string;
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
/** 对外联系电话(店长);可与登录号不同,支持座机 */
contactPhone?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
@@ -59,6 +63,7 @@ export function validateStoreCreateStep1(
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'contactPhone'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -68,6 +73,8 @@ export function validateStoreCreateStep1(
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
const contactPhone = form.contactPhone?.trim();
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
Badge,
Button,
Drawer,
Image,
@@ -14,17 +15,23 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type {
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
StorePackageChangeStatus,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import {
STORE_PACKAGE_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
const name = String(pkg.name ?? '').trim();
return name ? `name:${name}` : `idx:${index}`;
@@ -75,11 +82,14 @@ const CHANGE_LABELS = {
function PackageDetailCard({
title,
pkg,
change,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
return (
<div
style={{
@@ -90,27 +100,36 @@ function PackageDetailCard({
background: '#fafafa',
}}
>
{title ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{title}
</Typography.Text>
) : null}
<Space style={{ marginBottom: 8 }} wrap>
{title ? (
<Typography.Text type="secondary">{title}</Typography.Text>
) : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<div style={{ marginBottom: 4 }}>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 4 }}
>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 8 }}
>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
@@ -133,64 +152,19 @@ function PackageDetailCard({
);
}
function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) {
if (!pkg) return <></>;
const images = normalizeStorePackageImageUrls(pkg);
return (
<div>
<div>
<strong>{pkg.name}</strong> · ¥{pkg.price}
</div>
<Typography.Text type="secondary">{pkg.dishes}</Typography.Text>
{pkg.usableTime ? (
<div>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
) : null}
{pkg.otherNotes ? (
<div>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={4} style={{ marginTop: 8 }}>
{images.slice(0, 4).map((url) => (
<Image
key={url}
src={url}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
{images.length > 4 ? (
<Typography.Text type="secondary">+{images.length - 4}</Typography.Text>
) : null}
</Space>
</Image.PreviewGroup>
) : null}
</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 [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
const [pkgDetailOpen, setPkgDetailOpen] = useState(false);
const [pkgDetailTitle, setPkgDetailTitle] = useState('');
const [pkgDetailList, setPkgDetailList] = useState<Array<StorePackageItemDto | StorePackageViewDto>>(
[],
);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -200,12 +174,14 @@ export default function StorePackageAuditsPage() {
pageSize: '20',
});
if (nextStatus) qs.set('status', nextStatus);
const data = await request<Paginated<StorePackageChangeRequestDto>>(
`/admin/store-package-audits?${qs}`,
);
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 {
@@ -232,15 +208,6 @@ export default function StorePackageAuditsPage() {
}
}
function openPackageDetails(
title: string,
list: Array<StorePackageItemDto | StorePackageViewDto>,
) {
setPkgDetailTitle(title);
setPkgDetailList(list);
setPkgDetailOpen(true);
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-package-audits/${id}/audit`, {
@@ -251,6 +218,7 @@ export default function StorePackageAuditsPage() {
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
@@ -258,26 +226,7 @@ export default function StorePackageAuditsPage() {
}
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
{
title: '变更',
dataIndex: 'change',
width: 72,
render: (v: keyof typeof CHANGE_LABELS) => {
const meta = CHANGE_LABELS[v];
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '当前线上',
render: (_, row) => <PackageSummaryCell pkg={row.live} />,
},
{
title: '申请变更',
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
},
];
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
@@ -285,7 +234,7 @@ export default function StorePackageAuditsPage() {
title: '状态',
dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
@@ -335,7 +284,15 @@ export default function StorePackageAuditsPage() {
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
@@ -380,7 +337,7 @@ export default function StorePackageAuditsPage() {
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
@@ -388,55 +345,49 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Text>
<Button
size="small"
onClick={() =>
openPackageDetails('申请套餐详情', detail.packages ?? [])
}
>
</Button>
<Button
size="small"
onClick={() =>
openPackageDetails('线上套餐详情', detail.livePackages ?? [])
}
>
线
</Button>
</Space>
<Table
size="small"
rowKey="key"
columns={diffColumns}
dataSource={diffRows}
pagination={false}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
</Typography.Paragraph>
<div className="admin-package-audit-cols">
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
线
</Typography.Title>
{(detail.livePackages ?? []).length ? (
(detail.livePackages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`live-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
</>
) : null}
</Drawer>
<Modal
title={pkgDetailTitle || '套餐详情'}
open={pkgDetailOpen}
onCancel={() => setPkgDetailOpen(false)}
footer={null}
width={720}
destroyOnClose
>
{pkgDetailList.length ? (
pkgDetailList.map((pkg, index) => (
<PackageDetailCard key={`${pkg.name}-${index}`} title={`套餐 ${index + 1}`} pkg={pkg} />
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Modal>
<Modal
title="驳回套餐变更"
open={rejectOpen}
+23 -5
View File
@@ -24,6 +24,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { EnvironmentOutlined } from '@ant-design/icons';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
import { request, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
@@ -966,10 +967,18 @@ export default function StoresPage() {
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
rules={[{ required: true, message: '请填写对外联系电话' }]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同"
rules={[
{ required: true, message: '请填写对外联系电话' },
{
validator: (_, value) =>
isStoreContactPhone(String(value || ''))
? Promise.resolve()
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
},
]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
>
<Input placeholder="11位手机号" />
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item
name="categoryParentId"
@@ -1252,9 +1261,18 @@ export default function StoresPage() {
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
extra="用户端拨号展示;留空则与登录号相同"
extra="用户端拨号展示;留空则与登录号相同。支持座机"
rules={[
{
validator: (_, value) => {
const raw = String(value || '').trim();
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
},
},
]}
>
<Input placeholder="11位手机号,可与登录号不同" />
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
+4 -1
View File
@@ -11,7 +11,10 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
"strict": true,
"paths": {
"@dukang/domain": ["../../packages/domain/src/index.ts"]
}
},
"include": ["src"]
}
+6
View File
@@ -1,10 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
},
},
server: {
host: true,
port: 5175,