diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index fa378e3..9d7c08b 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -669,18 +669,30 @@ export default function StoresPage() { title: '封面', dataIndex: 'coverUrl', width: 72, render: (url) => url ? : '—', }, - { title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => ( - - {v} - {row.isTest ? 测试 : null} - - ) }, + { + title: '门店名', + dataIndex: 'name', + width: 180, + ellipsis: { showTitle: false }, + render: (v: string, row) => { + const name = v || '—'; + return ( + + + {name} + + {row.isTest ? 测试 : null} + + ); + }, + }, { title: '分类', width: 100, + ellipsis: true, render: (_, row) => row.category?.name || '—', }, - { title: '城市', dataIndex: 'cityName', width: 80 }, + { title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true }, { title: '登录号', dataIndex: 'phone', width: 120 }, { title: '联系电话', @@ -698,10 +710,10 @@ export default function StoresPage() { const status = s || 'APPROVED'; const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green'; return ( - + {STORE_AUDIT_STATUS_LABELS[status] || status} {status === 'REJECTED' && row.rejectReason ? ( - + {row.rejectReason} ) : null} @@ -713,8 +725,16 @@ export default function StoresPage() { title: '开城合伙人', dataIndex: 'partner', width: 140, - render: (partner: StoreRow['partner']) => - partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—', + ellipsis: { showTitle: false }, + render: (partner: StoreRow['partner']) => { + if (!partner) return '—'; + const label = partnerOptionLabel({ id: partner.id ?? '', ...partner }); + return ( + + {label} + + ); + }, }, { title: '可见', @@ -725,10 +745,12 @@ export default function StoresPage() { }, { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { title: '排序', dataIndex: 'sortOrder', width: 70 }, - { title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, + { title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 140, + title: '操作', + width: 140, + fixed: 'right', render: (_, row) => ( @@ -800,8 +822,24 @@ export default function StoresPage() { - { setPage(p); setPageSize(ps); } }} /> +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> setDrawerOpen(false)} extra={detail && ( diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts index 44d5d94..23e81ab 100644 --- a/apps/admin-web/vite.config.ts +++ b/apps/admin-web/vite.config.ts @@ -6,6 +6,7 @@ const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010'; export default defineConfig({ plugins: [react()], server: { + host: true, port: 5175, proxy: { '/api': apiTarget }, }, diff --git a/apps/h5-partner/src/lib/storeStatus.ts b/apps/h5-partner/src/lib/storeStatus.ts index 08f9da9..7f58278 100644 --- a/apps/h5-partner/src/lib/storeStatus.ts +++ b/apps/h5-partner/src/lib/storeStatus.ts @@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri export function storeAuditLabel(auditStatus?: string | null): string { const s = String(auditStatus || 'APPROVED').toUpperCase(); - if (s === 'PENDING') return '待总部审核'; + if (s === 'PENDING') return '待审核'; if (s === 'REJECTED') return '审核驳回'; if (s === 'APPROVED') return '审核通过'; return auditStatus || '—'; @@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string { const s = String(status).toUpperCase(); if (s === 'OPEN') return '营业中'; if (s === 'PAUSED') return '临时闭店'; - if (s === 'CLOSED') return '永久关闭'; + if (s === 'CLOSED') return '永久闭店'; return status; } +/** 列表右上角统一状态:审核未通过优先于营业状态 */ +export function storeListBadge(store: { + status?: unknown; + auditStatus?: unknown; +}): { label: string; pillClass: string } { + const audit = String(store.auditStatus || 'APPROVED').toUpperCase(); + if (audit === 'PENDING') { + return { label: '待审核', pillClass: storeAuditPillClass('PENDING') }; + } + if (audit === 'REJECTED') { + return { label: '审核驳回', pillClass: storeAuditPillClass('REJECTED') }; + } + const status = String(store.status || '').toUpperCase(); + return { label: storeStatusLabel(status), pillClass: storeStatusPillClass(status) }; +} + export function storeStatusPillClass(status: string): string { const s = String(status).toUpperCase(); if (s === 'OPEN') return 'partner-status-pill--open'; diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 940a03b..313d975 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -8,6 +8,7 @@ import OssUploadField from '../components/OssUploadField'; import MultiOssUploadField from '../components/MultiOssUploadField'; import { request } from '../lib/api'; +import { fetchClientConfig } from '../lib/wechat-auth'; import { toastError, toastSuccess } from '../lib/toast'; import { resolveRegionBinding } from '../lib/china-region'; @@ -48,6 +49,9 @@ import { normalizePackageFormItems, validatePackageFormItems } from '../lib/stor const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const; +const DEFAULT_PARTNER_ONBOARD_CS_HINT = + '使用问题、提现问题等随时可联系【杜康好客】客服'; + type StoreCategoryNode = { id: string; name: string; @@ -98,6 +102,10 @@ export default function StoreCreatePage() { const [fieldErrors, setFieldErrors] = useState({}); const [submitting, setSubmitting] = useState(false); + const [csAdded, setCsAdded] = useState(false); + const [csQrUrl, setCsQrUrl] = useState(null); + const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT); + const [csConfigLoading, setCsConfigLoading] = useState(false); const [cities, setCities] = useState([]); @@ -149,6 +157,29 @@ export default function StoreCreatePage() { }); }, [step]); + useEffect(() => { + if (step !== 4) return; + let cancelled = false; + setCsConfigLoading(true); + void fetchClientConfig() + .then((cfg) => { + if (cancelled) return; + setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null); + setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT); + }) + .catch(() => { + if (cancelled) return; + setCsQrUrl(null); + setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT); + }) + .finally(() => { + if (!cancelled) setCsConfigLoading(false); + }); + return () => { + cancelled = true; + }; + }, [step]); + useEffect(() => { void fetchPartnerCities() @@ -342,6 +373,15 @@ export default function StoreCreatePage() { async function submit(skipPackages = false) { + const qr = (csQrUrl ?? '').trim(); + if (!qr) { + reportFormError('客服二维码暂未配置,请联系总部'); + return; + } + if (!csAdded) { + reportFormError('请先勾选「我已添加【杜康好客】客服」'); + return; + } const msg = validateStoreStep3(form); @@ -537,6 +577,7 @@ export default function StoreCreatePage() { const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100; + const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading; const nextDisabled = submitting; @@ -1153,6 +1194,37 @@ export default function StoreCreatePage() { {step === 4 && ( <> +
+
+
+

添加客服

+
+

+ {csHint} +

+ {csConfigLoading ? ( +

加载客服二维码…

+ ) : csQrUrl ? ( + 杜康好客企微客服二维码 + ) : ( +

+ 客服二维码暂未配置,请联系总部 +

+ )} + +
@@ -1198,13 +1270,21 @@ export default function StoreCreatePage() { ) : ( <> - - diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx index 1fac980..4485023 100644 --- a/apps/h5-partner/src/pages/StoreListPage.tsx +++ b/apps/h5-partner/src/pages/StoreListPage.tsx @@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce import { canPartnerOpenStore, storeAuditLabel, - storeAuditPillClass, - storeStatusLabel, - storeStatusPillClass, + storeListBadge, type StoreStatusValue, } from '../lib/storeStatus'; import { usePartnerPageView } from '../lib/usePageView'; @@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED'; const FILTERS: { key: StatusFilter; label: string }[] = [ { key: 'ALL', label: '全部' }, { key: 'OPEN', label: '营业中' }, - { key: 'PAUSED', label: '暂时闭店' }, + { key: 'PAUSED', label: '临时闭店' }, { key: 'PENDING_AUDIT', label: '待审核' }, { key: 'REJECTED', label: '已驳回' }, - { key: 'CLOSED', label: '关闭' }, + { key: 'CLOSED', label: '永久闭店' }, ]; export default function StoreListPage() { @@ -46,7 +44,10 @@ export default function StoreListPage() { }, []); useEffect(() => { - if (!isLoggedIn()) { navigate('/login'); return; } + if (!isLoggedIn()) { + navigate('/login'); + return; + } void loadStores(); }, [navigate, loadStores]); @@ -54,20 +55,28 @@ export default function StoreListPage() { document.title = canMutate ? '门店管理' : '我的门店'; }, [canMutate]); - const filtered = useMemo(() => stores.filter((s) => { - const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); - const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); - const status = String(s.status).toUpperCase(); - let matchStatus = true; - if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING'; - else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED'; - else if (filter !== 'ALL') matchStatus = status === filter; - return matchQ && matchStatus; - }), [stores, q, filter]); + const filtered = useMemo( + () => + stores.filter((s) => { + const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); + const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); + const status = String(s.status).toUpperCase(); + let matchStatus = true; + if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING'; + else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED'; + else if (filter !== 'ALL') matchStatus = status === filter; + return matchQ && matchStatus; + }), + [stores, q, filter], + ); async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) { if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) { - setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门'); + setError( + auditStatus === 'REJECTED' + ? '门店审核未通过,请查看驳回原因并修改后重新提交' + : '门店尚在总部审核中,通过后方可开门', + ); return; } if (next === 'CLOSED') { @@ -82,7 +91,8 @@ export default function StoreListPage() { body: JSON.stringify({ status: next }), }); await loadStores(); - if (next === 'OPEN') toastSuccess('开店成功'); + if (next === 'OPEN') toastSuccess('已营业'); + if (next === 'PAUSED') toastSuccess('已临时闭店'); } catch { /* request 已 toast */ } finally { @@ -102,6 +112,7 @@ export default function StoreListPage() { body: JSON.stringify({ status: 'CLOSED' }), }); await loadStores(); + toastSuccess('已永久闭店'); } catch { /* request 已 toast */ } finally { @@ -111,29 +122,43 @@ export default function StoreListPage() { return ( - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}
search setQ(e.target.value)} />
-
- {FILTERS.map((f) => ( - - ))} +
+ +
{canCreate && ( - - - + + + )} {filtered.length === 0 &&
暂无门店
} @@ -145,87 +170,114 @@ export default function StoreListPage() { const dim = currentStatus === 'CLOSED'; const storeName = String(s.name || '未命名门店'); const busy = updatingId === storeId; - const canOpen = canPartnerOpenStore(auditStatus); + const badge = storeListBadge(s); + const switchOn = currentStatus === 'OPEN'; + const switchDisabled = + busy || + currentStatus === 'CLOSED' || + auditStatus === 'PENDING' || + auditStatus === 'REJECTED'; return (
- +
-
-

门店名称

+
+

+ 门店名称 +

{storeName}

-

{String(s.address || s.district || '')}

- {auditStatus !== 'APPROVED' && ( -

- {storeAuditLabel(auditStatus)} - {auditStatus === 'REJECTED' && s.rejectReason ? `:${String(s.rejectReason)}` : ''} +

+ {String(s.address || s.district || '')} +

+ {auditStatus === 'REJECTED' && s.rejectReason ? ( +

+ {storeAuditLabel(auditStatus)}:{String(s.rejectReason)}

- )} -
-
- {auditStatus !== 'APPROVED' ? ( - - {storeAuditLabel(auditStatus)} - - ) : ( - - {storeStatusLabel(currentStatus)} - - )} + ) : null}
+ + {badge.label} +
- {canMutate && ( -
- - - {currentStatus === 'PAUSED' && ( - + + ) : null} + - {canOpen ? '开门营业' : '待审核通过'} - - )} - - edit - -
- )} + + edit + + +
+ ) : null}
); })} {closeTarget && ( -
setCloseTarget(null)}> -
e.stopPropagation()}> -

确认关闭门店?

+
setCloseTarget(null)} + > +
e.stopPropagation()} + > +

