feat(v3.4.16): partner UX, package pricing, CS gate, HQ list polish

Stop auto ST from validation_error; show package prices with right-aligned layout; partner store list status/filter and onboard CS QR gate; HQ store table truncation/fixed actions; expose CS config in wechat_mini settings; bind local servers for LAN.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 16:00:45 +08:00
parent 07630c9046
commit 46361ec713
21 changed files with 637 additions and 221 deletions
+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>