486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import AppImage from '@dukang/shared-ui/AppImage';
|
||
import { request } from '../lib/api';
|
||
import { toastError, toastSuccess } from '../lib/toast';
|
||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||
import OssUploadField from '../components/OssUploadField';
|
||
import {
|
||
canPartnerOpenStore,
|
||
storeAuditLabel,
|
||
storeAuditPillClass,
|
||
storeStatusLabel,
|
||
storeStatusPillClass,
|
||
type StoreStatusValue,
|
||
} from '../lib/storeStatus';
|
||
|
||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||
const ENV_SLOT_COUNT = 3;
|
||
|
||
function uniqueEnvUrls(urls: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
const out: string[] = [];
|
||
for (const raw of urls) {
|
||
const url = raw.trim();
|
||
if (!url || seen.has(url)) continue;
|
||
seen.add(url);
|
||
out.push(url);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export default function StoreDetailPage() {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const { account } = usePartnerSession();
|
||
const canMutate = canManagePartnerStore(account);
|
||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||
const [loadError, setLoadError] = useState('');
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
phone: '',
|
||
address: '',
|
||
intro: '',
|
||
latitude: '',
|
||
longitude: '',
|
||
});
|
||
const [coverUrl, setCoverUrl] = useState('');
|
||
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||
const [statusSaving, setStatusSaving] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [mediaSaving, setMediaSaving] = useState(false);
|
||
const [locating, setLocating] = useState(false);
|
||
const [actionError, setActionError] = useState('');
|
||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||
|
||
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 || ''),
|
||
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
|
||
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
||
});
|
||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||
setCoverUrl(String(data.coverUrl || ''));
|
||
const envFromMedia = Array.isArray(data.media)
|
||
? uniqueEnvUrls(
|
||
(data.media as Array<{ url?: string; bizType?: string }>)
|
||
.filter((m) => m.bizType === 'ENV')
|
||
.map((m) => String(m.url || '')),
|
||
)
|
||
: [];
|
||
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
|
||
}
|
||
|
||
useEffect(() => {
|
||
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]);
|
||
|
||
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') {
|
||
setCloseConfirmOpen(true);
|
||
return;
|
||
}
|
||
setStatusSaving(true);
|
||
setActionError('');
|
||
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));
|
||
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
|
||
} catch (e) {
|
||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||
} finally {
|
||
setStatusSaving(false);
|
||
}
|
||
}
|
||
|
||
async function confirmCloseStore() {
|
||
if (!id || statusSaving) return;
|
||
setCloseConfirmOpen(false);
|
||
setStatusSaving(true);
|
||
setActionError('');
|
||
try {
|
||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ status: 'CLOSED' }),
|
||
});
|
||
setStatus('CLOSED');
|
||
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
|
||
toastSuccess('门店已关闭');
|
||
} catch (e) {
|
||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||
} finally {
|
||
setStatusSaving(false);
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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(),
|
||
...(form.latitude.trim() && form.longitude.trim()
|
||
? {
|
||
latitude: Number(form.latitude),
|
||
longitude: Number(form.longitude),
|
||
}
|
||
: {}),
|
||
}),
|
||
});
|
||
applyStore(data);
|
||
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
|
||
} catch (e) {
|
||
setActionError(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveMedia() {
|
||
if (!id || mediaSaving || status === 'CLOSED') return;
|
||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||
if (auditStatus === 'PENDING') {
|
||
setActionError('门店审核中,暂不可修改资料');
|
||
return;
|
||
}
|
||
const nextCover = coverUrl.trim();
|
||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||
if (!nextCover) {
|
||
setActionError('请上传门头照');
|
||
return;
|
||
}
|
||
if (nextEnv.length < ENV_SLOT_COUNT) {
|
||
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
|
||
return;
|
||
}
|
||
setMediaSaving(true);
|
||
setActionError('');
|
||
try {
|
||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
coverUrl: nextCover,
|
||
envPhotoUrls: nextEnv,
|
||
}),
|
||
});
|
||
applyStore(data);
|
||
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||
} catch (e) {
|
||
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||
} finally {
|
||
setMediaSaving(false);
|
||
}
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<div className="partner-detail-page partner-home--flush-top">
|
||
<div className="empty">{loadError}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!store) return <div className="empty">加载中...</div>;
|
||
|
||
const envPhotos = uniqueEnvUrls(
|
||
Array.isArray(store.media)
|
||
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||
.filter((m) => m.bizType === 'ENV')
|
||
.map((m) => String(m.url || ''))
|
||
: [],
|
||
);
|
||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||
const auditPending = auditStatus === 'PENDING';
|
||
const auditRejected = auditStatus === 'REJECTED';
|
||
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||
const canOpen = canPartnerOpenStore(auditStatus);
|
||
|
||
return (
|
||
<div className="partner-detail-page partner-home--flush-top">
|
||
<main style={{ padding: '12px 20px 16px' }}>
|
||
{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>
|
||
|
||
{canMutate && (
|
||
<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 ${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">
|
||
{STATUS_OPTIONS.map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
className={status === s ? 'active' : ''}
|
||
disabled={readOnly || statusSaving || (s === 'OPEN' && !canOpen) || status === 'CLOSED'}
|
||
onClick={() => void changeStatus(s)}
|
||
>
|
||
{s === 'OPEN' && !canOpen ? '待审核' : storeStatusLabel(s)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{status === 'CLOSED' && (
|
||
<p className="label-md text-muted" style={{ marginTop: 12 }}>门店已关闭,不可再变更状态或编辑资料</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||
{!canMutate || readOnly ? (
|
||
<div className="partner-cover">
|
||
<AppImage
|
||
src={coverUrl || null}
|
||
alt={form.name}
|
||
wrapperClassName="app-image--fill"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>门头照</p>
|
||
<OssUploadField
|
||
wide
|
||
bizType="STORE_TITLE"
|
||
mediaType="IMAGE"
|
||
value={coverUrl}
|
||
onChange={setCoverUrl}
|
||
label="从系统相册选择"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="partner-field">
|
||
<label>门店名称</label>
|
||
<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 disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="partner-field">
|
||
<label>门店地址</label>
|
||
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||
{!readOnly ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||
<button
|
||
type="button"
|
||
className="partner-btn-outline"
|
||
style={{ padding: '8px 12px', fontSize: 13 }}
|
||
disabled={locating}
|
||
onClick={() => {
|
||
void (async () => {
|
||
setLocating(true);
|
||
setActionError('');
|
||
try {
|
||
const pos = await locateStorePosition();
|
||
setForm((prev) => ({
|
||
...prev,
|
||
latitude: String(pos.latitude),
|
||
longitude: String(pos.longitude),
|
||
}));
|
||
toastSuccess('已获取当前坐标');
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : '定位失败';
|
||
setActionError(msg);
|
||
toastError(msg);
|
||
} finally {
|
||
setLocating(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{locating ? '定位中…' : '获取当前位置'}
|
||
</button>
|
||
<span className="label-md text-muted">
|
||
{formatStoreCoords(form.latitude, form.longitude)
|
||
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||
: '未定位'}
|
||
</span>
|
||
</div>
|
||
) : formatStoreCoords(form.latitude, form.longitude) ? (
|
||
<p className="label-md text-muted" style={{ marginTop: 6 }}>
|
||
坐标:{formatStoreCoords(form.latitude, form.longitude)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="partner-field">
|
||
<label>门店简介</label>
|
||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-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>
|
||
</div>
|
||
</section>
|
||
|
||
<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">
|
||
{canMutate && !readOnly
|
||
? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||
: envPhotos.length
|
||
? `已上传 ${envPhotos.length} 张`
|
||
: '暂无照片'}
|
||
</span>
|
||
</div>
|
||
{canMutate && !readOnly ? (
|
||
<>
|
||
<div className="partner-upload-grid">
|
||
{envPhotoUrls.map((url, index) => (
|
||
<OssUploadField
|
||
key={index}
|
||
compact
|
||
bizType="STORE_ENV"
|
||
mediaType="IMAGE"
|
||
value={url}
|
||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||
/>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="partner-btn-outline"
|
||
style={{ width: '100%', marginTop: 16 }}
|
||
disabled={mediaSaving}
|
||
onClick={() => void saveMedia()}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||
upload
|
||
</span>
|
||
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||
</button>
|
||
</>
|
||
) : envPhotos.length > 0 ? (
|
||
<div className="partner-photo-grid">
|
||
{envPhotos.map((url, index) => (
|
||
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||
<AppImage src={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)' }}>
|
||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}>管理信息</h3>
|
||
<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>
|
||
|
||
<footer className="partner-save-footer">
|
||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||
{canMutate && (
|
||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||
<span className="material-symbols-outlined">save</span>
|
||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||
</button>
|
||
)}
|
||
</footer>
|
||
|
||
{closeConfirmOpen && (
|
||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
|
||
<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={() => setCloseConfirmOpen(false)}>
|
||
取消
|
||
</button>
|
||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||
确认关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|