门店开通流程

This commit is contained in:
2026-07-03 14:53:21 +08:00
parent c7dc6a2274
commit f2175b83f7
24 changed files with 1451 additions and 186 deletions
+3 -38
View File
@@ -1,27 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
import { storeStatusLabel } from '../lib/storeStatus';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function auditStatus(store: Record<string, unknown>) {
const audits = store.audits as Array<{ status?: string }> | undefined;
return String(audits?.[0]?.status ?? '').toUpperCase();
}
function storeStatusLabel(store: Record<string, unknown>) {
const audit = auditStatus(store);
if (audit === 'PENDING') return '待审核';
const s = String(store.status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '暂时闭店';
if (s === 'CLOSED') return '已关闭';
return s;
}
export default function HomePage() {
const navigate = useNavigate();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
@@ -37,10 +23,6 @@ export default function HomePage() {
const orderCount = Number(dash?.orderCount || 0);
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const pendingStores = useMemo(
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
[stores],
);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
@@ -173,25 +155,8 @@ export default function HomePage() {
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
</div>
</div>
{pendingStores.length > 0 && (
<div className="partner-pending-stores">
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<ul className="partner-store-name-list partner-store-name-list--compact">
{pendingStores.map((s) => (
<li key={String(s.id)}>
<span className="headline-md">{String(s.name || '未命名门店')}</span>
</li>
))}
</ul>
</div>
)}
{displayStores.length > 0 && (
<ul className="partner-store-name-list">
<li className="partner-store-name-list-header">
@@ -201,7 +166,7 @@ export default function HomePage() {
{displayStores.slice(0, 5).map((s) => (
<li key={String(s.id)}>
<span className="headline-md">{String(s.name || '未命名门店')}</span>
<span className="label-md text-muted">{storeStatusLabel(s)}</span>
<span className="label-md text-muted">{storeStatusLabel(String(s.status))}</span>
</li>
))}
</ul>
+139 -51
View File
@@ -1,7 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField';
import { request } from '../lib/api';
import { resolveRegionBinding } from '../lib/china-region';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import {
clearStoreDraft,
defaultStoreForm,
@@ -20,6 +24,9 @@ export default function StoreCreatePage() {
const saved = loadStoreDraft();
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState('');
const stepFromUrl = Number(params.get('step') || 0);
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
@@ -34,11 +41,45 @@ export default function StoreCreatePage() {
saveStoreDraft({ step, form });
}, [step, form]);
useEffect(() => {
void fetchPartnerCities()
.then((items) => {
setCities(items);
if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
})
.catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败'));
}, []);
const regionBindingHint = useMemo(() => {
if (!form.regionCodes.length) return null;
const binding = resolveRegionBinding(form.regionCodes, cities);
if (!binding) return null;
if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}`;
}
return '所选地区暂未开城,请联系总部配置对应区划';
}, [form.regionCodes, cities]);
function patchForm(patch: Partial<StoreDraftForm>) {
setForm((prev) => ({ ...prev, ...patch }));
setError('');
}
function bindRegionSelection(codes: string[]) {
const binding = resolveRegionBinding(codes, cities);
if (!binding) {
patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' });
return;
}
patchForm({
regionCodes: codes,
cityId: binding.cityId ?? '',
province: binding.region.province,
city: binding.region.city,
district: binding.region.district,
});
}
function goStep(n: number) {
setParams({ step: String(n) });
setError('');
@@ -58,21 +99,38 @@ export default function StoreCreatePage() {
const step1Msg = validateStoreStep1(form);
if (step1Msg) { setError(step1Msg); return; }
await request('PARTNER_H5', '/partner/stores', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
intro: form.intro.trim() || undefined,
bankAccountName: form.bankAccountName.trim(),
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
bankBranch: form.bankBranch.trim(),
}),
});
clearStoreDraft();
navigate('/stores');
setSubmitting(true);
setError('');
try {
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
method: 'POST',
body: JSON.stringify({
cityId: form.cityId,
province: form.province,
city: form.city,
name: form.name.trim(),
phone: form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
intro: form.intro.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: form.contractUrl.trim() || undefined,
bankAccountName: form.bankAccountName.trim(),
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
bankBranch: form.bankBranch.trim(),
accountPhone: form.accountPhone.trim() || undefined,
accountName: form.accountName.trim() || undefined,
}),
});
clearStoreDraft();
navigate(`/stores/${result.store.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
@@ -102,8 +160,8 @@ export default function StoreCreatePage() {
</div>
</nav>
{error && (
<p className="partner-form-error" role="alert">{error}</p>
{(error || citiesError) && (
<p className="partner-form-error" role="alert">{error || citiesError}</p>
)}
{step === 1 && (
@@ -113,6 +171,15 @@ export default function StoreCreatePage() {
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<ChinaRegionPicker value={form.regionCodes} onChange={bindRegionSelection} />
{regionBindingHint && (
<p className={`label-md${form.cityId ? ' text-muted' : ' text-primary'}`} style={{ marginTop: 8 }}>
{regionBindingHint}
</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
@@ -127,13 +194,6 @@ export default function StoreCreatePage() {
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">location_on</span>
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
@@ -145,6 +205,20 @@ export default function StoreCreatePage() {
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">smartphone</span>
<input type="tel" placeholder="默认同门店电话" value={form.accountPhone} onChange={(e) => patchForm({ accountPhone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">person</span>
<input placeholder="默认同门店名" value={form.accountName} onChange={(e) => patchForm({ accountName: e.target.value })} />
</div>
</div>
</section>
<div className="partner-info-banner">
<div className="partner-bills-icon">
@@ -170,45 +244,57 @@ export default function StoreCreatePage() {
</div>
<section className="partner-form-card">
<h3 className="headline-md"> *</h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>1</p>
<button type="button" className="partner-upload-dashed partner-upload-dashed--wide">
<span className="material-symbols-outlined text-primary" style={{ fontSize: 36 }}>add_a_photo</span>
<span className="text-primary" style={{ fontWeight: 500 }}></span>
</button>
<h3 className="headline-md"></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> 1 </p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={form.coverUrl}
onChange={(coverUrl) => patchForm({ coverUrl })}
label="点击或拖拽上传"
/>
</section>
<section className="partner-form-card">
<h3 className="headline-md"> *</h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>3</p>
<h3 className="headline-md"></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> 3 </p>
<div className="partner-upload-grid">
{[0, 1, 2].map((i) => (
<button key={i} type="button" className="partner-upload-dashed">
<span className="material-symbols-outlined text-primary">add_a_photo</span>
<span></span>
</button>
{form.envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
label="添加照片"
onChange={(nextUrl) => {
const envPhotoUrls = [...form.envPhotoUrls];
envPhotoUrls[index] = nextUrl;
patchForm({ envPhotoUrls });
}}
/>
))}
</div>
</section>
<section className="partner-form-card">
<h3 className="headline-md"> *</h3>
<h3 className="headline-md"></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p>
<button type="button" className="partner-upload-dashed" style={{ flexDirection: 'row', height: 96 }}>
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
<span className="material-symbols-outlined text-primary">description</span>
</div>
<div style={{ textAlign: 'left' }}>
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}></span>
<span className="label-md text-muted"> JPG, PNG, PDF</span>
</div>
</button>
<OssUploadField
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
value={form.contractUrl}
onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本"
/>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
preV1
OSS
</p>
</div>
</>
@@ -245,7 +331,7 @@ export default function StoreCreatePage() {
<footer className="partner-sticky-footer">
{step > 1 && (
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}></button>
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={submitting}></button>
)}
{step < 3 ? (
<button type="button" className="partner-btn-primary" onClick={handleNext}>
@@ -253,7 +339,9 @@ export default function StoreCreatePage() {
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
</button>
) : (
<button type="button" className="partner-btn-primary" onClick={submit}></button>
<button type="button" className="partner-btn-primary" onClick={() => void submit()} disabled={submitting}>
{submitting ? '提交中…' : '提交'}
</button>
)}
</footer>
</div>
+126 -37
View File
@@ -3,59 +3,143 @@ import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [status, setStatus] = useState('OPEN');
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState('');
function showToast(message: string) {
setToast(message);
window.setTimeout(() => setToast(''), 2000);
}
function applyStore(data: Record<string, unknown>) {
setStore(data);
setForm({
name: String(data.name || ''),
phone: String(data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
});
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
}
useEffect(() => {
if (id) {
request('PARTNER_H5', `/stores/${id}`).then((data) => {
setStore(data);
setForm({
name: String(data.name || ''),
phone: String(data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
});
setStatus(String(data.status || 'OPEN').toUpperCase());
if (!id) return;
setLoadError('');
request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}`)
.then(applyStore)
.catch((e) => {
setStore(null);
setLoadError(e instanceof Error ? e.message : '加载失败');
});
}
}, [id]);
function save() {
setToast('preV1:保存成功(演示)');
setTimeout(() => setToast(''), 2000);
async function changeStatus(next: StoreStatusValue) {
if (!id || next === status || statusSaving) return;
if (status === 'CLOSED') return;
if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
if (!ok) return;
}
setStatusSaving(true);
setError('');
try {
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
setStatus(next);
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
showToast('状态已更新');
} catch (e) {
setError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setStatusSaving(false);
}
}
async function saveBasic() {
if (!id || saving || status === 'CLOSED') return;
setSaving(true);
setError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
}),
});
applyStore(data);
showToast('已保存');
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
if (loadError) {
return (
<div className="partner-detail-page">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<div className="empty">{loadError}</div>
</div>
);
}
if (!store) return <div className="empty">...</div>;
const statusLabel = status === 'OPEN' ? '营业中' : status === 'PAUSED' ? '暂停服务' : '已下线';
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
const readOnly = status === 'CLOSED';
return (
<div className="partner-detail-page">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<main style={{ padding: '16px 20px' }}>
{error && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{error}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<span className="partner-status-pill partner-status-pill--open">
<span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />
{statusLabel}
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
{status === 'OPEN' && <span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />}
{storeStatusLabel(status)}
</span>
</div>
<div className="partner-status-toggle">
{(['OPEN', 'PAUSED', 'CLOSED'] as const).map((s) => (
<button key={s} type="button" className={status === s ? 'active' : ''} onClick={() => setStatus(s)}>
{s === 'OPEN' ? '营业中' : s === 'PAUSED' ? '暂停服务' : '已下线'}
{STATUS_OPTIONS.map((s) => (
<button
key={s}
type="button"
className={status === s ? 'active' : ''}
disabled={readOnly || statusSaving}
onClick={() => void changeStatus(s)}
>
{storeStatusLabel(s)}
</button>
))}
</div>
{readOnly && (
<p className="label-md text-muted" style={{ marginTop: 12 }}></p>
)}
</section>
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -69,22 +153,22 @@ export default function StoreDetailPage() {
</div>
<div className="partner-field">
<label></label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label></label>
<textarea rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<textarea rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
@@ -94,14 +178,19 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">12</span>
</div>
<div className="partner-photo-grid">
<button type="button" className="partner-photo-add">
<span className="material-symbols-outlined">add</span>
<span className="label-md"></span>
</button>
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span>
</div>
{envPhotos.length > 0 ? (
<div className="partner-photo-grid">
{envPhotos.map((photo, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div>
))}
</div>
) : (
<p className="label-md text-muted"></p>
)}
</section>
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
@@ -109,7 +198,7 @@ export default function StoreDetailPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<label className="label-md text-muted"></label>
<p className="body-md" style={{ fontWeight: 500 }}>{String(store.status)}</p>
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
</div>
<div>
<label className="label-md text-muted">ID</label>
@@ -120,10 +209,10 @@ export default function StoreDetailPage() {
</main>
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
<button type="button" className="partner-save-submit" onClick={save}>
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : '保存修改'}
</button>
</footer>
+73 -33
View File
@@ -1,42 +1,33 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
type StatusFilter = 'ALL' | 'OPEN' | 'PAUSED' | 'CLOSED';
function statusPill(status: string) {
const s = String(status).toUpperCase();
if (s === 'OPEN') return { cls: 'partner-status-pill--open', label: '营业中' };
if (s === 'PAUSED') return { cls: 'partner-status-pill--paused', label: '暂时闭店' };
return { cls: 'partner-status-pill--closed', label: '已关闭' };
}
type StatusFilter = 'ALL' | StoreStatusValue;
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '暂时闭店' },
{ key: 'CLOSED', label: '关闭' },
{ key: 'CLOSED', label: '关闭' },
];
function auditLabel(store: Record<string, unknown>) {
const audits = store.audits as Array<{ status?: string }> | undefined;
const s = String(audits?.[0]?.status ?? '').toUpperCase();
if (s === 'PENDING') return { cls: 'partner-status-pill--paused', label: '待审核' };
if (s === 'REJECTED') return { cls: 'partner-status-pill--closed', label: '已驳回' };
if (s === 'APPROVED') return { cls: 'partner-status-pill--open', label: '已通过' };
return null;
}
export default function StoreListPage() {
const navigate = useNavigate();
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState('');
const [filter, setFilter] = useState<StatusFilter>('ALL');
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState('');
const loadStores = useCallback(() => {
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
}, [navigate]);
void loadStores();
}, [navigate, loadStores]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
@@ -44,6 +35,26 @@ export default function StoreListPage() {
return matchQ && matchStatus;
}), [stores, q, filter]);
async function updateStatus(storeId: string, next: StoreStatusValue) {
if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
if (!ok) return;
}
setUpdatingId(storeId);
setError('');
try {
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
await loadStores();
} catch (e) {
setError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setUpdatingId(null);
}
}
return (
<div className="page partner-store-page">
<header className="header app-page-header">
@@ -55,6 +66,8 @@ export default function StoreListPage() {
<p className="text-muted body-md"></p>
</div>
{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>
@@ -79,29 +92,56 @@ export default function StoreListPage() {
{filtered.length === 0 && <div className="empty"></div>}
{filtered.map((s) => {
const pill = statusPill(String(s.status));
const audit = auditLabel(s);
const dim = String(s.status).toUpperCase() === 'CLOSED';
const storeId = String(s.id);
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店');
const busy = updatingId === storeId;
return (
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
<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' }}>
<div className="partner-store-card-header">
<div>
<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>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
{audit && <span className={`partner-status-pill ${audit.cls}`}>{audit.label}</span>}
</div>
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
</div>
</Link>
<div className="partner-store-card-actions">
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px' }}></button>
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}></button>
<Link to={`/stores/${s.id}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED'}
onClick={() => void updateStatus(storeId, 'PAUSED')}
>
</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')}
>
</button>
{currentStatus === 'PAUSED' && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy}
onClick={() => void updateStatus(storeId, 'OPEN')}
>
</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>