oss接通了80端口,还没有接通https,先用
This commit is contained in:
@@ -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<StoreCreateForm, 'partnerId' | 'cityId' | 'name' | 'phone' | 'district' | 'address' | 'intro'>): 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<StoreCreateForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>): 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;
|
||||
}
|
||||
@@ -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<StoreCreateForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||
'/admin/stores',
|
||||
@@ -46,9 +71,13 @@ export default function StoresPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | 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<PartnerOption[]>([]);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerId', createForm);
|
||||
|
||||
async function loadOptions() {
|
||||
const [p, c] = await Promise.all([
|
||||
request<Paginated<PartnerOption>>('/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<StoreRow> = [
|
||||
{
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
@@ -91,7 +199,7 @@ export default function StoresPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadOptions(); setCreateOpen(true); }}>新建门店</Button>
|
||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
@@ -148,31 +256,111 @@ export default function StoresPage() {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建门店" open={createOpen} width={560} onCancel={() => 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();
|
||||
}}>
|
||||
<Modal
|
||||
title="新建门店"
|
||||
open={createOpen}
|
||||
width={640}
|
||||
onCancel={closeCreateModal}
|
||||
destroyOnClose
|
||||
footer={(
|
||||
<Space>
|
||||
{createStep > 0 && <Button onClick={() => { setCreateError(''); setCreateStep((s) => s - 1); }}>上一步</Button>}
|
||||
{createStep < 2 ? (
|
||||
<Button type="primary" onClick={() => void handleCreateNext()}>下一步</Button>
|
||||
) : (
|
||||
<Button type="primary" onClick={() => void handleCreateSubmit()}>提交</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Steps current={createStep} items={CREATE_STEPS} style={{ marginBottom: 24 }} />
|
||||
{createError && (
|
||||
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="门店名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="门店电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="accountPhone" label="店长手机"><Input placeholder="默认同门店电话" /></Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名"><Input placeholder="默认同门店名" /></Form.Item>
|
||||
{createStep === 0 && (
|
||||
<>
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onChange={() => createForm.setFieldValue('cityId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true, message: '请选择开城城市' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={filteredCities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||
<Input placeholder="请输入门店名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
|
||||
<Input placeholder="11位手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县" rules={[{ required: true, message: '请填写区县' }]}>
|
||||
<Input placeholder="例如:金水区" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="门店简介">
|
||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountPhone" label="店长手机">
|
||||
<Input placeholder="默认同门店电话" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名">
|
||||
<Input placeholder="默认同门店名" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{createStep === 1 && (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
||||
</Typography.Paragraph>
|
||||
<Form.Item name="coverUrl" label="门头照">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Typography.Text strong>环境照片</Typography.Text>
|
||||
<Form.List name="envPhotoUrls">
|
||||
{(fields) => (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||
<OssUpload bizType="ENV" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="contractUrl" label="签约合同">
|
||||
<OssUpload bizType="CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{createStep === 2 && (
|
||||
<>
|
||||
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
|
||||
<Input placeholder="银行卡实名姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="银行卡号" rules={[{ required: true, message: '请填写银行卡号' }]}>
|
||||
<Input placeholder="16~19位银行卡号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
|
||||
<Input placeholder="例如:中国工商银行洛阳分行" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请确保银行卡信息准确,以免影响门店餐费结算。"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user