+ 确认永久闭店? +

- 关闭后不可恢复营业,确认关闭该门店? + 永久闭店后不可再开门营业,确认关闭该门店?

diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 00136ed..b026ee8 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -1960,6 +1960,134 @@ nav.app-tabbar .app-tabbar-label { color: #fff; } +.partner-store-filter-row { + display: flex; + align-items: center; + gap: 10px; +} + +.partner-store-filter-label { + flex-shrink: 0; + font-family: var(--font-label); + font-size: 13px; + font-weight: 500; + color: var(--color-on-surface-variant); +} + +.partner-store-filter-select { + flex: 1; + height: 40px; + padding: 0 12px; + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + background: var(--color-surface-container-low); + font-size: 14px; + color: var(--color-ink-black); +} + +.partner-store-filter-select:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.2); +} + +.partner-store-card-main { + flex: 1; + min-width: 0; + padding-right: 8px; +} + +.partner-store-card-badge { + flex-shrink: 0; + white-space: nowrap; +} + +.partner-store-card-actions--v3416 { + align-items: center; + flex-wrap: wrap; +} + +.partner-store-card-actions--v3416 > .partner-store-switch { + flex: 1 1 auto; + min-width: 140px; +} + +.partner-store-card-actions--v3416 > .partner-store-close-btn { + flex: 0 0 auto; +} + +.partner-store-card-actions--v3416 > .partner-menu-icon { + flex: 0 0 auto; + margin-left: auto; +} + +.partner-store-switch { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-family: var(--font-label); + font-size: 12px; + color: var(--color-on-surface-variant); + user-select: none; +} + +.partner-store-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.partner-store-switch-track { + position: relative; + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--color-surface-container-highest); + transition: background 0.2s ease; + flex-shrink: 0; +} + +.partner-store-switch-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease; +} + +.partner-store-switch input:checked + .partner-store-switch-track { + background: var(--color-success-green); +} + +.partner-store-switch input:checked + .partner-store-switch-track::after { + transform: translateX(20px); +} + +.partner-store-switch--disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.partner-store-close-btn { + flex: 0 0 auto !important; + padding: 8px 12px !important; + border: 1px solid rgba(166, 29, 36, 0.35); + background: transparent; + color: var(--color-heritage-red); + font-weight: 500; +} + +.partner-store-close-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .partner-store-card { background: var(--color-card); border-radius: var(--radius-md); @@ -2061,6 +2189,39 @@ nav.app-tabbar .app-tabbar-label { margin: 0 var(--space-page) var(--space-md); } +.partner-onboard-cs-card { + text-align: center; +} + +.partner-onboard-cs-qr { + display: block; + width: min(220px, 70vw); + height: auto; + margin: 0 auto 16px; + border-radius: 8px; + background: #fff; + border: 1px solid var(--color-surface-container); +} + +.partner-onboard-cs-check { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + font-family: var(--font-label); + font-size: 14px; + color: var(--color-ink-black); + cursor: pointer; + text-align: left; +} + +.partner-onboard-cs-check input { + width: 18px; + height: 18px; + flex-shrink: 0; +} + /* ── Store detail ── */ .partner-detail-page { padding-bottom: 96px; diff --git a/apps/h5-partner/vite.config.ts b/apps/h5-partner/vite.config.ts index ef72a3e..2cf42af 100644 --- a/apps/h5-partner/vite.config.ts +++ b/apps/h5-partner/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ }, }, server: { + host: true, port: 5175, proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010', diff --git a/apps/h5-shop/vite.config.ts b/apps/h5-shop/vite.config.ts index 803f41b..decaef4 100644 --- a/apps/h5-shop/vite.config.ts +++ b/apps/h5-shop/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ }, }, server: { + host: true, port: 5174, proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' }, }, diff --git a/apps/h5-user/vite.config.ts b/apps/h5-user/vite.config.ts index 8fa7010..1afb2c0 100644 --- a/apps/h5-user/vite.config.ts +++ b/apps/h5-user/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ }, }, server: { + host: true, proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010', }, diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index a966b67..f326b2f 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -108,6 +108,13 @@ function formatRedeemTime(input?: string | null) { return formatShanghaiDateTime(input); } +function formatPackagePriceYuan(price: string | number) { + const n = typeof price === 'number' ? price : Number(price); + if (!Number.isFinite(n)) return '0'; + if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)); + return n.toFixed(2).replace(/\.?0+$/, ''); +} + function formatRedeemAmountYuan(amount: number | string) { const n = typeof amount === 'number' ? amount : Number(amount); if (!Number.isFinite(n)) return '0'; @@ -419,7 +426,12 @@ export default function StoreDetailPage() { className="store-detail-package-list-item" onClick={() => openPackageDetail(index)} > - {pkg.name} + + {pkg.name} + +     ¥{formatPackagePriceYuan(pkg.price)} + + ))} diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index fef3d3a..327c4fe 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -281,13 +281,36 @@ padding-bottom: 0; } -.store-detail-package-list-title { +/* 名称在左可换行;价格始终贴该行最右侧 */ +.store-detail-package-list-row { + /* display: flex; + flex-wrap: wrap; + align-items: flex-start; + column-gap: 12px; + row-gap: 4px; */ display: block; + width: 100%; +} + +.store-detail-package-list-title { + min-width: 0; + font-family: var(--font-headline); font-size: 15px; line-height: 1.5; font-weight: 500; color: var(--color-on-surface); word-break: break-word; + display: inline; +} + +.store-detail-package-list-price { + display: inline; + margin-left: auto; + font-size: 15px; + line-height: 1.5; + font-weight: 600; + color: var(--color-heritage-red, #a61d24); + text-align: right; } .store-detail-package-list-item:active { @@ -315,15 +338,18 @@ padding: 8px 16px 4px 16px; } +/* 与门店详情套餐列表一致:名称在左可换行;价格始终贴该行最右侧 */ .store-package-detail-title-row { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; + flex-wrap: wrap; + align-items: flex-end; + column-gap: 16px; + row-gap: 4px; + width: 100%; } .store-package-detail-title { - flex: 1; + /* flex: 1 1 10em; */ min-width: 0; font-family: var(--font-headline); font-size: 20px; @@ -334,13 +360,14 @@ } .store-package-detail-price { - flex-shrink: 0; - font-size: 24px; + /* flex: 0 0 auto; */ + margin-left: auto; + font-size: 20px; font-weight: 700; - line-height: 1.35; - color: #a61d24; + line-height: 1.4; color: var(--color-heritage-red, #a61d24); text-align: right; + white-space: nowrap; } .store-package-detail-store { diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 3acd4fc..c3f6953 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -1,4 +1,4 @@ -/** HQ 权限目录(权限分配页勾选源) */ +/** HQ 权限目录(权限分配页勾选源) */ export const HQ_PERMISSION_CATALOG = [ { key: 'dashboard', label: '概览', group: '业务' }, { key: 'users', label: '用户管理', group: '业务' }, @@ -30,6 +30,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'system_settings_sms', label: '短信', group: '系统设置' }, { key: 'system_settings_wechat', label: '微信', group: '系统设置' }, { key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' }, + { key: 'system_settings_wechat_mini_share', label: '小程序分享配置', group: '系统设置' }, { key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' }, { key: 'system_settings_app', label: '应用链接', group: '系统设置' }, { key: 'system_settings_deploy', label: '发布部署', group: '系统设置' }, @@ -60,6 +61,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record = { sms: 'system_settings_sms', wechat: 'system_settings_wechat', wechat_mini: 'system_settings_wechat_mini', + wechat_mini_share: 'system_settings_wechat_mini_share', oss: 'system_settings_oss', app: 'system_settings_app', deploy: 'system_settings_deploy', @@ -121,6 +123,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'resources', 'logs', 'system_settings_wechat_mini', + 'system_settings_wechat_mini_share', ], FINANCE: [ 'dashboard', diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 886acff..1f21e61 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -64,6 +64,10 @@ export type ClientRuntimeConfig = { qualificationDisclosureUrl?: string; /** 总部客服电话 */ customerServicePhone?: string; + /** 合伙人入驻:企微客服二维码图片 URL */ + partnerOnboardCsQrUrl?: string | null; + /** 合伙人入驻:企微客服提示文案 */ + partnerOnboardCsHint?: string | null; /** 小程序各场景分享文案/图 */ share?: MiniShareRuntime; }; diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index 6bd424b..6cb68a7 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -213,6 +213,23 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ placeholder: '13203801799', description: 'C 端联系客服拨号号码', }, + { + key: 'PARTNER_ONBOARD_CS_QR_URL', + label: '合伙人入驻 · 企微客服二维码', + group: G.wechat_mini, + type: 'image', + requiresRestart: false, + description: '合伙人 H5 录入门店提交前展示;未配置时禁止提交入驻', + }, + { + key: 'PARTNER_ONBOARD_CS_HINT', + label: '合伙人入驻 · 客服提示文案', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: '使用问题、提现问题等随时可联系【杜康好客】客服', + description: '二维码下方说明;留空用默认文案', + }, { key: 'MOCK_SMS_FIXED_CODE', label: 'Mock 短信固定验证码', diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index 3828a4b..5970bbb 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -86,12 +86,13 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService))); app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor)); - const port = process.env.PORT || 3010; + const port = Number(process.env.PORT || 3010); + const host = process.env.HOST || '0.0.0.0'; const cfg = loadAppConfig(); const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN'; console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`); - await app.listen(port); - console.log(`dukang-api listening on http://localhost:${port}/api/v1`); + await app.listen(port, host); + console.log(`dukang-api listening on http://${host}:${port}/api/v1`); } bootstrap(); diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 84c56b5..a51c5d1 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -37,6 +37,10 @@ export class ClientConfigController { brandLogoMarkUrl: brand.brandLogoMarkUrl, qualificationDisclosureUrl: brand.qualificationDisclosureUrl, customerServicePhone: brand.customerServicePhone, + partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null, + partnerOnboardCsHint: + (env.PARTNER_ONBOARD_CS_HINT ?? '').trim() || + '使用问题、提现问题等随时可联系【杜康好客】客服', share, }; } diff --git a/server/dukang-api/src/modules/common/client-error.service.ts b/server/dukang-api/src/modules/common/client-error.service.ts index 9e0a31f..2e7a814 100644 --- a/server/dukang-api/src/modules/common/client-error.service.ts +++ b/server/dukang-api/src/modules/common/client-error.service.ts @@ -5,7 +5,6 @@ import { AlertService } from '../../common/alert/alert.service'; import type { AlertLevel } from '../../common/alert/alert.constants'; import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import type { ReportClientErrorDto } from './dto/client-error.dto'; -import { SupportTicketService } from './support-ticket.service'; const WECOM_LEVELS = new Set(['fatal', 'error']); @@ -16,7 +15,6 @@ export class ClientErrorService { constructor( private readonly prisma: PrismaService, private readonly alert: AlertService, - private readonly supportTicket: SupportTicketService, ) {} async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) { @@ -109,25 +107,6 @@ export class ClientErrorService { ); } - if (dto.category === 'validation_error') { - const apiPath = - dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined; - void this.supportTicket - .createFromClientValidation({ - clientApp, - message, - pagePath, - apiPath, - actorLabel: - user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined, - }) - .catch((e) => { - this.logger.warn( - `auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`, - ); - }); - } - return { ok: true }; } } diff --git a/server/dukang-api/src/modules/common/support-ticket.service.ts b/server/dukang-api/src/modules/common/support-ticket.service.ts index fce12a4..dcd735e 100644 --- a/server/dukang-api/src/modules/common/support-ticket.service.ts +++ b/server/dukang-api/src/modules/common/support-ticket.service.ts @@ -38,17 +38,8 @@ function generateSupportTicketNo() { return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } -const AUTO_VALIDATION_TICKET_APPS = new Set([ - 'USER_H5', - 'USER_MINI', - 'SHOP_H5', - 'PARTNER_H5', -]); - @Injectable() export class SupportTicketService { - private systemCreatorCache: { id: bigint; name: string } | null = null; - constructor( private readonly prisma: PrismaService, private readonly alert: AlertService, @@ -105,66 +96,6 @@ export class SupportTicketService { return serializeBigInt(mapSupportTicketRow(ticket)); } - /** 客户端 400 验证错误自动建单(1 小时内同标题去重) */ - async createFromClientValidation(input: { - clientApp: string; - message: string; - pagePath?: string; - apiPath?: string; - actorLabel?: string; - }) { - if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) { - return { skipped: true as const, reason: 'unsupported_app' as const }; - } - - const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`; - const oneHourAgo = new Date(Date.now() - 3600_000); - const existing = await this.prisma.commonSupportTicket.findFirst({ - where: { title, createdAt: { gte: oneHourAgo } }, - select: { id: true }, - }); - if (existing) { - return { skipped: true as const, ticketId: existing.id.toString() }; - } - - const creator = await this.resolveSystemCreator(); - const content = [ - `端:${input.clientApp}`, - input.pagePath ? `页面:${input.pagePath}` : null, - input.apiPath ? `接口:${input.apiPath}` : null, - input.actorLabel ? `用户:${input.actorLabel}` : null, - '', - input.message, - ] - .filter(Boolean) - .join('\n'); - - const ticket = await this.create( - { - ticketType: 'BUG', - title, - content, - remark: '客户端验证错误自动上报', - }, - creator, - ); - return { skipped: false as const, ticketId: String(ticket.id) }; - } - - private async resolveSystemCreator() { - if (this.systemCreatorCache) return this.systemCreatorCache; - const account = await this.prisma.hqAccount.findFirst({ - where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' }, - orderBy: { id: 'asc' }, - select: { id: true }, - }); - if (!account) { - throw new BadRequestException('未找到系统管理员账号,无法自动创建工单'); - } - this.systemCreatorCache = { id: account.id, name: '系统自动' }; - return this.systemCreatorCache; - } - async update(id: bigint, dto: UpdateSupportTicketDto) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'PENDING_REVIEW') { diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 2f72baa..48b45fd 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -21,7 +21,7 @@ | 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) | | 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | | 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | -| 3.4.16 | 08-09 | 门店登录手机号与对外联系电话分离 | 见 §3.12 | +| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) | --- @@ -94,9 +94,9 @@ | 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 | | 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 | | 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 | -| 3.12 | **登录手机号 ≠ 对外联系电话** | 见下 | +| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 | -### 3.12 门店登录号与对外联系电话 +### 3.12 门店登录号与体验增量(v3.4.16) | 字段 | 含义 | 用途 | |------|------|------| @@ -105,6 +105,10 @@ - 合伙人拓店入驻、门店资料修改均须采集 **联系电话**(可与登录号不同)。 - 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。 +- **不**再因客户端 `validation_error` 自动创建技术支持工单。 +- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。 +- HQ 门店列表:长店名截断、操作列右固定、表过宽可横向滚动。 +- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)。 --- diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index 0708380..44f9fcf 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -9,7 +9,7 @@ |------|------| | 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** | | C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 | -| 近期版本 | … · v3.4.15 门店列表/套餐多图 · **v3.4.16 登录号/联系电话分离(开发中)** | +| 近期版本 | … · v3.4.15 · **v3.4.16 联系电话/套餐 UI/合伙人列表与入驻客服门槛/HQ 门店列表表格** | | 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC | | REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` | diff --git a/杜康好客-v3.4.16-门店联系电话与体验优化.md b/杜康好客-v3.4.16-门店联系电话与体验优化.md new file mode 100644 index 0000000..b8afbab --- /dev/null +++ b/杜康好客-v3.4.16-门店联系电话与体验优化.md @@ -0,0 +1,83 @@ +# 杜康好客 · v3.4.16 门店联系电话与体验优化 + +> **2026-08-09** · PRD §3.12 · mini-user / h5-partner / admin-web / API + +## 范围 + +| 项 | 交付 | +|----|------| +| A. 登录号 / 联系电话分离 | `Store.contactPhone`;合伙人入驻/编辑;HQ;C 端 `publicDial` | +| B. 取消自动技术支持工单 | `validation_error` 不再自动建 ST;上报与人工 ST 保留 | +| C. 套餐详情排版 | 套餐名 + 价格:一行能放下则同行,否则名称换行、价格次行右对齐 | +| D. 门店详情套餐列表 | 套餐名称后展示价格 | +| E. 合伙人门店列表 | 右上角状态;开关营业/临时闭店;永久闭店按钮;下拉筛选 | +| F. 入驻企微客服门槛 | HQ 可配二维码;勾选「已添加客服」后才可提交 | +| G. HQ 门店列表表格 | 店名过长截断;操作列右侧固定;横向滚动 | + +## A. 登录号 ≠ 对外联系电话 + +| 字段 | 含义 | 用途 | +|------|------|------| +| `phone` | 老板主账号 | 门店端登录;`StoreAccount.phone` | +| `contactPhone` | 店长/对外 | C 端拨号与展示 | + +- C 端 `GET /stores`、`GET /stores/:id`:`phone` = `contactPhone ?? phone`(兼容旧小程序) +- 未填联系电话时对外回退登录号 + +## B. 取消 validation_error 自动 ST + +- 原路径:`POST /common/client-errors` + `category=validation_error` → `createFromClientValidation` +- **删除**自动建单;保留 client-error 落库 / WeCom 告警;HQ 手动建单与企微「创建」不变 + +## C / D. mini-user 套餐 UI + +- 套餐详情标题与门店详情「门店套餐」列表一致:名称在左可换行,价格始终居右;放不下时价格落在次行右侧 + +## E. 合伙人门店列表 + +| UI | 行为 | +|----|------| +| 右上角徽标 | 待审核 / 审核驳回 / 营业中 / 临时闭店 / 永久闭店 | +| 开关 | `OPEN` ↔ `PAUSED`;CLOSED / PENDING 禁用 | +| 永久闭店 | 独立按钮 + 确认 → `CLOSED` | +| 筛选 | 下拉:全部 / 营业中 / 临时闭店 / 待审核 / 已驳回 / 永久闭店 | + +API:`PUT /partner/stores/:id/status`(不变) + +## F. 入驻企微客服二维码门槛 + +| 配置键 | 说明 | +|--------|------| +| `PARTNER_ONBOARD_CS_QR_URL` | 企微客服二维码图片 URL(OSS) | +| `PARTNER_ONBOARD_CS_HINT` | 提示文案(默认见下) | + +默认提示:`使用问题、提现问题等随时可联系【杜康好客】客服` + +- `GET /common/client-config` 对 PARTNER 下发 `partnerOnboardCsQrUrl` / `partnerOnboardCsHint` +- HQ **系统设置 → 微信小程序配置**(与「总部客服电话」同组):可上传二维码、编辑提示文案 +- 入驻信息填完后展示二维码;须勾选「我已添加【杜康好客】客服」才可提交/跳过 +- **未配置二维码**:禁止提交,提示联系总部 + +## G. HQ 门店列表页表格体验 + +[`apps/admin-web` 门店列表](apps/admin-web/src/pages/StoresPage.tsx): + +| 点 | 行为 | +|----|------| +| 门店名 | 过长省略号截断,悬停可看全名;「测试」标签保留 | +| 操作列 | `fixed: 'right'`,横向滚动时详情/评价始终可见 | +| 整表过宽 | `scroll.x` 开启横向滚动条(列宽合计约 1720) | + +合伙人名、介绍、分类等长文本列同样启用 ellipsis,避免撑破布局。 + +## ACC + +- [ ] 旧门店对外拨号仍可用(回退登录号或已回填 contactPhone) +- [ ] 合伙人可分别维护登录号与联系电话 +- [ ] API 400 不再产生「客户端验证错误自动上报」ST +- [ ] 套餐详情短/长标题排版符合定案 +- [ ] 门店详情套餐列表可见价格 +- [ ] 合伙人列表状态/开关/永久闭店/下拉筛选可用 +- [ ] 无二维码不可提交;有二维码须勾选后提交 +- [ ] HQ 可上传/更换入驻客服二维码 +- [ ] HQ 门店列表:长店名截断、操作栏右固定、可横向滚动