From de396442a4680be2a296569bb81c59c2f513766b Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 10:36:29 +0800 Subject: [PATCH] feat(partner): let staff manage stores and fix env photo dupes Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API. Co-authored-by: Cursor --- apps/h5-partner/src/lib/partnerAccess.ts | 10 + apps/h5-partner/src/pages/StoreCreatePage.tsx | 4 +- apps/h5-partner/src/pages/StoreDetailPage.tsx | 159 ++++++++++++-- apps/h5-partner/src/pages/StoreListPage.tsx | 13 +- .../src/modules/store/store.controller.ts | 9 + .../src/modules/store/store.service.ts | 202 ++++++++++++++++-- 6 files changed, 357 insertions(+), 40 deletions(-) diff --git a/apps/h5-partner/src/lib/partnerAccess.ts b/apps/h5-partner/src/lib/partnerAccess.ts index d0646e7..dc8d2c7 100644 --- a/apps/h5-partner/src/lib/partnerAccess.ts +++ b/apps/h5-partner/src/lib/partnerAccess.ts @@ -41,6 +41,16 @@ export function canAccessPartnerStores(account: PartnerMe | null | undefined): b return hasAnyPartnerPermission(account, ['store:create', 'store:manage']); } +/** 编辑资料 / 开闭店 / 重新上传:主账号或 store:manage / store:create */ +export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean { + return hasAnyPartnerPermission(account, ['store:manage', 'store:create']); +} + +/** 录入新店 */ +export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean { + return hasPartnerPermission(account, 'store:create'); +} + /** 与后端 GET /partner/orders 权限点一致 */ export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean { return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']); diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 363c0f4..49fb279 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -523,7 +523,9 @@ export default function StoreCreatePage() { try { - const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); + const envPhotoUrls = Array.from( + new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)), + ).slice(0, 3); const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx index 8205722..82f22b6 100644 --- a/apps/h5-partner/src/pages/StoreDetailPage.tsx +++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx @@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage'; import { request } from '../lib/api'; import { toastSuccess } from '../lib/toast'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; -import { isSubAccount } from '../lib/partnerAccess'; +import { canManagePartnerStore } from '../lib/partnerAccess'; +import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft'; +import OssUploadField from '../components/OssUploadField'; import { canPartnerOpenStore, storeAuditLabel, @@ -16,19 +18,36 @@ import { } from '../lib/storeStatus'; const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED']; +const ENV_SLOT_COUNT = 3; + +function uniqueEnvUrls(urls: string[]): string[] { + const seen = new Set(); + 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 subReadonly = isSubAccount(account); + const canMutate = canManagePartnerStore(account); const [store, setStore] = useState | null>(null); const [loadError, setLoadError] = useState(''); const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' }); + const [coverUrl, setCoverUrl] = useState(''); + const [envPhotoUrls, setEnvPhotoUrls] = useState(['', '', '']); const [status, setStatus] = useState('OPEN'); const [statusSaving, setStatusSaving] = useState(false); const [saving, setSaving] = useState(false); + const [mediaSaving, setMediaSaving] = useState(false); const [actionError, setActionError] = useState(''); + const [wechatReady, setWechatReady] = useState(false); function applyStore(data: Record) { setStore(data); @@ -39,6 +58,15 @@ export default function StoreDetailPage() { intro: String(data.intro || ''), }); 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(() => { @@ -113,6 +141,42 @@ export default function StoreDetailPage() { } } + 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>('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 (
@@ -124,13 +188,17 @@ export default function StoreDetailPage() { if (!store) return
加载中...
; - const envPhotos = Array.isArray(store.media) - ? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV') - : []; + 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 = subReadonly || status === 'CLOSED' || auditPending; + const readOnly = !canMutate || status === 'CLOSED' || auditPending; const canOpen = canPartnerOpenStore(auditStatus); return ( @@ -170,7 +238,7 @@ export default function StoreDetailPage() { )} - {!subReadonly && ( + {canMutate && (

运营状态

@@ -200,13 +268,29 @@ export default function StoreDetailPage() {

基本信息

-
- -
+ {!canMutate || readOnly ? ( +
+ +
+ ) : ( +
+

门头照

+ +
+ )}
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 }} /> @@ -234,13 +318,48 @@ export default function StoreDetailPage() {

店内环境

- {envPhotos.length ? `已上传 ${envPhotos.length} 张` : '暂无照片'} + + {canMutate && !readOnly + ? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张` + : envPhotos.length + ? `已上传 ${envPhotos.length} 张` + : '暂无照片'} +
- {envPhotos.length > 0 ? ( + {canMutate && !readOnly ? ( + <> +
+ {envPhotoUrls.map((url, index) => ( + setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))} + /> + ))} +
+ + + ) : envPhotos.length > 0 ? (
- {envPhotos.map((photo, index) => ( -
- + {envPhotos.map((url, index) => ( +
+
))}
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
- {!subReadonly && ( + {canMutate && (
+ {canCreate && ( + )} {filtered.length === 0 &&
暂无门店
} @@ -146,7 +149,7 @@ export default function StoreListPage() {
- {!readonly && ( + {canMutate && (