门店审核功能

This commit is contained in:
2026-07-14 15:25:17 +08:00
parent d61a43cc8c
commit e0f5977dd9
13 changed files with 479 additions and 40 deletions
+21
View File
@@ -1,3 +1,20 @@
export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string;
export function storeAuditLabel(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return '待总部审核';
if (s === 'REJECTED') return '审核驳回';
if (s === 'APPROVED') return '审核通过';
return auditStatus || '—';
}
export function storeAuditPillClass(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return 'partner-status-pill--paused';
if (s === 'REJECTED') return 'partner-status-pill--closed';
return 'partner-status-pill--open';
}
export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string {
@@ -14,3 +31,7 @@ export function storeStatusPillClass(status: string): string {
if (s === 'PAUSED') return 'partner-status-pill--paused';
return 'partner-status-pill--closed';
}
export function canPartnerOpenStore(auditStatus?: string | null): boolean {
return String(auditStatus || '').toUpperCase() === 'APPROVED';
}
+42 -4
View File
@@ -16,6 +16,7 @@ export default function HomePage() {
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
const [notifOpen, setNotifOpen] = useState(false);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
@@ -29,6 +30,10 @@ export default function HomePage() {
const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const notifications = Array.isArray(dash?.notifications)
? (dash.notifications as Array<Record<string, unknown>>)
: [];
const hasUnread = notifications.length > 0;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const revenue = orderCount * 128.45;
@@ -49,9 +54,14 @@ export default function HomePage() {
<header className="partner-home-header">
<h1 className="app-page-title"></h1>
<div className="partner-home-header-actions">
<button type="button" className="partner-notif-btn" aria-label="通知">
<button
type="button"
className="partner-notif-btn"
aria-label="通知"
onClick={() => setNotifOpen((v) => !v)}
>
<span className="material-symbols-outlined">notifications</span>
<span className="partner-notif-dot" />
{hasUnread && <span className="partner-notif-dot" />}
</button>
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
@@ -59,6 +69,33 @@ export default function HomePage() {
</div>
</header>
{notifOpen && (
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 className="headline-md"></h2>
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}></button>
</div>
{notifications.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
notifications.slice(0, 8).map((n) => (
<Link
key={String(n.id)}
to={`/stores/${n.storeId}`}
className="partner-home-store-row"
style={{ marginBottom: 8 }}
onClick={() => setNotifOpen(false)}
>
<div className="partner-home-store-info">
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
<p className="label-md text-muted">{String(n.content || '')}</p>
</div>
</Link>
))
)}
</section>
)}
<main className="partner-home-body">
<section className="partner-revenue-card">
<p className="partner-revenue-label">
@@ -172,14 +209,15 @@ export default function HomePage() {
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div>
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
{storeStatusLabel(status)}
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link>
);
+68 -8
View File
@@ -6,7 +6,14 @@ import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
@@ -48,6 +55,15 @@ export default function StoreDetailPage() {
async function changeStatus(next: StoreStatusValue) {
if (!id || next === status || statusSaving) return;
if (status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setActionError(
auditStatus === 'REJECTED'
? `门店审核未通过${store?.rejectReason ? `${String(store.rejectReason)}` : ''}`
: '门店尚在总部审核中,通过后方可开门',
);
return;
}
if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
if (!ok) return;
@@ -71,6 +87,11 @@ export default function StoreDetailPage() {
async function saveBasic() {
if (!id || saving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
setSaving(true);
setActionError('');
try {
@@ -84,7 +105,7 @@ export default function StoreDetailPage() {
}),
});
applyStore(data);
toastSuccess('已保存');
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
} catch (e) {
setActionError(e instanceof Error ? e.message : '保存失败');
} finally {
@@ -106,7 +127,11 @@ export default function StoreDetailPage() {
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
const readOnly = subReadonly || status === 'CLOSED';
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus);
return (
<div className="partner-detail-page">
@@ -114,6 +139,37 @@ export default function StoreDetailPage() {
<main style={{ padding: '16px 20px' }}>
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
</span>
</div>
{auditPending && (
<p className="body-md" style={{ color: 'var(--color-secondary)' }}>
</p>
)}
{auditRejected && (
<div>
<p className="body-md" style={{ color: 'var(--color-heritage-red)', fontWeight: 600, marginBottom: 8 }}>
</p>
<p className="body-md" style={{ background: 'rgba(180,35,24,0.06)', padding: 12, borderRadius: 8 }}>
{String(store.rejectReason || '未填写驳回原因')}
</p>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
)}
{auditStatus === 'APPROVED' && (
<p className="label-md text-muted"></p>
)}
</section>
{!subReadonly && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
@@ -129,10 +185,10 @@ export default function StoreDetailPage() {
key={s}
type="button"
className={status === s ? 'active' : ''}
disabled={readOnly || statusSaving}
disabled={readOnly || statusSaving || (s === 'OPEN' && !canOpen) || status === 'CLOSED'}
onClick={() => void changeStatus(s)}
>
{storeStatusLabel(s)}
{s === 'OPEN' && !canOpen ? '待审核' : storeStatusLabel(s)}
</button>
))}
</div>
@@ -195,10 +251,14 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div>
<label className="label-md text-muted"></label>
<div style={{ marginBottom: 12 }}>
<label className="label-md text-muted"></label>
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
</div>
<div>
<label className="label-md text-muted"></label>
<p className="body-md" style={{ fontWeight: 500 }}>{storeAuditLabel(auditStatus)}</p>
</div>
</section>
</main>
@@ -207,7 +267,7 @@ export default function StoreDetailPage() {
{!subReadonly && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : '保存修改'}
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
</button>
)}
</footer>
+47 -13
View File
@@ -3,14 +3,23 @@ import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
type StatusFilter = 'ALL' | StoreStatusValue;
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '暂时闭店' },
{ key: 'PENDING_AUDIT', label: '待审核' },
{ key: 'REJECTED', label: '已驳回' },
{ key: 'CLOSED', label: '关闭' },
];
@@ -35,11 +44,20 @@ export default function StoreListPage() {
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const matchStatus = filter === 'ALL' || String(s.status).toUpperCase() === filter;
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) {
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
return;
}
if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
if (!ok) return;
@@ -98,9 +116,11 @@ export default function StoreListPage() {
{filtered.map((s) => {
const storeId = String(s.id);
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
const auditStatus = String(s.auditStatus || 'APPROVED').toUpperCase();
const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店');
const busy = updatingId === storeId;
const canOpen = canPartnerOpenStore(auditStatus);
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' }}>
@@ -109,10 +129,24 @@ export default function StoreListPage() {
<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>
)}
</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>
)}
</div>
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
</div>
</Link>
{!readonly && (
@@ -121,8 +155,8 @@ export default function StoreListPage() {
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED'}
onClick={() => void updateStatus(storeId, 'PAUSED')}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
>
</button>
@@ -131,7 +165,7 @@ export default function StoreListPage() {
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')}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
@@ -140,10 +174,10 @@ export default function StoreListPage() {
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy}
onClick={() => void updateStatus(storeId, 'OPEN')}
disabled={busy || !canOpen}
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
>
{canOpen ? '开门营业' : '待审核通过'}
</button>
)}
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>