diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index d66910d..355939c 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -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", diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index cd49eea..81f5524 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -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; +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 1cbf5e5..774836e 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -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: ( + + 套餐审核 + + ), + } 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(null); const [profile, setProfile] = useState(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('/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 ( diff --git a/apps/admin-web/src/lib/admin-events.ts b/apps/admin-web/src/lib/admin-events.ts new file mode 100644 index 0000000..649986f --- /dev/null +++ b/apps/admin-web/src/lib/admin-events.ts @@ -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)); +} diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 86f9453..ced40d4 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -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(); diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index ee69261..2499388 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -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 = { + 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 (
- {title ? ( - - {title} - - ) : null} + + {title ? ( + {title} + ) : null} + {meta ? {meta.text} : null} +
{pkg.name} ¥{pkg.price}
- + {pkg.dishes || '—'} {pkg.usableTime ? ( -
- 可用时间:{pkg.usableTime} -
+ + 可用时间:{pkg.usableTime} + ) : null} {pkg.otherNotes ? ( -
- 其他说明:{pkg.otherNotes} -
+ + 其他说明:{pkg.otherNotes} + ) : null} {images.length ? ( @@ -133,64 +152,19 @@ function PackageDetailCard({ ); } -function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) { - if (!pkg) return <>—; - const images = normalizeStorePackageImageUrls(pkg); - return ( -
-
- {pkg.name} · ¥{pkg.price} -
- {pkg.dishes} - {pkg.usableTime ? ( -
- 可用:{pkg.usableTime} -
- ) : null} - {pkg.otherNotes ? ( -
- 备注:{pkg.otherNotes} -
- ) : null} - {images.length ? ( - - - {images.slice(0, 4).map((url) => ( - - ))} - {images.length > 4 ? ( - +{images.length - 4} - ) : null} - - - ) : null} -
- ); -} - export default function StorePackageAuditsPage() { const [loading, setLoading] = useState(false); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [status, setStatus] = useState('PENDING'); + const [pendingCount, setPendingCount] = useState(0); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [activeId, setActiveId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [detail, setDetail] = useState(null); - const [pkgDetailOpen, setPkgDetailOpen] = useState(false); - const [pkgDetailTitle, setPkgDetailTitle] = useState(''); - const [pkgDetailList, setPkgDetailList] = useState>( - [], - ); 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>( - `/admin/store-package-audits?${qs}`, - ); + const [data, summary] = await Promise.all([ + request>(`/admin/store-package-audits?${qs}`), + request('/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, - ) { - 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 {meta.text}; - }, - }, - { - title: '当前线上', - render: (_, row) => , - }, - { - title: '申请变更', - render: (_, row) => , - }, - ]; + const changeByKey = new Map(diffRows.map((row) => [row.key, row.change])); const columns: ColumnsType = [ { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, @@ -285,7 +234,7 @@ export default function StorePackageAuditsPage() { title: '状态', dataIndex: 'status', render: (v: StorePackageChangeRequestDto['status']) => ( - {STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v} + {HQ_PACKAGE_STATUS_LABELS[v] ?? v} ), }, { @@ -335,7 +284,15 @@ export default function StorePackageAuditsPage() { {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( ))} @@ -380,7 +337,7 @@ export default function StorePackageAuditsPage() { ) : detail ? ( <> - {STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]} + {HQ_PACKAGE_STATUS_LABELS[detail.status]} 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} @@ -388,55 +345,49 @@ export default function StorePackageAuditsPage() { {detail.rejectReason ? ( 驳回原因:{detail.rejectReason} ) : null} - - - 线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条 - - - - - + + 线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条 + +
+
+ + 线上已审核套餐 + + {(detail.livePackages ?? []).length ? ( + (detail.livePackages ?? []).map((pkg, index) => ( + + )) + ) : ( + 暂无线上套餐 + )} +
+
+ + 待审核套餐 + + {(detail.packages ?? []).length ? ( + (detail.packages ?? []).map((pkg, index) => ( + + )) + ) : ( + 暂无待审核套餐 + )} +
+
) : null} - setPkgDetailOpen(false)} - footer={null} - width={720} - destroyOnClose - > - {pkgDetailList.length ? ( - pkgDetailList.map((pkg, index) => ( - - )) - ) : ( - 暂无套餐 - )} - - + isStoreContactPhone(String(value || '')) + ? Promise.resolve() + : Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)), + }, + ]} + extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机" > - + { + const raw = String(value || '').trim(); + if (!raw || isStoreContactPhone(raw)) return Promise.resolve(); + return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)); + }, + }, + ]} > - + diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json index 42e0521..308420e 100644 --- a/apps/admin-web/tsconfig.json +++ b/apps/admin-web/tsconfig.json @@ -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"] } diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts index 23e81ab..e809b7b 100644 --- a/apps/admin-web/vite.config.ts +++ b/apps/admin-web/vite.config.ts @@ -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, diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index f188aea..af1565f 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@dukang/client-logging": "workspace:*", + "@dukang/domain": "workspace:*", "@dukang/shared-types": "workspace:*", "@dukang/shared-ui": "workspace:*", "@dukang/weixin-sdk": "workspace:*", diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index d52f08d..c0290f6 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -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; } diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 1b00ab0..cb4f387 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -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() {

