Files
dukang/apps/h5-partner/src/pages/StoreDetailPage.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

592 lines
25 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 { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request, submitStoreInfoChangeRequest, listStoreInfoChangeRequests } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
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();
usePartnerPageView('partner_store_detail_view', id ? { storeId: id } : undefined);
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: '',
loginPhone: '',
contactPhone: '',
address: '',
intro: '',
benefitUsageRule: '',
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 [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const [pendingInfoChange, setPendingInfoChange] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
setForm({
name: String(data.name || ''),
loginPhone: String(data.phone || ''),
contactPhone: String(data.contactPhone || data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
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, MIN_ENV_PHOTO_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 : '加载失败');
});
// 拉取该门店的信息变更审核记录,若有 PENDING 则展示「审核中」横幅
listStoreInfoChangeRequests(id)
.then((reqs) =>
setPendingInfoChange(Array.isArray(reqs) && reqs.some((r) => r.status === 'PENDING')),
)
.catch(() => {});
}, [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);
}
}
function mediaChanged(): boolean {
if (!store) return false;
const nextCover = coverUrl.trim();
const origCover = String(store.coverUrl || '').trim();
if (nextCover !== origCover) return true;
const nextEnv = uniqueEnvUrls(envPhotoUrls);
const origEnv = uniqueEnvUrls(
Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
if (nextEnv.length !== origEnv.length) return true;
return nextEnv.some((u, i) => u !== origEnv[i]);
}
function basicChanged(): boolean {
if (!store) return false;
const liveContact = String(store.contactPhone || store.phone || '').trim();
const liveIntro = String(store.intro || '').trim();
const liveRuleRaw = String(store.benefitUsageRule || '').trim();
const liveRule = liveRuleRaw && !/^null$/i.test(liveRuleRaw) ? liveRuleRaw : '';
const liveLat =
store.latitude != null && store.latitude !== '' ? String(store.latitude) : '';
const liveLng =
store.longitude != null && store.longitude !== '' ? String(store.longitude) : '';
return (
form.name.trim() !== String(store.name || '').trim() ||
form.contactPhone.trim() !== liveContact ||
form.address.trim() !== String(store.address || '').trim() ||
form.intro.trim() !== liveIntro ||
form.benefitUsageRule.trim() !== liveRule ||
form.latitude.trim() !== liveLat ||
form.longitude.trim() !== liveLng
);
}
async function saveChanges() {
if (!id || saving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const wantMedia = mediaChanged();
const wantBasic = basicChanged();
if (!wantMedia && !wantBasic) {
setActionError('没有检测到需要变更的字段');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (wantMedia) {
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
}
setSaving(true);
setActionError('');
try {
// 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核
if (auditStatus === 'REJECTED') {
if (wantMedia) {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
}
if (wantBasic) {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}),
});
applyStore(data);
}
toastSuccess('已重新提交审核');
return;
}
const fields: Record<string, unknown> = {};
if (wantBasic) {
fields.name = form.name.trim();
fields.contactPhone = form.contactPhone.trim();
fields.address = form.address.trim();
fields.intro = form.intro.trim();
fields.benefitUsageRule = form.benefitUsageRule.trim() || null;
if (form.latitude.trim() && form.longitude.trim()) {
fields.latitude = Number(form.latitude);
fields.longitude = Number(form.longitude);
}
}
if (wantMedia) {
fields.coverUrl = nextCover;
fields.envPhotoUrls = nextEnv;
}
await submitStoreInfoChangeRequest(id, fields);
setPendingInfoChange(true);
toastSuccess(
wantMedia
? '变更已提交(含门头照/环境图),总部审核通过后生效'
: '变更已提交,等待总部审核',
);
} catch (e) {
setActionError(e instanceof Error ? e.message : '提交失败');
} finally {
setSaving(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>
)}
{pendingInfoChange && (
<div style={{ marginTop: 12, background: 'rgba(245,166,35,0.08)', border: '1px solid rgba(245,166,35,0.3)', borderRadius: 8, padding: 12 }}>
<p className="body-md" style={{ fontWeight: 600, marginBottom: 4 }}>
基础信息变更审核中
</p>
<p className="label-md text-muted" style={{ margin: 0 }}>
已提交的资料变更正在等待总部审核,审核通过后才会覆盖门店线上信息。审核期间可继续修改并重新提交。
</p>
</div>
)}
</section>
{canMutate && !auditPending && status !== 'CLOSED' && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
<button type="button" className="partner-btn-outline partner-btn-outline--compact" onClick={() => navigate(`/stores/${id}/packages`)}>
编辑套餐
</button>
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>维护可核销套餐信息,提交后由总部审核。</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">lock</span>
<input disabled type="tel" value={form.loginPhone} readOnly />
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
老板主账号,用于门店端登录;如需变更请联系总部。
</p>
</div>
<div className="partner-field">
<label>联系电话</label>
<div className="partner-field-input">
<span className="material-symbols-outlined">phone_in_talk</span>
<input
disabled={readOnly}
type="tel"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
/>
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
店长或对外展示号码,用户端拨号使用此号码,支持座机。
</p>
</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 }}
onClick={() => setMapPickerOpen(true)}
>
地图选点
</button>
<span className="label-md text-muted">
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
</span>
</div>
) : null}
<input type="hidden" name="latitude" value={form.latitude} readOnly />
<input type="hidden" name="longitude" value={form.longitude} readOnly />
</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>
<div className="partner-field">
<label>好客权益券使用规则</label>
<textarea
disabled={readOnly}
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</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
? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
: envPhotos.length
? `已上传 ${envPhotos.length} 张`
: '暂无照片'}
</span>
</div>
{canMutate && !readOnly ? (
<MultiOssUploadField
bizType="STORE_ENV"
maxCount={20}
variant="grid"
value={uniqueEnvUrls(envPhotoUrls)}
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
/>
) : 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 saveChanges()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">send</span>
{saving ? '提交中…' : '提交变更'}
</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>
)}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
setForm((prev) => ({
...prev,
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
}}
/>
</div>
);
}