门店审核功能
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user