merge(dev_jacy): 座机联系电话 + 套餐审核体验
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/client-logging": "workspace:*",
|
||||
"@dukang/domain": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getDefaultPartnerRegionForm } from './china-region';
|
||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||
|
||||
export type StoreDraftForm = {
|
||||
regionCodes: string[];
|
||||
cityId: string;
|
||||
@@ -48,8 +51,6 @@ export function storeDraftKey(accountId?: string): string {
|
||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||
}
|
||||
|
||||
import { getDefaultPartnerRegionForm } from './china-region';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
...getDefaultPartnerRegionForm(),
|
||||
cityId: '',
|
||||
@@ -274,6 +275,6 @@ export function validateStoreStep3(
|
||||
if (!form.phone.trim()) return '请填写门店登录手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
|
||||
if (!form.contactPhone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.contactPhone.trim())) return '联系电话须为11位手机号';
|
||||
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1175,7 +1175,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
type="tel"
|
||||
|
||||
placeholder="店长或对外可拨打号码"
|
||||
placeholder="手机号或座机,如 0379-8888888"
|
||||
|
||||
value={form.contactPhone}
|
||||
|
||||
@@ -1193,7 +1193,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
用户端门店详情展示与拨号使用此号码,可与登录号不同。
|
||||
用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -365,12 +365,13 @@ export default function StoreDetailPage() {
|
||||
<input
|
||||
disabled={readOnly}
|
||||
type="tel"
|
||||
placeholder="手机号或座机,如 0379-8888888"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
店长或对外展示号码,用户端拨号使用此号码。
|
||||
店长或对外展示号码,用户端拨号使用此号码,支持座机。
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
|
||||
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -21,3 +21,7 @@ export function validateMobilePhone(phone: string): { ok: boolean; message?: str
|
||||
export function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
export function toDialablePhone(raw: string): string {
|
||||
return String(raw ?? '').replace(/[\s-]/g, '');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import ShareNavButton from '../../components/ShareNavButton';
|
||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||
import { track } from '../../lib/analytics';
|
||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||
import {
|
||||
@@ -276,7 +276,7 @@ export default function StoreDetailPage() {
|
||||
return;
|
||||
}
|
||||
track('store_phone_call', { storeId: store.id });
|
||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||
Taro.makePhoneCall({ phoneNumber: toDialablePhone(store.phone) }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
|
||||
@@ -351,3 +351,4 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
export * from './city-partner';
|
||||
export * from './dev-plan';
|
||||
export * from './support-ticket';
|
||||
export * from './phone';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isLandlinePhone,
|
||||
isMobilePhone,
|
||||
isStoreContactPhone,
|
||||
normalizeContactPhone,
|
||||
toDialablePhone,
|
||||
} from './phone';
|
||||
|
||||
describe('store contact phone', () => {
|
||||
it('accepts mainland mobile numbers', () => {
|
||||
expect(isMobilePhone('13800138000')).toBe(true);
|
||||
expect(isStoreContactPhone('13800138000')).toBe(true);
|
||||
expect(isStoreContactPhone(' 13800138000 ')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts landlines with or without hyphens and optional extension', () => {
|
||||
expect(isLandlinePhone('0379-8888888')).toBe(true);
|
||||
expect(isLandlinePhone('010-12345678')).toBe(true);
|
||||
expect(isLandlinePhone('03798888888')).toBe(true);
|
||||
expect(isLandlinePhone('01012345678')).toBe(true);
|
||||
expect(isLandlinePhone('0379-8888888-12')).toBe(true);
|
||||
expect(isStoreContactPhone('0379-8888 888')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects login-style invalid and incomplete numbers', () => {
|
||||
expect(isStoreContactPhone('')).toBe(false);
|
||||
expect(isStoreContactPhone('12345678')).toBe(false);
|
||||
expect(isStoreContactPhone('12345678901')).toBe(false);
|
||||
expect(isStoreContactPhone('400-123-4567')).toBe(false);
|
||||
expect(isMobilePhone('0379-8888888')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes spaces and strips dial punctuation', () => {
|
||||
expect(normalizeContactPhone(' 0379-8888 888 ')).toBe('0379-8888888');
|
||||
expect(toDialablePhone('0379-8888888')).toBe('03798888888');
|
||||
expect(toDialablePhone('010 1234 5678')).toBe('01012345678');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 11 位大陆手机号(登录凭证) */
|
||||
export const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
|
||||
/**
|
||||
* 国内座机:区号 0 开头(3~4 位),本地号 7~8 位,允许 `-`,可选分机。
|
||||
* 例:0379-8888888、010-12345678、03798888888
|
||||
*/
|
||||
export const LANDLINE_PHONE_RE = /^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/;
|
||||
|
||||
export const STORE_CONTACT_PHONE_HINT = '请输入正确的联系电话(手机号或座机,如 0379-8888888)';
|
||||
|
||||
export function normalizeContactPhone(raw: string): string {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export function isMobilePhone(raw: string): boolean {
|
||||
return MOBILE_PHONE_RE.test(normalizeContactPhone(raw));
|
||||
}
|
||||
|
||||
export function isLandlinePhone(raw: string): boolean {
|
||||
return LANDLINE_PHONE_RE.test(normalizeContactPhone(raw));
|
||||
}
|
||||
|
||||
/** 门店对外联系电话:手机号或座机 */
|
||||
export function isStoreContactPhone(raw: string): boolean {
|
||||
const s = normalizeContactPhone(raw);
|
||||
return isMobilePhone(s) || isLandlinePhone(s);
|
||||
}
|
||||
|
||||
/** 拨号用:去掉空格和横线 */
|
||||
export function toDialablePhone(raw: string): string {
|
||||
return String(raw ?? '').replace(/[\s-]/g, '');
|
||||
}
|
||||
@@ -105,6 +105,10 @@ export interface StorePackageAuditAction {
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
export interface StorePackageAuditSummaryDto {
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export interface CreatePackageDisputeRequest {
|
||||
storeId: string;
|
||||
remark?: string;
|
||||
|
||||
Generated
+6
@@ -26,6 +26,9 @@ importers:
|
||||
'@ant-design/icons':
|
||||
specifier: ^5.5.1
|
||||
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dukang/domain':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/domain
|
||||
'@dukang/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-types
|
||||
@@ -78,6 +81,9 @@ importers:
|
||||
'@dukang/client-logging':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/client-logging
|
||||
'@dukang/domain':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/domain
|
||||
'@dukang/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-types
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { validateBusinessHours } from '@dukang/domain';
|
||||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
@@ -277,14 +277,14 @@ export class AdminStoresService {
|
||||
|
||||
if (dto.phone !== undefined) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
if (!isMobilePhone(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的登录手机号');
|
||||
}
|
||||
}
|
||||
if (dto.contactPhone !== undefined) {
|
||||
const contact = dto.contactPhone.trim();
|
||||
if (contact && !/^1[3-9]\d{9}$/.test(contact)) {
|
||||
throw new BadRequestException('请输入正确的联系电话');
|
||||
if (contact && !isStoreContactPhone(contact)) {
|
||||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +505,7 @@ export class AdminStoresService {
|
||||
|
||||
async createStore(dto: CreateStoreDto) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
if (!isMobilePhone(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
@@ -576,8 +576,8 @@ export class AdminStoresService {
|
||||
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||
|
||||
const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
|
||||
if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
|
||||
throw new BadRequestException('请输入正确的联系电话');
|
||||
if (!isStoreContactPhone(contactPhoneRaw)) {
|
||||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
|
||||
@@ -70,6 +70,11 @@ export class AdminStorePackageController {
|
||||
export class AdminStorePackageAuditController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.packages.adminAuditSummary();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('status') status?: string,
|
||||
|
||||
@@ -376,4 +376,11 @@ export class StorePackageService {
|
||||
});
|
||||
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
||||
}
|
||||
|
||||
async adminAuditSummary() {
|
||||
const pendingCount = await this.prisma.storePackageChangeRequest.count({
|
||||
where: { status: 'PENDING' },
|
||||
});
|
||||
return { pendingCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import { validateBusinessHours } from '@dukang/domain';
|
||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
@@ -371,8 +371,8 @@ export class StoreService {
|
||||
body.contactPhone != null && String(body.contactPhone).trim()
|
||||
? String(body.contactPhone).trim()
|
||||
: normalizedPhone;
|
||||
if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
if (!isStoreContactPhone(contactPhoneRaw)) {
|
||||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||
}
|
||||
|
||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||
@@ -653,8 +653,8 @@ export class StoreService {
|
||||
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
|
||||
|
||||
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
||||
if (contactPhone !== undefined && !/^1[3-9]\d{9}$/.test(contactPhone)) {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
if (contactPhone !== undefined && !isStoreContactPhone(contactPhone)) {
|
||||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||
}
|
||||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
|
||||
Reference in New Issue
Block a user