- 用户端门店详情展示与拨号使用此号码,可与登录号不同。 + 用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。

diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx index a7b7059..3d4f2c9 100644 --- a/apps/h5-partner/src/pages/StoreDetailPage.tsx +++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx @@ -365,12 +365,13 @@ export default function StoreDetailPage() { setForm({ ...form, contactPhone: e.target.value })} />

- 店长或对外展示号码,用户端拨号使用此号码。 + 店长或对外展示号码,用户端拨号使用此号码,支持座机。

diff --git a/apps/h5-partner/tsconfig.json b/apps/h5-partner/tsconfig.json index 11f3df4..3acf069 100644 --- a/apps/h5-partner/tsconfig.json +++ b/apps/h5-partner/tsconfig.json @@ -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"] diff --git a/apps/h5-partner/vite.config.ts b/apps/h5-partner/vite.config.ts index 2cf42af..783cc64 100644 --- a/apps/h5-partner/vite.config.ts +++ b/apps/h5-partner/vite.config.ts @@ -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: { diff --git a/apps/mini-user/src/lib/phone.ts b/apps/mini-user/src/lib/phone.ts index 2f5bc30..94f0253 100644 --- a/apps/mini-user/src/lib/phone.ts +++ b/apps/mini-user/src/lib/phone.ts @@ -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, ''); +} diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index f326b2f..fc50b28 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -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() { diff --git a/packages/domain/package.json b/packages/domain/package.json index 864fede..9b45fe8 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -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", diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 7422ac3..7190649 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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'; diff --git a/packages/domain/src/phone.test.ts b/packages/domain/src/phone.test.ts new file mode 100644 index 0000000..eeaeee3 --- /dev/null +++ b/packages/domain/src/phone.test.ts @@ -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'); + }); +}); diff --git a/packages/domain/src/phone.ts b/packages/domain/src/phone.ts new file mode 100644 index 0000000..d3a0a72 --- /dev/null +++ b/packages/domain/src/phone.ts @@ -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, ''); +} diff --git a/packages/shared-types/src/store-package.ts b/packages/shared-types/src/store-package.ts index abb36e3..24d075c 100644 --- a/packages/shared-types/src/store-package.ts +++ b/packages/shared-types/src/store-package.ts @@ -105,6 +105,10 @@ export interface StorePackageAuditAction { rejectReason?: string; } +export interface StorePackageAuditSummaryDto { + pendingCount: number; +} + export interface CreatePackageDisputeRequest { storeId: string; remark?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07b6153..ceed905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 40f7ebb..a3028d9 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -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({ diff --git a/server/dukang-api/src/modules/store/store-package.controller.ts b/server/dukang-api/src/modules/store/store-package.controller.ts index 6fe1689..2cb62cd 100644 --- a/server/dukang-api/src/modules/store/store-package.controller.ts +++ b/server/dukang-api/src/modules/store/store-package.controller.ts @@ -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, diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts index 13192ff..c72b92f 100644 --- a/server/dukang-api/src/modules/store/store-package.service.ts +++ b/server/dukang-api/src/modules/store/store-package.service.ts @@ -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 }; + } } diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 748712d..63c6e03 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -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)) {