46361ec713
Stop auto ST from validation_error; show package prices with right-aligned layout; partner store list status/filter and onboard CS QR gate; HQ store table truncation/fixed actions; expose CS config in wechat_mini settings; bind local servers for LAN. Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||
import { isLoggedIn, request } from '../lib/api';
|
||
import { toastSuccess } from '../lib/toast';
|
||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||
import {
|
||
canPartnerOpenStore,
|
||
storeAuditLabel,
|
||
storeListBadge,
|
||
type StoreStatusValue,
|
||
} from '../lib/storeStatus';
|
||
import { usePartnerPageView } from '../lib/usePageView';
|
||
|
||
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: '永久闭店' },
|
||
];
|
||
|
||
export default function StoreListPage() {
|
||
usePartnerPageView('partner_store_list_view');
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const { account } = usePartnerSession();
|
||
const canMutate = canManagePartnerStore(account);
|
||
const canCreate = canCreatePartnerStore(account);
|
||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||
const [q, setQ] = useState('');
|
||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
|
||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||
const [error, setError] = useState('');
|
||
const [closeTarget, setCloseTarget] = useState<string | null>(null);
|
||
|
||
const loadStores = useCallback(() => {
|
||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isLoggedIn()) {
|
||
navigate('/login');
|
||
return;
|
||
}
|
||
void loadStores();
|
||
}, [navigate, loadStores]);
|
||
|
||
useEffect(() => {
|
||
document.title = canMutate ? '门店管理' : '我的门店';
|
||
}, [canMutate]);
|
||
|
||
const filtered = useMemo(
|
||
() =>
|
||
stores.filter((s) => {
|
||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||
const status = String(s.status).toUpperCase();
|
||
let matchStatus = true;
|
||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||
else if (filter !== 'ALL') matchStatus = status === filter;
|
||
return matchQ && matchStatus;
|
||
}),
|
||
[stores, q, filter],
|
||
);
|
||
|
||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||
setError(
|
||
auditStatus === 'REJECTED'
|
||
? '门店审核未通过,请查看驳回原因并修改后重新提交'
|
||
: '门店尚在总部审核中,通过后方可开门',
|
||
);
|
||
return;
|
||
}
|
||
if (next === 'CLOSED') {
|
||
setCloseTarget(storeId);
|
||
return;
|
||
}
|
||
setUpdatingId(storeId);
|
||
setError('');
|
||
try {
|
||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ status: next }),
|
||
});
|
||
await loadStores();
|
||
if (next === 'OPEN') toastSuccess('已营业');
|
||
if (next === 'PAUSED') toastSuccess('已临时闭店');
|
||
} catch {
|
||
/* request 已 toast */
|
||
} finally {
|
||
setUpdatingId(null);
|
||
}
|
||
}
|
||
|
||
async function confirmCloseStore() {
|
||
if (!closeTarget) return;
|
||
const storeId = closeTarget;
|
||
setCloseTarget(null);
|
||
setUpdatingId(storeId);
|
||
setError('');
|
||
try {
|
||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ status: 'CLOSED' }),
|
||
});
|
||
await loadStores();
|
||
toastSuccess('已永久闭店');
|
||
} catch {
|
||
/* request 已 toast */
|
||
} finally {
|
||
setUpdatingId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||
{error && (
|
||
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<div className="partner-sticky-filter">
|
||
<div className="partner-search">
|
||
<span className="material-symbols-outlined">search</span>
|
||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</div>
|
||
<div className="partner-store-filter-row">
|
||
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
|
||
状态
|
||
</label>
|
||
<select
|
||
id="partner-store-status-filter"
|
||
className="partner-store-filter-select"
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value as StatusFilter)}
|
||
>
|
||
{FILTERS.map((f) => (
|
||
<option key={f.key} value={f.key}>
|
||
{f.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{canCreate && (
|
||
<Link to="/stores/new" className="partner-fab-link">
|
||
<button type="button" className="partner-btn-primary">
|
||
<span className="material-symbols-outlined">add_business</span>
|
||
录入新门店
|
||
</button>
|
||
</Link>
|
||
)}
|
||
|
||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||
|
||
{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 badge = storeListBadge(s);
|
||
const switchOn = currentStatus === 'OPEN';
|
||
const switchDisabled =
|
||
busy ||
|
||
currentStatus === 'CLOSED' ||
|
||
auditStatus === 'PENDING' ||
|
||
auditStatus === 'REJECTED';
|
||
return (
|
||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||
<Link
|
||
to={`/stores/${storeId}`}
|
||
className="partner-store-card-hit"
|
||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||
>
|
||
<div className="partner-store-card-header">
|
||
<div className="partner-store-card-main">
|
||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
|
||
门店名称
|
||
</p>
|
||
<h3 className="headline-md">{storeName}</h3>
|
||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||
{String(s.address || s.district || '')}
|
||
</p>
|
||
{auditStatus === 'REJECTED' && s.rejectReason ? (
|
||
<p
|
||
className="label-md"
|
||
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
|
||
>
|
||
{storeAuditLabel(auditStatus)}:{String(s.rejectReason)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
|
||
{badge.label}
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
{canMutate ? (
|
||
<div className="partner-store-card-actions partner-store-card-actions--v3416">
|
||
{currentStatus !== 'CLOSED' ? (
|
||
<>
|
||
<label
|
||
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
role="switch"
|
||
checked={switchOn}
|
||
disabled={switchDisabled}
|
||
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
|
||
onChange={() => {
|
||
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
|
||
void updateStatus(storeId, next, auditStatus);
|
||
}}
|
||
/>
|
||
<span className="partner-store-switch-track" aria-hidden />
|
||
<span className="partner-store-switch-text">
|
||
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
|
||
</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="partner-store-close-btn"
|
||
disabled={busy}
|
||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||
>
|
||
永久闭店
|
||
</button>
|
||
</>
|
||
) : null}
|
||
<Link
|
||
to={`/stores/${storeId}`}
|
||
className="partner-menu-icon"
|
||
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||
edit
|
||
</span>
|
||
</Link>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
{closeTarget && (
|
||
<div
|
||
className="partner-ship-modal-backdrop"
|
||
role="presentation"
|
||
onClick={() => setCloseTarget(null)}
|
||
>
|
||
<div
|
||
className="partner-ship-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="headline-md" style={{ marginBottom: 8 }}>
|
||
确认永久闭店?
|
||
</h3>
|
||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||
永久闭店后不可再开门营业,确认关闭该门店?
|
||
</p>
|
||
<div className="partner-ship-actions">
|
||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||
取消
|
||
</button>
|
||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||
确认永久闭店
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</PullToRefresh>
|
||
);
|
||
}
|