oss接通了80端口,还没有接通https,先用

This commit is contained in:
2026-07-02 16:13:45 +08:00
parent ff4622ff6c
commit 4b2589156c
5 changed files with 351 additions and 30 deletions
+43
View File
@@ -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;
}
+212 -24
View File
@@ -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 }))} />
{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 }]}>
<Select showSearch optionFilterProp="label" options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} />
<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 }]}><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="封面图">
<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>
<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>
<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 {