Files
dukang/apps/h5-partner/src/pages/StoreListPage.tsx
T
jacy 46361ec713 feat(v3.4.16): partner UX, package pricing, CS gate, HQ list polish
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>
2026-08-09 16:00:45 +08:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}