门店审核功能

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
+6
View File
@@ -38,6 +38,12 @@ export const STORE_STATUS_LABELS: Record<string, string> = {
CLOSED: '已关闭',
};
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
ACTIVE: '正常',
DISABLED: '停用',
+125 -4
View File
@@ -20,7 +20,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import {
validateStoreCreateStep1,
validateStoreCreateStep3,
@@ -42,6 +42,8 @@ type StoreRow = {
name: string;
phone: string;
status: string;
auditStatus?: string;
rejectReason?: string | null;
cityName: string;
district: string;
address: string;
@@ -73,6 +75,7 @@ export default function StoresPage() {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
if (filters.phone) qs.set('phone', filters.phone);
return qs;
},
@@ -80,6 +83,9 @@ export default function StoresPage() {
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState('');
@@ -228,9 +234,26 @@ export default function StoresPage() {
{ title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{
title: '状态', dataIndex: 'status', width: 90,
title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '审核', dataIndex: 'auditStatus', width: 100,
render: (s, row) => {
const status = s || 'APPROVED';
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
return (
<Space direction="vertical" size={0}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
);
},
},
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
@@ -268,9 +291,17 @@ export default function StoresPage() {
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Form.Item name="status" label="营业状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="auditStatus" label="审核">
<Select
allowClear
style={{ width: 110 }}
placeholder="全部"
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button></Form.Item>
</Form>
@@ -278,7 +309,41 @@ export default function StoresPage() {
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space>
<Space wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
<>
<Button
type="primary"
loading={auditing}
onClick={async () => {
setAuditing(true);
try {
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
method: 'PUT',
body: JSON.stringify({ approved: true, remark: '审核通过' }),
});
message.success('已通过审核,合伙人可开门营业');
setDetail({ ...detail, ...updated, auditStatus: 'APPROVED', rejectReason: null });
void reload();
} finally {
setAuditing(false);
}
}}
>
</Button>
<Button
danger
loading={auditing}
onClick={() => {
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : null}
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
@@ -304,6 +369,17 @@ export default function StoresPage() {
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="审核状态">
<Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange'
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
}>
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
</Tag>
</Descriptions.Item>
{String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
) : null}
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销结算比例">
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
@@ -319,6 +395,17 @@ export default function StoresPage() {
<Image src={String(detail.coverUrl)} width={120} />
</Descriptions.Item>
) : null}
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
</Typography.Text>
))}
</Space>
</Descriptions.Item>
) : null}
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
@@ -449,6 +536,40 @@ export default function StoresPage() {
</div>
</Form>
</Modal>
<Modal
title="驳回门店审核"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: auditing, disabled: !rejectReason.trim() }}
onCancel={() => setRejectOpen(false)}
onOk={async () => {
if (!detail || !rejectReason.trim()) return;
setAuditing(true);
try {
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
method: 'PUT',
body: JSON.stringify({ approved: false, remark: rejectReason.trim() }),
});
message.success('已驳回,原因已同步合伙人端');
setDetail({ ...detail, ...updated, auditStatus: 'REJECTED', rejectReason: rejectReason.trim() });
setRejectOpen(false);
void reload();
} finally {
setAuditing(false);
}
}}
>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<Input.TextArea
rows={4}
maxLength={200}
showCount
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
+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>
+44 -10
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>
</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' }}>
+12
View File
@@ -105,6 +105,18 @@ export enum StoreStatus {
CLOSED = 'CLOSED',
}
export enum StoreAuditStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
export const STORE_AUDIT_STATUS_LABELS: Record<StoreAuditStatus, string> = {
[StoreAuditStatus.PENDING]: '待审核',
[StoreAuditStatus.APPROVED]: '已通过',
[StoreAuditStatus.REJECTED]: '已驳回',
};
export enum PartnerStaffRole {
PARTNER = 'PARTNER',
INTERNAL = 'INTERNAL',
+6 -1
View File
@@ -18,7 +18,12 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
'partner_staff_delete',
'partner_staff_permission_update',
],
store_ops: ['partner_store_create', 'partner_store_status_change'],
store_ops: [
'partner_store_create',
'partner_store_status_change',
'partner_store_audit_approved',
'partner_store_audit_rejected',
],
shipping: ['partner_order_ship', 'partner_delivery_advance'],
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
+2
View File
@@ -22,6 +22,8 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 开启总部人工审核时改为 false:合伙人录店 → 待审核 → HQ 通过后可开门;驳回须填原因并在合伙人端展示
# AUTO_APPROVE_STORE=false
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 并配置 WX_APP_ID / WX_APP_SECRET。
MOCK_WECHAT=true
+10
View File
@@ -176,6 +176,12 @@ enum StoreStatus {
CLOSED
}
enum StoreAuditStatus {
PENDING
APPROVED
REJECTED
}
enum UserSourceType {
ORGANIC
PROMO_CODE
@@ -735,6 +741,9 @@ model Store {
rating Decimal? @db.Decimal(3, 2)
tags Json?
status StoreStatus @default(PAUSED)
auditStatus StoreAuditStatus @default(APPROVED) @map("audit_status")
rejectReason String? @map("reject_reason") @db.VarChar(512)
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
openTime String? @map("open_time") @db.VarChar(8)
closeTime String? @map("close_time") @db.VarChar(8)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
@@ -753,6 +762,7 @@ model Store {
@@index([cityId, status])
@@index([partnerAccountId])
@@index([auditStatus, createdAt])
@@map("store_store")
}
@@ -28,6 +28,9 @@ export class AdminStoresService {
const where: Prisma.StoreWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.auditStatus) {
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
@@ -57,6 +60,7 @@ export class AdminStoresService {
items: items.map((s) =>
mapStoreCompat({
...s,
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
}),
@@ -118,11 +122,33 @@ export class AdminStoresService {
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
const store = await this.prisma.store.findUnique({ where: { id } });
if (!store) throw new NotFoundException('门店不存在');
const status = dto.approved ? 'OPEN' : 'PAUSED';
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
// 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核
}
if (!dto.approved) {
const reason = dto.remark?.trim();
if (!reason) throw new BadRequestException('驳回时必须填写原因');
}
const now = new Date();
const updated = await this.prisma.store.update({
where: { id },
data: { status },
data: dto.approved
? {
// 审核通过后保持闭店,由合伙人自行开门
status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED',
auditStatus: 'APPROVED',
rejectReason: null,
auditedAt: now,
}
: {
status: 'PAUSED',
auditStatus: 'REJECTED',
rejectReason: dto.remark!.trim(),
auditedAt: now,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
@@ -130,10 +156,20 @@ export class AdminStoresService {
refId: id,
actorType: 'HQ',
status: dto.approved ? 'APPROVED' : 'REJECTED',
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
remark: dto.approved
? (dto.remark?.trim() || '审核通过,可开门营业')
: dto.remark!.trim(),
param1: dto.approved ? 'APPROVE' : 'REJECT',
param1Desc: 'audit_action',
},
});
return serializeBigInt(updated);
return serializeBigInt({
...updated,
notifyHint: dto.approved
? '已通过审核,合伙人可在端内开门营业'
: '已驳回,驳回原因已同步至合伙人端',
});
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
@@ -217,6 +253,9 @@ export class AdminStoresService {
openTime: '10:00',
closeTime: '22:00',
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
},
});
@@ -85,6 +85,10 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
auditStatus?: string;
}
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@@ -197,6 +197,9 @@ export class StoreService {
openTime: body.openTime ? String(body.openTime) : '10:00',
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
auditedAt: this.config.autoApproveStore ? new Date() : null,
rejectReason: null,
},
});
@@ -325,6 +328,18 @@ export class StoreService {
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
throw new BadRequestException('无效的门店状态');
}
if (status === 'OPEN') {
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店尚在总部审核中,通过后方可开门');
}
if (store.auditStatus === 'REJECTED') {
throw new BadRequestException(
store.rejectReason
? `门店审核未通过:${store.rejectReason}`
: '门店审核未通过,请查看驳回原因并重新提交',
);
}
}
const updated = await this.prisma.store.update({
where: { id: storeId },
@@ -358,6 +373,9 @@ export class StoreService {
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可编辑');
}
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
const name = body.name !== undefined ? String(body.name).trim() : undefined;
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
@@ -373,6 +391,7 @@ export class StoreService {
throw new BadRequestException('门店简介须为 10~500 字');
}
const resubmitAudit = store.auditStatus === 'REJECTED';
await this.prisma.store.update({
where: { id: storeId },
data: {
@@ -380,8 +399,33 @@ export class StoreService {
...(phone !== undefined ? { phone } : {}),
...(address !== undefined ? { address } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(resubmitAudit
? {
auditStatus: 'PENDING' as const,
rejectReason: null,
auditedAt: null,
status: store.status === 'OPEN' ? ('PAUSED' as const) : store.status,
}
: {}),
},
});
if (resubmitAudit) {
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: storeId,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: 'PENDING',
param1: 'RESUBMIT',
param1Desc: 'audit_type',
remark: '合伙人修改资料后重新提交审核',
},
});
}
return this.partnerGetStore(partnerAccountId, storeId);
}
@@ -398,6 +442,13 @@ export class StoreService {
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
throw new BadRequestException(
binding.store.auditStatus === 'REJECTED'
? `门店审核未通过${binding.store.rejectReason ? `${binding.store.rejectReason}` : ''}`
: '门店尚在总部审核中,通过后方可营业',
);
}
const previousStatus = binding.store.status;
const store = await this.prisma.store.update({
where: { id: storeId },
@@ -423,34 +474,70 @@ export class StoreService {
select: { id: true },
});
const storeIds = partnerStoreIds.map((s) => s.id);
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
const [storeCount, orderCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.order.count({
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
}),
this.prisma.store.findMany({
where: { partnerAccountId: primaryId },
select: { id: true, name: true, status: true, createdAt: true },
select: { id: true, name: true, status: true, auditStatus: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.store.count({
where: { partnerAccountId: primaryId, auditStatus: 'PENDING' },
}),
storeIds.length === 0
? Promise.resolve(0)
: this.prisma.commonEvent.count({
? Promise.resolve([])
: this.prisma.commonEvent.findMany({
where: {
eventType: 'STORE_AUDIT',
status: 'PENDING',
refType: 'STORE',
refId: { in: storeIds },
status: { in: ['APPROVED', 'REJECTED'] },
actorType: 'HQ',
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
]);
const storeNameMap = new Map(
(
await this.prisma.store.findMany({
where: { id: { in: recentNotices.map((n) => n.refId) } },
select: { id: true, name: true },
})
).map((s) => [s.id.toString(), s.name]),
);
return {
storeCount,
orderCount,
companyName: account.companyName ?? '',
recentStores: serializeBigInt(recentStores),
pendingAuditCount,
notifications: serializeBigInt(
recentNotices.map((n) => ({
id: n.id,
storeId: n.refId,
storeName: storeNameMap.get(n.refId.toString()) ?? '门店',
status: n.status,
remark: n.remark,
createdAt: n.createdAt,
title:
n.status === 'APPROVED'
? '门店审核已通过'
: n.status === 'REJECTED'
? '门店审核已驳回'
: '门店审核更新',
content:
n.status === 'APPROVED'
? `${storeNameMap.get(n.refId.toString()) ?? '门店'} 已通过总部审核,可开门营业`
: `${storeNameMap.get(n.refId.toString()) ?? '门店'} 未通过审核${n.remark ? `${n.remark}` : ''}`,
})),
),
};
}