feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled

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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 10:36:29 +08:00
parent 14b867a3a5
commit de396442a4
6 changed files with 357 additions and 40 deletions
+10
View File
@@ -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']);
@@ -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', {
+139 -20
View File
@@ -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<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 subReadonly = isSubAccount(account);
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: '' });
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 [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) {
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<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">
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>;
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() {
)}
</section>
{!subReadonly && (
{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>
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
<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>
<div className="partner-cover">
<AppImage
src={store.coverUrl ? String(store.coverUrl) : null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
{!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}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
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 }} />
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
<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">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span>
<span className="label-md text-muted">
{canMutate && !readOnly
? `${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div>
{envPhotos.length > 0 ? (
{canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
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((photo, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
{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>
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{!subReadonly && (
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
+8 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -27,7 +27,8 @@ export default function StoreListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
const readonly = isSubAccount(account);
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;
@@ -45,8 +46,8 @@ export default function StoreListPage() {
}, [navigate, loadStores]);
useEffect(() => {
document.title = readonly ? '我的门店' : '门店管理';
}, [readonly]);
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
@@ -101,12 +102,14 @@ export default function StoreListPage() {
</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>}
@@ -146,7 +149,7 @@ export default function StoreListPage() {
</div>
</div>
</Link>
{!readonly && (
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
@@ -75,6 +75,15 @@ export class PartnerStoreController {
) {
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
}
@Put(':id/media')
updateMedia(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: Record<string, unknown>,
) {
return this.storeService.partnerUpdateStoreMedia(user.actorId, BigInt(id), body);
}
}
@Controller('partner/dashboard')
@@ -85,15 +85,7 @@ export class StoreService {
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
const media = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
status: 'ACTIVE',
bizType: { in: ['ENV', 'CONTRACT'] },
},
orderBy: { sortOrder: 'asc' },
});
const media = await this.loadPartnerStoreMedia(storeId);
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
@@ -169,9 +161,7 @@ export class StoreService {
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
: [];
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
if (!coverUrl) throw new BadRequestException('请上传门头照');
@@ -317,7 +307,7 @@ export class StoreService {
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
@@ -365,7 +355,7 @@ export class StoreService {
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
@@ -429,6 +419,115 @@ export class StoreService {
return this.partnerGetStore(partnerAccountId, storeId);
}
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
async partnerUpdateStoreMedia(
partnerAccountId: bigint,
storeId: bigint,
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可编辑');
}
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
const hasEnv = body.envPhotoUrls !== undefined;
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
if (coverUrl !== undefined && !coverUrl) {
throw new BadRequestException('请上传门头照');
}
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
throw new BadRequestException('请上传至少 3 张环境照片');
}
if (coverUrl === undefined && envPhotoUrls === undefined) {
throw new BadRequestException('请至少更新门头照或环境照片');
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl !== undefined) {
if (store.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: store.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({
where: { id: storeId },
data: { coverResourceId: cover.id },
});
}
}
if (envPhotoUrls !== undefined) {
await this.prisma.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envPhotoUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: envPhotoUrls[i],
url: envPhotoUrls[i],
sortOrder: i,
},
});
}
}
const resubmitAudit = store.auditStatus === 'REJECTED';
if (resubmitAudit) {
await this.prisma.store.update({
where: { id: storeId },
data: {
auditStatus: 'PENDING',
rejectReason: null,
auditedAt: null,
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: storeId,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: 'PENDING',
param1: 'RESUBMIT',
param1Desc: 'audit_type',
remark: '合伙人重新上传资料后重新提交审核',
},
});
}
return this.partnerGetStore(partnerAccountId, storeId);
}
async getShopStore(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
@@ -889,6 +988,81 @@ export class StoreService {
return account.isPrimary !== 1;
}
private partnerPermissionList(account: { permissions?: unknown }): string[] {
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
}
/** 主账号,或具备 store:manage / store:create 的子账号可改门店 */
private async assertCanMutateStore(
account: { isPrimary: number; permissions?: unknown },
partnerAccountId: bigint,
storeId: bigint,
) {
if (!this.isSubAccount(account)) return;
const perms = this.partnerPermissionList(account);
const canManage = perms.includes('store:manage');
const canCreate = perms.includes('store:create');
if (!canManage && !canCreate) {
throw new ForbiddenException('子账号无门店管理权限');
}
// store:manage 可管团队门店;仅 store:create 只能改自己录入的店
if (canManage) return;
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const urls: string[] = [];
for (const item of raw) {
const url = String(item ?? '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= max) break;
}
return urls;
}
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
private async loadPartnerStoreMedia(storeId: bigint) {
const media = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
status: 'ACTIVE',
bizType: { in: ['ENV', 'CONTRACT'] },
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const seenEnv = new Set<string>();
const duplicateEnvIds: bigint[] = [];
const kept: typeof media = [];
for (const row of media) {
if (row.bizType !== 'ENV') {
kept.push(row);
continue;
}
const key = row.url.trim();
if (seenEnv.has(key)) {
duplicateEnvIds.push(row.id);
continue;
}
seenEnv.add(key);
kept.push(row);
}
if (duplicateEnvIds.length > 0) {
await this.prisma.commonResource.updateMany({
where: { id: { in: duplicateEnvIds } },
data: { status: 'DELETED' },
});
}
return kept;
}
private assertPrimaryAccount(account: { isPrimary: number }) {
if (this.isSubAccount(account)) {
throw new ForbiddenException('子账号无权执行此操作');