From 4b2589156cf7dc2fce96fffb903b8de9904ee504 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 2 Jul 2026 16:13:45 +0800 Subject: [PATCH] =?UTF-8?q?oss=E6=8E=A5=E9=80=9A=E4=BA=8680=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E8=BF=98=E6=B2=A1=E6=9C=89=E6=8E=A5=E9=80=9A?= =?UTF-8?q?https=EF=BC=8C=E5=85=88=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/storeCreate.ts | 43 +++ apps/admin-web/src/pages/StoresPage.tsx | 244 ++++++++++++++++-- .../integrations/oss/oss.aliyun.provider.ts | 3 + .../src/modules/ops/admin-stores.service.ts | 68 ++++- .../src/modules/ops/dto/admin-mutate.dto.ts | 23 +- 5 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 apps/admin-web/src/lib/storeCreate.ts diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts new file mode 100644 index 0000000..982e8ae --- /dev/null +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -0,0 +1,43 @@ +export type StoreCreateForm = { + partnerId: string; + cityId: string; + name: string; + phone: string; + district: string; + address: string; + intro?: string; + coverUrl?: string; + envPhotoUrls?: string[]; + contractUrl?: string; + bankAccountName: string; + bankAccountNo: string; + bankBranch: string; + accountPhone?: string; + accountName?: string; +}; + +const PHONE_RE = /^1\d{10}$/; +const BANK_RE = /^\d{16,19}$/; + +export function validateStoreCreateStep1(form: Pick): string | null { + if (!form.partnerId) return '请选择开城合伙人'; + if (!form.cityId) return '请选择开城城市'; + if (!form.name?.trim()) return '请填写门店名称'; + if (!form.phone?.trim()) return '请填写联系电话'; + if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; + if (!form.district?.trim()) return '请填写区县'; + if (!form.address?.trim()) return '请填写详细地址'; + if (form.intro?.trim()) { + const len = form.intro.trim().length; + if (len < 10 || len > 500) return '门店简介须为 10~500 字'; + } + return null; +} + +export function validateStoreCreateStep3(form: Pick): string | null { + if (!form.bankAccountName?.trim()) return '请填写户主姓名'; + if (!form.bankAccountNo?.trim()) return '请填写银行卡号'; + if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; + if (!form.bankBranch?.trim()) return '请填写开户支行'; + return null; +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 4ab65d3..c3167ac 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,13 +1,38 @@ import { useState } from 'react'; import { - Button, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Table, Tag, Typography, message, + Alert, + Button, + Descriptions, + Drawer, + Form, + Image, + Input, + Modal, + Select, + Space, + Steps, + Table, + Tag, + Typography, + message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { + validateStoreCreateStep1, + validateStoreCreateStep3, + type StoreCreateForm, +} from '../lib/storeCreate'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +const CREATE_STEPS = [ + { title: '基本信息' }, + { title: '照片上传' }, + { title: '结算资质' }, +]; + type StoreRow = { id: string; name: string; @@ -25,12 +50,12 @@ type StoreRow = { }; type PartnerOption = { id: string; companyName: string }; -type CityOption = { id: string; name: string; code: string }; +type CityOption = { id: string; name: string; code: string; partnerId?: string | null }; export default function StoresPage() { const [form] = Form.useForm(); const [editForm] = Form.useForm(); - const [createForm] = Form.useForm(); + const [createForm] = Form.useForm(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/stores', @@ -46,9 +71,13 @@ export default function StoresPage() { const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [createStep, setCreateStep] = useState(0); + const [createError, setCreateError] = useState(''); const [partners, setPartners] = useState([]); const [cities, setCities] = useState([]); + const selectedPartnerId = Form.useWatch('partnerId', createForm); + async function loadOptions() { const [p, c] = await Promise.all([ request>('/admin/partners?pageSize=200'), @@ -58,6 +87,85 @@ export default function StoresPage() { setCities(c.items); } + function closeCreateModal() { + setCreateOpen(false); + setCreateStep(0); + setCreateError(''); + createForm.resetFields(); + } + + function openCreateModal() { + void loadOptions(); + createForm.setFieldsValue({ + envPhotoUrls: ['', '', ''], + }); + setCreateStep(0); + setCreateError(''); + setCreateOpen(true); + } + + async function handleCreateNext() { + const values = createForm.getFieldsValue(); + if (createStep === 0) { + const msg = validateStoreCreateStep1(values); + if (msg) { + setCreateError(msg); + return; + } + try { + await createForm.validateFields(['partnerId', 'cityId', 'name', 'phone', 'district', 'address']); + } catch { + return; + } + } + setCreateError(''); + setCreateStep((s) => s + 1); + } + + async function handleCreateSubmit() { + const values = createForm.getFieldsValue(); + const step1Msg = validateStoreCreateStep1(values); + if (step1Msg) { + setCreateError(step1Msg); + setCreateStep(0); + return; + } + const step3Msg = validateStoreCreateStep3(values); + if (step3Msg) { + setCreateError(step3Msg); + return; + } + + const envPhotoUrls = (values.envPhotoUrls ?? []).map((u) => u?.trim()).filter(Boolean) as string[]; + await request('/admin/stores', { + method: 'POST', + body: JSON.stringify({ + partnerId: values.partnerId, + cityId: values.cityId, + name: values.name.trim(), + phone: values.phone.trim(), + district: values.district.trim(), + address: values.address.trim(), + intro: values.intro?.trim() || undefined, + coverUrl: values.coverUrl?.trim() || undefined, + envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, + contractUrl: values.contractUrl?.trim() || undefined, + bankAccountName: values.bankAccountName.trim(), + bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), + bankBranch: values.bankBranch.trim(), + accountPhone: values.accountPhone?.trim() || undefined, + accountName: values.accountName?.trim() || undefined, + }), + }); + message.success('门店已创建'); + closeCreateModal(); + void reload(); + } + + const filteredCities = selectedPartnerId + ? cities.filter((c) => String(c.partnerId ?? '') === selectedPartnerId) + : cities; + const columns: ColumnsType = [ { title: '封面', dataIndex: 'coverUrl', width: 72, @@ -91,7 +199,7 @@ export default function StoresPage() {
门店 - +
{ setFilters(v); setPage(1); }}> @@ -148,31 +256,111 @@ export default function StoresPage() { )} - setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - await request('/admin/stores', { method: 'POST', body: JSON.stringify(v) }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); - }}> + + {createStep > 0 && } + {createStep < 2 ? ( + + ) : ( + + )} + + )} + > + + {createError && ( + + )} - - ({ value: c.id, label: `${c.name} (${c.code})` }))} /> - - - - - - - - - - - + {createStep === 0 && ( + <> + + ({ value: c.id, label: `${c.name} (${c.code})` }))} + /> + + + + + + + + + + + + + + + + + + + + + + + + )} + {createStep === 1 && ( + <> + + preV1 照片上传为选填,可直接下一步(与合伙人端一致)。 + + + + + 环境照片 + + {(fields) => ( +
+ {fields.map((field, index) => ( + + + + ))} +
+ )} +
+ + + + + )} + {createStep === 2 && ( + <> + + + + + + + + + + + + )}
diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts index 3557b26..29f43e0 100644 --- a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts +++ b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts @@ -97,6 +97,9 @@ export class OssAliyunProvider implements IOssProvider { const ossKey = buildOssObjectKey(this.uploadPrefix, input.bizType, input.fileName); await client.put(ossKey, input.buffer, { mime: input.mimeType || 'application/octet-stream', + headers: { + 'Content-Disposition': 'inline', + }, }); return { bucket: this.bucket, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 3e32e65..ebb5e4a 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -136,6 +136,9 @@ export class AdminStoresService { if (!partner) throw new BadRequestException('开城合伙人不存在'); const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } }); if (!city) throw new BadRequestException('开城城市不存在'); + if (city.partnerId && city.partnerId !== partner.id) { + throw new BadRequestException('开城城市与合伙人不匹配'); + } const store = await this.prisma.store.create({ data: { @@ -149,10 +152,73 @@ export class AdminStoresService { district: dto.district ?? '', address: dto.address, intro: dto.intro ?? null, + bankAccountName: dto.bankAccountName ?? null, + bankAccountNo: dto.bankAccountNo ?? null, + bankBranch: dto.bankBranch ?? null, + openTime: '10:00', + closeTime: '22:00', status: 'OPEN', }, }); + if (dto.coverUrl) { + const cover = await this.prisma.commonResource.create({ + data: { + ownerType: 'STORE', + ownerId: store.id, + bizType: 'COVER', + mediaType: 'IMAGE', + ossBucket: 'legacy', + ossKey: dto.coverUrl, + url: dto.coverUrl, + }, + }); + await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); + } + + const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean); + for (let i = 0; i < envUrls.length; i++) { + await this.prisma.commonResource.create({ + data: { + ownerType: 'STORE', + ownerId: store.id, + bizType: 'ENV', + mediaType: 'IMAGE', + ossBucket: 'legacy', + ossKey: envUrls[i], + url: envUrls[i], + sortOrder: i, + }, + }); + } + + if (dto.contractUrl) { + await this.prisma.commonResource.create({ + data: { + ownerType: 'STORE', + ownerId: store.id, + bizType: 'CONTRACT', + mediaType: 'FILE', + ossBucket: 'legacy', + ossKey: dto.contractUrl, + url: dto.contractUrl, + }, + }); + } + + await this.prisma.commonEvent.create({ + data: { + eventType: 'STORE_AUDIT', + refType: 'STORE', + refId: store.id, + actorType: 'HQ', + status: 'APPROVED', + param1: 'NEW', + param1Desc: 'audit_type', + remark: 'HQ 后台新建', + }, + }); + await this.prisma.storeAccount.create({ data: { storeId: store.id, @@ -161,7 +227,7 @@ export class AdminStoresService { }, }); - return serializeBigInt(store); + return this.detailStore(store.id); } async createStoreAccount(dto: CreateStoreAccountDto) { diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index ae8fdf7..10bf26d 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -58,6 +58,27 @@ export class CreateStoreDto { @IsOptional() @IsString() accountName?: string; + + @IsOptional() + @IsString() + bankAccountName?: string; + + @IsOptional() + @IsString() + bankAccountNo?: string; + + @IsOptional() + @IsString() + bankBranch?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + envPhotoUrls?: string[]; + + @IsOptional() + @IsString() + contractUrl?: string; } export class UpdateStoreDto {