merge(dev): v3.4.16 partner UX and CS gate
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-09 16:01:04 +08:00
21 changed files with 637 additions and 221 deletions
+53 -15
View File
@@ -669,18 +669,30 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
) },
{
title: '门店名',
dataIndex: 'name',
width: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => {
const name = v || '—';
return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
);
},
},
{
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 (
<Space direction="vertical" size={0}>
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
{row.rejectReason}
</Typography.Text>
) : 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 (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
},
},
{
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) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
@@ -800,8 +822,24 @@ export default function StoresPage() {
</Button>
</Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1720 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space wrap>
+1
View File
@@ -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 },
},
+18 -2
View File
@@ -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';
+84 -4
View File
@@ -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<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
const [csAdded, setCsAdded] = useState(false);
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
const [csConfigLoading, setCsConfigLoading] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]);
@@ -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 && (
<>
<section className="partner-form-card partner-onboard-cs-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
{csHint}
</p>
{csConfigLoading ? (
<p className="body-md text-muted"></p>
) : csQrUrl ? (
<img
className="partner-onboard-cs-qr"
src={csQrUrl}
alt="杜康好客企微客服二维码"
/>
) : (
<p className="partner-form-error" role="alert">
</p>
)}
<label className="partner-onboard-cs-check">
<input
type="checkbox"
checked={csAdded}
disabled={!csQrUrl || submitting}
onChange={(e) => setCsAdded(e.target.checked)}
/>
<span></span>
</label>
</section>
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
@@ -1198,13 +1270,21 @@ export default function StoreCreatePage() {
) : (
<>
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
<button
type="button"
className="partner-btn-outline"
onClick={() => void submit(true)}
disabled={!canSubmitOnboard}
>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
<button
type="button"
className="partner-btn-primary"
onClick={() => void submit(false)}
disabled={!canSubmitOnboard}
>
{submitting ? '提交中…' : '提交'}
</button>
</>
+144 -92
View File
@@ -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 (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
{error && (
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
{error}
</p>
)}
<div className="partner-sticky-filter">
<div className="partner-search">
<span className="material-symbols-outlined">search</span>
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="partner-chips">
{FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
{f.label}
</button>
))}
<div className="partner-store-filter-row">
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
</label>
<select
id="partner-store-status-filter"
className="partner-store-filter-select"
value={filter}
onChange={(e) => setFilter(e.target.value as StatusFilter)}
>
{FILTERS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div>
</div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
)}
{filtered.length === 0 && <div className="empty"></div>}
@@ -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 (
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
<Link
to={`/stores/${storeId}`}
className="partner-store-card-hit"
style={{ color: 'inherit', textDecoration: 'none' }}
>
<div className="partner-store-card-header">
<div>
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p>
<div className="partner-store-card-main">
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
</p>
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
{auditStatus !== 'APPROVED' && (
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
{storeAuditLabel(auditStatus)}
{auditStatus === 'REJECTED' && s.rejectReason ? `${String(s.rejectReason)}` : ''}
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{String(s.address || s.district || '')}
</p>
{auditStatus === 'REJECTED' && s.rejectReason ? (
<p
className="label-md"
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
>
{storeAuditLabel(auditStatus)}{String(s.rejectReason)}
</p>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{auditStatus !== 'APPROVED' ? (
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
</span>
) : (
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
)}
) : null}
</div>
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
{badge.label}
</span>
</div>
</Link>
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
>
</button>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
disabled={busy || currentStatus === 'CLOSED'}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
{currentStatus === 'PAUSED' && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || !canOpen}
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
{canMutate ? (
<div className="partner-store-card-actions partner-store-card-actions--v3416">
{currentStatus !== 'CLOSED' ? (
<>
<label
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
>
<input
type="checkbox"
role="switch"
checked={switchOn}
disabled={switchDisabled}
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
onChange={() => {
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
void updateStatus(storeId, next, auditStatus);
}}
/>
<span className="partner-store-switch-track" aria-hidden />
<span className="partner-store-switch-text">
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
</span>
</label>
<button
type="button"
className="partner-store-close-btn"
disabled={busy}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
</>
) : null}
<Link
to={`/stores/${storeId}`}
className="partner-menu-icon"
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
>
{canOpen ? '开门营业' : '待审核通过'}
</button>
)}
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
</Link>
</div>
)}
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
edit
</span>
</Link>
</div>
) : null}
</div>
);
})}
{closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div
className="partner-ship-modal-backdrop"
role="presentation"
onClick={() => setCloseTarget(null)}
>
<div
className="partner-ship-modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h3 className="headline-md" style={{ marginBottom: 8 }}>
</h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
+161
View File
@@ -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;
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
},
server: {
host: true,
port: 5175,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
},
server: {
host: true,
port: 5174,
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
},
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
},
server: {
host: true,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
},
@@ -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)}
>
<Text className="store-detail-package-list-title">{pkg.name}</Text>
<View className="store-detail-package-list-row">
<Text className="store-detail-package-list-title">{pkg.name}</Text>
<Text className="store-detail-package-list-price">
&nbsp;&nbsp;&nbsp;&nbsp;¥{formatPackagePriceYuan(pkg.price)}
</Text>
</View>
</View>
))}
</View>
+36 -9
View File
@@ -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 {
+4 -1
View File
@@ -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<string, HqPermissionKey> = {
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<string, HqPermissionKey[]> = {
'resources',
'logs',
'system_settings_wechat_mini',
'system_settings_wechat_mini_share',
],
FINANCE: [
'dashboard',
+4
View File
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
qualificationDisclosureUrl?: string;
/** 总部客服电话 */
customerServicePhone?: string;
/** 合伙人入驻:企微客服二维码图片 URL */
partnerOnboardCsQrUrl?: string | null;
/** 合伙人入驻:企微客服提示文案 */
partnerOnboardCsHint?: string | null;
/** 小程序各场景分享文案/图 */
share?: MiniShareRuntime;
};
@@ -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 短信固定验证码',
+4 -3
View File
@@ -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();
@@ -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,
};
}
@@ -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 };
}
}
@@ -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') {
+7 -3
View File
@@ -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)。
---
+1 -1
View File
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-userv3.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` |
@@ -0,0 +1,83 @@
# 杜康好客 · v3.4.16 门店联系电话与体验优化
> **2026-08-09** · PRD §3.12 · mini-user / h5-partner / admin-web / API
## 范围
| 项 | 交付 |
|----|------|
| A. 登录号 / 联系电话分离 | `Store.contactPhone`;合伙人入驻/编辑;HQC 端 `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 门店列表:长店名截断、操作栏右固定、可横向滚动