门店开通流程
This commit is contained in:
@@ -106,7 +106,7 @@ export default function StoreMediaPage() {
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||
{({ getFieldValue }) => (
|
||||
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
@@ -126,7 +126,7 @@ export default function StoreMediaPage() {
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||
{({ getFieldValue }) => (
|
||||
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
@@ -249,16 +249,20 @@ export default function StoresPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||
</Space>
|
||||
<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>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
@@ -298,7 +302,7 @@ export default function StoresPage() {
|
||||
<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="coverUrl" label="封面图">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
@@ -387,7 +391,7 @@ export default function StoresPage() {
|
||||
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
||||
</Typography.Paragraph>
|
||||
<Form.Item name="coverUrl" label="门头照">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Typography.Text strong>环境照片</Typography.Text>
|
||||
<Form.List name="envPhotoUrls">
|
||||
@@ -395,14 +399,14 @@ export default function StoresPage() {
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||
<OssUpload bizType="ENV" mediaType="IMAGE" />
|
||||
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="contractUrl" label="签约合同">
|
||||
<OssUpload bizType="CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { codeToText } from 'element-china-area-data';
|
||||
import { CHINA_REGION_OPTIONS, formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||
|
||||
type RegionNode = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: RegionNode[];
|
||||
};
|
||||
|
||||
type ChinaRegionPickerProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const STEP_LABELS = ['选择省份', '选择城市', '选择区县'] as const;
|
||||
|
||||
function lockBodyScroll(lock: boolean) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const body = document.body;
|
||||
if (lock) {
|
||||
const scrollY = window.scrollY;
|
||||
body.dataset.dukangScrollY = String(scrollY);
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.overflow = 'hidden';
|
||||
} else {
|
||||
const scrollY = Number(body.dataset.dukangScrollY ?? '0');
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.overflow = '';
|
||||
delete body.dataset.dukangScrollY;
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChinaRegionPicker({ value = [], onChange, disabled }: ChinaRegionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(value);
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const options = CHINA_REGION_OPTIONS as RegionNode[];
|
||||
const parsed = parseRegionCodes(value);
|
||||
const display = parsed ? formatRegionLabel(parsed) : '请选择省 / 市 / 区县';
|
||||
|
||||
const provinces = options;
|
||||
const cities = useMemo(() => {
|
||||
const p = provinces.find((item) => item.value === draft[0]);
|
||||
return p?.children ?? [];
|
||||
}, [draft, provinces]);
|
||||
|
||||
const districts = useMemo(() => {
|
||||
const c = cities.find((item) => item.value === draft[1]);
|
||||
return c?.children ?? [];
|
||||
}, [draft, cities]);
|
||||
|
||||
const listItems = step === 0 ? provinces : step === 1 ? cities : districts;
|
||||
const activeCode = draft[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
lockBodyScroll(true);
|
||||
return () => lockBodyScroll(false);
|
||||
}, [open]);
|
||||
|
||||
function openPicker() {
|
||||
if (disabled) return;
|
||||
const next = value.length === 3 ? [...value] : [];
|
||||
setDraft(next);
|
||||
setStep(next.length >= 3 ? 2 : next.length);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function selectItem(code: string) {
|
||||
const next = [...draft.slice(0, step), code];
|
||||
setDraft(next);
|
||||
if (step < 2) {
|
||||
setStep(step + 1);
|
||||
return;
|
||||
}
|
||||
onChange?.(next);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (draft.length < 3) return;
|
||||
onChange?.(draft);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
const modal = open ? (
|
||||
<div className="partner-region-overlay" role="presentation" onClick={closePicker}>
|
||||
<div
|
||||
className="partner-region-sheet"
|
||||
role="dialog"
|
||||
aria-modal
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="partner-region-sheet-header">
|
||||
<button type="button" className="partner-region-sheet-btn" onClick={closePicker}>取消</button>
|
||||
<span className="headline-md">选择地区</span>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-region-sheet-btn partner-region-sheet-btn--primary"
|
||||
disabled={draft.length < 3}
|
||||
onClick={confirm}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="partner-region-steps">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`partner-region-step${step === index ? ' partner-region-step--active' : ''}${draft[index] ? ' partner-region-step--done' : ''}`}
|
||||
disabled={index === 1 && !draft[0] || index === 2 && !draft[1]}
|
||||
onClick={() => {
|
||||
if (index === 1 && !draft[0]) return;
|
||||
if (index === 2 && !draft[1]) return;
|
||||
setStep(index);
|
||||
}}
|
||||
>
|
||||
<span className="partner-region-step-index">{index + 1}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft[0] && (
|
||||
<p className="partner-region-breadcrumb label-md text-muted">
|
||||
{codeToText[draft[0]]}
|
||||
{draft[1] ? ` / ${codeToText[draft[1]]}` : ''}
|
||||
{draft[2] ? ` / ${codeToText[draft[2]]}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="partner-region-list" role="listbox">
|
||||
{listItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={activeCode === item.value}
|
||||
className={`partner-region-item${activeCode === item.value ? ' partner-region-item--active' : ''}`}
|
||||
onClick={() => selectItem(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="partner-field-input partner-region-trigger" onClick={openPicker} disabled={disabled}>
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span className={parsed ? '' : 'text-muted'}>{display}</span>
|
||||
<span className="material-symbols-outlined partner-region-chevron">expand_more</span>
|
||||
</button>
|
||||
{typeof document !== 'undefined' ? createPortal(modal, document.body) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
type OssUploadFieldProps = {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
accept?: string;
|
||||
wide?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
wide,
|
||||
compact,
|
||||
label,
|
||||
}: OssUploadFieldProps) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO';
|
||||
|
||||
async function uploadSelectedFile(file: File) {
|
||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||
setError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if (uploading) return;
|
||||
setError('');
|
||||
|
||||
if (useWechatPicker) {
|
||||
try {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await uploadSelectedFile(files[0]);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (!/cancel/i.test(msg)) setError(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
const isFile = mediaType === 'FILE' && value;
|
||||
|
||||
const triggerProps = {
|
||||
type: 'button' as const,
|
||||
disabled: uploading,
|
||||
onClick: () => void pickFile(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={uploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadSelectedFile(file);
|
||||
}}
|
||||
/>
|
||||
{isImage ? (
|
||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||
<img src={value} alt={label ?? '已上传'} />
|
||||
<span className="partner-upload-preview-mask">
|
||||
<span className="material-symbols-outlined">{uploading ? 'hourglass_top' : 'edit'}</span>
|
||||
<span>{uploading ? '上传中…' : '更换'}</span>
|
||||
</span>
|
||||
</button>
|
||||
) : isFile ? (
|
||||
<button {...triggerProps} className="partner-upload-file">
|
||||
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
|
||||
<span className="material-symbols-outlined text-primary">description</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}>已上传合同</span>
|
||||
<span className="label-md text-muted">{uploading ? '上传中…' : '点击更换'}</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
{...triggerProps}
|
||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading ? '上传中…' : (label ?? (useWechatPicker ? '从相册选择' : '点击上传'))}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="partner-form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
};
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
export function districtCodeToCityCode(districtCode: string): string {
|
||||
if (districtCode.length < 6) return districtCode;
|
||||
return `${districtCode.slice(0, 4)}00`;
|
||||
}
|
||||
|
||||
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
||||
if (!codes || codes.length < 3) return null;
|
||||
const [provinceCode, cityCode, districtCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
const city = codeToText[cityCode];
|
||||
const district = codeToText[districtCode];
|
||||
if (!province || !city || !district) return null;
|
||||
return { province, city, district, provinceCode, cityCode, districtCode };
|
||||
}
|
||||
|
||||
export function formatRegionLabel(region: ParsedChinaRegion): string {
|
||||
return `${region.province} / ${region.city} / ${region.district}`;
|
||||
}
|
||||
|
||||
export function matchOpenCityId(cities: OpenCityRef[], districtCode: string): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
return (
|
||||
cities.find((c) => c.code === cityCode)?.id
|
||||
?? cities.find((c) => c.code === districtCode)?.id
|
||||
?? cities.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRegionBinding(codes: string[], cities: OpenCityRef[]) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
cityId,
|
||||
matchedCity,
|
||||
cityCode: districtCodeToCityCode(region.districtCode),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
export type StoreDraftForm = {
|
||||
regionCodes: string[];
|
||||
cityId: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro: string;
|
||||
accountPhone: string;
|
||||
accountName: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
contractUrl: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
@@ -14,27 +23,55 @@ export type StoreDraft = {
|
||||
form: StoreDraftForm;
|
||||
};
|
||||
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v1';
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v2';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
regionCodes: [],
|
||||
cityId: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
district: '金水区',
|
||||
address: '',
|
||||
intro: '',
|
||||
accountPhone: '',
|
||||
accountName: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
contractUrl: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
||||
const items = raw.map((item) => String(item ?? ''));
|
||||
while (items.length < length) items.push('');
|
||||
return items.slice(0, length);
|
||||
}
|
||||
|
||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
const regionCodes = Array.isArray(raw.regionCodes)
|
||||
? raw.regionCodes.map((code) => String(code))
|
||||
: base.regionCodes;
|
||||
return {
|
||||
regionCodes,
|
||||
cityId: String(raw.cityId ?? base.cityId),
|
||||
province: String(raw.province ?? base.province),
|
||||
city: String(raw.city ?? base.city),
|
||||
district: String(raw.district ?? base.district),
|
||||
name: String(raw.name ?? base.name),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
district: String(raw.district ?? base.district),
|
||||
address: String(raw.address ?? base.address),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
accountPhone: String(raw.accountPhone ?? base.accountPhone),
|
||||
accountName: String(raw.accountName ?? base.accountName),
|
||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
||||
@@ -69,11 +106,14 @@ export function clearStoreDraft() {
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreStep1(form: StoreDraftForm): string | null {
|
||||
export function validateStoreStep1(
|
||||
form: Pick<StoreDraftForm, 'regionCodes' | 'cityId' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
): string | null {
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) 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;
|
||||
@@ -82,7 +122,9 @@ export function validateStoreStep1(form: StoreDraftForm): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStoreStep3(form: StoreDraftForm): string | null {
|
||||
export function validateStoreStep3(
|
||||
form: Pick<StoreDraftForm, '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 位数字';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
|
||||
export function storeStatusLabel(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '暂时闭店';
|
||||
if (s === 'CLOSED') return '关闭';
|
||||
return status;
|
||||
}
|
||||
|
||||
export function storeStatusPillClass(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return 'partner-status-pill--open';
|
||||
if (s === 'PAUSED') return 'partner-status-pill--paused';
|
||||
return 'partner-status-pill--closed';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { apiBase, request } from './api';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_H5' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||
|
||||
const data = json.data as UploadFileResult;
|
||||
return {
|
||||
url: data.url,
|
||||
ossKey: data.ossKey,
|
||||
bucket: data.bucket,
|
||||
mock: data.mock,
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenCityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
province: string;
|
||||
};
|
||||
|
||||
export async function fetchPartnerCities(): Promise<OpenCityOption[]> {
|
||||
return request<OpenCityOption[]>('PARTNER_H5', '/partner/stores/cities');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'PARTNER_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -1,27 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
import { storeStatusLabel } from '../lib/storeStatus';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function auditStatus(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
return String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
function storeStatusLabel(store: Record<string, unknown>) {
|
||||
const audit = auditStatus(store);
|
||||
if (audit === 'PENDING') return '待审核';
|
||||
const s = String(store.status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '暂时闭店';
|
||||
if (s === 'CLOSED') return '已关闭';
|
||||
return s;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
@@ -37,10 +23,6 @@ export default function HomePage() {
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||
const pendingStores = useMemo(
|
||||
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
|
||||
[stores],
|
||||
);
|
||||
const revenue = orderCount * 128.45;
|
||||
const profit = revenue * 0.25;
|
||||
const pendingShip = Math.ceil(orderCount * 0.04);
|
||||
@@ -173,25 +155,8 @@ export default function HomePage() {
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingStores.length > 0 && (
|
||||
<div className="partner-pending-stores">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>待审核门店名称</p>
|
||||
<ul className="partner-store-name-list partner-store-name-list--compact">
|
||||
{pendingStores.map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayStores.length > 0 && (
|
||||
<ul className="partner-store-name-list">
|
||||
<li className="partner-store-name-list-header">
|
||||
@@ -201,7 +166,7 @@ export default function HomePage() {
|
||||
{displayStores.slice(0, 5).map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
<span className="label-md text-muted">{storeStatusLabel(s)}</span>
|
||||
<span className="label-md text-muted">{storeStatusLabel(String(s.status))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import { request } from '../lib/api';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||
import {
|
||||
clearStoreDraft,
|
||||
defaultStoreForm,
|
||||
@@ -20,6 +24,9 @@ export default function StoreCreatePage() {
|
||||
const saved = loadStoreDraft();
|
||||
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
||||
@@ -34,11 +41,45 @@ export default function StoreCreatePage() {
|
||||
saveStoreDraft({ step, form });
|
||||
}, [step, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPartnerCities()
|
||||
.then((items) => {
|
||||
setCities(items);
|
||||
if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
|
||||
})
|
||||
.catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败'));
|
||||
}, []);
|
||||
|
||||
const regionBindingHint = useMemo(() => {
|
||||
if (!form.regionCodes.length) return null;
|
||||
const binding = resolveRegionBinding(form.regionCodes, cities);
|
||||
if (!binding) return null;
|
||||
if (binding.cityId && binding.matchedCity) {
|
||||
return `已匹配开城城市:${binding.matchedCity.name}`;
|
||||
}
|
||||
return '所选地区暂未开城,请联系总部配置对应区划';
|
||||
}, [form.regionCodes, cities]);
|
||||
|
||||
function patchForm(patch: Partial<StoreDraftForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
setError('');
|
||||
}
|
||||
|
||||
function bindRegionSelection(codes: string[]) {
|
||||
const binding = resolveRegionBinding(codes, cities);
|
||||
if (!binding) {
|
||||
patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' });
|
||||
return;
|
||||
}
|
||||
patchForm({
|
||||
regionCodes: codes,
|
||||
cityId: binding.cityId ?? '',
|
||||
province: binding.region.province,
|
||||
city: binding.region.city,
|
||||
district: binding.region.district,
|
||||
});
|
||||
}
|
||||
|
||||
function goStep(n: number) {
|
||||
setParams({ step: String(n) });
|
||||
setError('');
|
||||
@@ -58,21 +99,38 @@ export default function StoreCreatePage() {
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
if (step1Msg) { setError(step1Msg); return; }
|
||||
|
||||
await request('PARTNER_H5', '/partner/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
district: form.district.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim() || undefined,
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
}),
|
||||
});
|
||||
clearStoreDraft();
|
||||
navigate('/stores');
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
cityId: form.cityId,
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
district: form.district.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim() || undefined,
|
||||
coverUrl: form.coverUrl.trim() || undefined,
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
contractUrl: form.contractUrl.trim() || undefined,
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
accountPhone: form.accountPhone.trim() || undefined,
|
||||
accountName: form.accountName.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
clearStoreDraft();
|
||||
navigate(`/stores/${result.store.id}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
@@ -102,8 +160,8 @@ export default function StoreCreatePage() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<p className="partner-form-error" role="alert">{error}</p>
|
||||
{(error || citiesError) && (
|
||||
<p className="partner-form-error" role="alert">{error || citiesError}</p>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
@@ -113,6 +171,15 @@ export default function StoreCreatePage() {
|
||||
<div className="partner-section-bar" />
|
||||
<h2 className="headline-md">门店基本信息</h2>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>所在地区 <span className="text-primary">*</span></label>
|
||||
<ChinaRegionPicker value={form.regionCodes} onChange={bindRegionSelection} />
|
||||
{regionBindingHint && (
|
||||
<p className={`label-md${form.cityId ? ' text-muted' : ' text-primary'}`} style={{ marginTop: 8 }}>
|
||||
{regionBindingHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店名称 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
@@ -127,13 +194,6 @@ export default function StoreCreatePage() {
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>区县 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>详细地址 <span className="text-primary">*</span></label>
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||
@@ -145,6 +205,20 @@ export default function StoreCreatePage() {
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长手机</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
<input type="tel" placeholder="默认同门店电话" value={form.accountPhone} onChange={(e) => patchForm({ accountPhone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长姓名</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<input placeholder="默认同门店名" value={form.accountName} onChange={(e) => patchForm({ accountName: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner">
|
||||
<div className="partner-bills-icon">
|
||||
@@ -170,45 +244,57 @@ export default function StoreCreatePage() {
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">门头照 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少1张,需包含完整招牌</p>
|
||||
<button type="button" className="partner-upload-dashed partner-upload-dashed--wide">
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 36 }}>add_a_photo</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>点击或拖拽上传</span>
|
||||
</button>
|
||||
<h3 className="headline-md">门头照</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>建议至少 1 张,需包含完整招牌</p>
|
||||
<OssUploadField
|
||||
wide
|
||||
bizType="STORE_TITLE"
|
||||
mediaType="IMAGE"
|
||||
value={form.coverUrl}
|
||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||
label="点击或拖拽上传"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">环境照片 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少3张,展示店内整洁环境</p>
|
||||
<h3 className="headline-md">环境照片</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>建议至少 3 张,展示店内整洁环境</p>
|
||||
<div className="partner-upload-grid">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button key={i} type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
{form.envPhotoUrls.map((url, index) => (
|
||||
<OssUploadField
|
||||
key={index}
|
||||
compact
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
label="添加照片"
|
||||
onChange={(nextUrl) => {
|
||||
const envPhotoUrls = [...form.envPhotoUrls];
|
||||
envPhotoUrls[index] = nextUrl;
|
||||
patchForm({ envPhotoUrls });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
<h3 className="headline-md">签约合同 *</h3>
|
||||
<h3 className="headline-md">签约合同</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
<button type="button" className="partner-upload-dashed" style={{ flexDirection: 'row', height: 96 }}>
|
||||
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
|
||||
<span className="material-symbols-outlined text-primary">description</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}>上传合同副本</span>
|
||||
<span className="label-md text-muted">支持 JPG, PNG, PDF</span>
|
||||
</div>
|
||||
</button>
|
||||
<OssUploadField
|
||||
bizType="STORE_CONTRACT"
|
||||
mediaType="FILE"
|
||||
accept="image/*,.pdf"
|
||||
value={form.contractUrl}
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
label="上传合同副本"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
|
||||
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
|
||||
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
|
||||
温馨提示:preV1 占位上传,可直接下一步。
|
||||
照片与合同将上传至 OSS 存储;与总部后台一致,选填项可跳过直接下一步。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -245,7 +331,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
{step > 1 && (
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}>上一步</button>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={submitting}>上一步</button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<button type="button" className="partner-btn-primary" onClick={handleNext}>
|
||||
@@ -253,7 +339,9 @@ export default function StoreCreatePage() {
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="partner-btn-primary" onClick={submit}>提交审核</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -3,59 +3,143 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
|
||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||
const [status, setStatus] = useState('OPEN');
|
||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
function showToast(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(''), 2000);
|
||||
}
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
setForm({
|
||||
name: String(data.name || ''),
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
});
|
||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
request('PARTNER_H5', `/stores/${id}`).then((data) => {
|
||||
setStore(data);
|
||||
setForm({
|
||||
name: String(data.name || ''),
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
});
|
||||
setStatus(String(data.status || 'OPEN').toUpperCase());
|
||||
if (!id) return;
|
||||
setLoadError('');
|
||||
request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}`)
|
||||
.then(applyStore)
|
||||
.catch((e) => {
|
||||
setStore(null);
|
||||
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
function save() {
|
||||
setToast('preV1:保存成功(演示)');
|
||||
setTimeout(() => setToast(''), 2000);
|
||||
async function changeStatus(next: StoreStatusValue) {
|
||||
if (!id || next === status || statusSaving) return;
|
||||
if (status === 'CLOSED') return;
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
showToast('状态已更新');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBasic() {
|
||||
if (!id || saving || status === 'CLOSED') return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim(),
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
showToast('已保存');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
<div className="empty">{loadError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!store) return <div className="empty">加载中...</div>;
|
||||
|
||||
const statusLabel = status === 'OPEN' ? '营业中' : status === 'PAUSED' ? '暂停服务' : '已下线';
|
||||
const envPhotos = Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
||||
: [];
|
||||
const readOnly = status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
{error && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
<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>
|
||||
<span className="partner-status-pill partner-status-pill--open">
|
||||
<span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />
|
||||
{statusLabel}
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
|
||||
{status === 'OPEN' && <span className="partner-dot partner-dot--green" style={{ width: 8, height: 8, display: 'inline-block', marginRight: 4 }} />}
|
||||
{storeStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-status-toggle">
|
||||
{(['OPEN', 'PAUSED', 'CLOSED'] as const).map((s) => (
|
||||
<button key={s} type="button" className={status === s ? 'active' : ''} onClick={() => setStatus(s)}>
|
||||
{s === 'OPEN' ? '营业中' : s === 'PAUSED' ? '暂停服务' : '已下线'}
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={status === s ? 'active' : ''}
|
||||
disabled={readOnly || statusSaving}
|
||||
onClick={() => void changeStatus(s)}
|
||||
>
|
||||
{storeStatusLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{readOnly && (
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>门店已关闭,不可再变更状态或编辑资料</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
@@ -69,22 +153,22 @@ export default function StoreDetailPage() {
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店名称</label>
|
||||
<input 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 }} />
|
||||
<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 }} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店地址</label>
|
||||
<textarea rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
@@ -94,14 +178,19 @@ 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">最多12张</span>
|
||||
</div>
|
||||
<div className="partner-photo-grid">
|
||||
<button type="button" className="partner-photo-add">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span className="label-md">添加照片</span>
|
||||
</button>
|
||||
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length} 张` : '暂无照片'}</span>
|
||||
</div>
|
||||
{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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="label-md text-muted">录入门店时上传的环境照片将显示在这里</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
|
||||
@@ -109,7 +198,7 @@ export default function StoreDetailPage() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<label className="label-md text-muted">状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{String(store.status)}</p>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-md text-muted">门店ID</label>
|
||||
@@ -120,10 +209,10 @@ export default function StoreDetailPage() {
|
||||
</main>
|
||||
|
||||
<footer className="partner-save-footer">
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>取消</button>
|
||||
<button type="button" className="partner-save-submit" onClick={save}>
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
保存修改
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
|
||||
type StatusFilter = 'ALL' | 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
|
||||
function statusPill(status: string) {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return { cls: 'partner-status-pill--open', label: '营业中' };
|
||||
if (s === 'PAUSED') return { cls: 'partner-status-pill--paused', label: '暂时闭店' };
|
||||
return { cls: 'partner-status-pill--closed', label: '已关闭' };
|
||||
}
|
||||
type StatusFilter = 'ALL' | StoreStatusValue;
|
||||
|
||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'OPEN', label: '营业中' },
|
||||
{ key: 'PAUSED', label: '暂时闭店' },
|
||||
{ key: 'CLOSED', label: '已关闭' },
|
||||
{ key: 'CLOSED', label: '关闭' },
|
||||
];
|
||||
|
||||
function auditLabel(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
const s = String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
if (s === 'PENDING') return { cls: 'partner-status-pill--paused', label: '待审核' };
|
||||
if (s === 'REJECTED') return { cls: 'partner-status-pill--closed', label: '已驳回' };
|
||||
if (s === 'APPROVED') return { cls: 'partner-status-pill--open', label: '已通过' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, [navigate]);
|
||||
void loadStores();
|
||||
}, [navigate, loadStores]);
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
@@ -44,6 +35,26 @@ export default function StoreListPage() {
|
||||
return matchQ && matchStatus;
|
||||
}), [stores, q, filter]);
|
||||
|
||||
async function updateStatus(storeId: string, next: StoreStatusValue) {
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
}
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadStores();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-store-page">
|
||||
<header className="header app-page-header">
|
||||
@@ -55,6 +66,8 @@ export default function StoreListPage() {
|
||||
<p className="text-muted body-md">管理您的合作门店及其运营状态</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
@@ -79,29 +92,56 @@ export default function StoreListPage() {
|
||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||
|
||||
{filtered.map((s) => {
|
||||
const pill = statusPill(String(s.status));
|
||||
const audit = auditLabel(s);
|
||||
const dim = String(s.status).toUpperCase() === 'CLOSED';
|
||||
const storeId = String(s.id);
|
||||
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
|
||||
const dim = currentStatus === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
const busy = updatingId === storeId;
|
||||
return (
|
||||
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
{audit && <span className={`partner-status-pill ${audit.cls}`}>{audit.label}</span>}
|
||||
</div>
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||
{storeStatusLabel(currentStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="partner-store-card-actions">
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px' }}>暂时闭店</button>
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}>正式关闭</button>
|
||||
<Link to={`/stores/${s.id}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED'}
|
||||
onClick={() => void updateStatus(storeId, 'PAUSED')}
|
||||
>
|
||||
暂时闭店
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
|
||||
disabled={busy || currentStatus === 'CLOSED'}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED')}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
{currentStatus === 'PAUSED' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy}
|
||||
onClick={() => void updateStatus(storeId, 'OPEN')}
|
||||
>
|
||||
恢复营业
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -926,6 +926,230 @@ html, body, #root {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.partner-upload-dashed--compact {
|
||||
padding: 12px;
|
||||
font-size: 10px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.partner-oss-upload-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.partner-upload-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.partner-upload-preview--wide {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.partner-upload-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.partner-upload-preview-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.partner-upload-preview:hover .partner-upload-preview-mask,
|
||||
.partner-upload-preview:focus-within .partner-upload-preview-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.partner-upload-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--color-surface-container-low);
|
||||
border: 1px solid rgba(166, 29, 36, 0.2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partner-region-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partner-region-chevron {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.partner-region-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.partner-region-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: min(78vh, 640px);
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.partner-region-sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-outline-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-region-sheet-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.partner-region-sheet-btn--primary {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.partner-region-sheet-btn--primary:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.partner-region-steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-region-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface-container-low);
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.partner-region-step--active {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
}
|
||||
|
||||
.partner-region-step--done {
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.partner-region-step-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-container-highest);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.partner-region-step--active .partner-region-step-index {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.partner-region-breadcrumb {
|
||||
padding: 8px 16px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-region-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
padding: 8px 0 calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.partner-region-columns {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.partner-region-column {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-outline-variant);
|
||||
}
|
||||
|
||||
.partner-region-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.partner-region-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.partner-region-item--active {
|
||||
color: var(--color-primary);
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.partner-sticky-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -1053,7 +1277,39 @@ html, body, #root {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partner-store-card-hit {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.partner-audit-result {
|
||||
margin: 48px var(--space-page) 0;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.partner-audit-result-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(42, 110, 187, 0.12);
|
||||
color: var(--color-status-blue);
|
||||
}
|
||||
|
||||
.partner-audit-result-icon .material-symbols-outlined {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.partner-status-pill {
|
||||
@@ -1074,6 +1330,11 @@ html, body, #root {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.partner-status-pill--auditing {
|
||||
background: rgba(42, 110, 187, 0.12);
|
||||
color: var(--color-status-blue);
|
||||
}
|
||||
|
||||
.partner-status-pill--closed {
|
||||
background: var(--color-surface-container);
|
||||
color: var(--color-subtle-gray);
|
||||
@@ -1217,7 +1478,7 @@ html, body, #root {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 200;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
/* ── Orders ── */
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type ChooseWechatImageOptions = {
|
||||
count?: number;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
};
|
||||
|
||||
function base64ToFile(base64: string, fileName: string): File {
|
||||
const normalized = base64.startsWith('data:')
|
||||
? base64
|
||||
: `data:image/jpeg;base64,${base64.replace(/\s/g, '')}`;
|
||||
const [header, body] = normalized.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(body);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new File([bytes], fileName, { type: mime });
|
||||
}
|
||||
|
||||
function localIdToFile(localId: string): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.getLocalImgData) {
|
||||
reject(new Error('微信 getLocalImgData 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getLocalImgData({
|
||||
localId,
|
||||
success: (res) => {
|
||||
try {
|
||||
resolve(base64ToFile(res.localData, `wx-${Date.now()}.jpg`));
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error('图片解析失败'));
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(new Error(err.errMsg || '读取图片失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 微信内选图(相册/拍照),返回 File 列表;非微信环境返回 null */
|
||||
export async function chooseWechatImages(
|
||||
config: WeixinSdkConfig,
|
||||
options: ChooseWechatImageOptions = {},
|
||||
): Promise<File[] | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'browser') return null;
|
||||
|
||||
const count = options.count ?? 1;
|
||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
if (!window.wx?.chooseImage) return null;
|
||||
|
||||
const localIds = await new Promise<string[]>((resolve, reject) => {
|
||||
window.wx!.chooseImage!({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: (res) => resolve(res.localIds ?? []),
|
||||
fail: (err) => reject(new Error(err.errMsg || '无法打开相册')),
|
||||
});
|
||||
});
|
||||
|
||||
const files: File[] = [];
|
||||
for (const localId of localIds) {
|
||||
files.push(await localIdToFile(localId));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canUseWechatChooseImage(): boolean {
|
||||
return getRuntimePlatform() !== 'browser';
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export {
|
||||
} from './location';
|
||||
export { scanQrCode } from './scan';
|
||||
export { invokeWechatPay } from './pay';
|
||||
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
||||
export type { ChooseWechatImageOptions } from './chooseImage';
|
||||
export {
|
||||
getWechatOAuthUrl,
|
||||
startWechatOAuthLogin,
|
||||
@@ -25,6 +27,7 @@ import { initWechatJssdk } from './jssdk';
|
||||
import { getWechatLocation } from './location';
|
||||
import { scanQrCode } from './scan';
|
||||
import { invokeWechatPay } from './pay';
|
||||
import { chooseWechatImages } from './chooseImage';
|
||||
import {
|
||||
wechatLogin,
|
||||
handleWechatOAuthCallback,
|
||||
@@ -43,6 +46,8 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
|
||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||
getLocation: () => getWechatLocation(config),
|
||||
scanQrCode: () => scanQrCode(config),
|
||||
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
||||
chooseWechatImages(config, options),
|
||||
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
|
||||
invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
|
||||
};
|
||||
|
||||
@@ -34,6 +34,18 @@ export type WxApi = {
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
cancel?: () => void;
|
||||
}) => void;
|
||||
chooseImage: (options: {
|
||||
count?: number;
|
||||
sizeType?: Array<'original' | 'compressed'>;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
success?: (res: { localIds: string[] }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocalImgData: (options: {
|
||||
localId: string;
|
||||
success?: (res: { localData: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type MiniProgramWx = {
|
||||
@@ -80,6 +92,8 @@ export const DEFAULT_JS_API_LIST = [
|
||||
'getLocation',
|
||||
'scanQRCode',
|
||||
'chooseWXPay',
|
||||
'chooseImage',
|
||||
'getLocalImgData',
|
||||
'updateAppMessageShareData',
|
||||
'updateTimelineShareData',
|
||||
] as const;
|
||||
|
||||
Generated
+6
@@ -59,6 +59,12 @@ importers:
|
||||
'@dukang/shared-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-ui
|
||||
'@dukang/weixin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/weixin-sdk
|
||||
element-china-area-data:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey } from './oss.key.util';
|
||||
import { buildOssObjectKey, resolveOssUploadDir } from './oss.key.util';
|
||||
import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
|
||||
|
||||
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
|
||||
@@ -64,13 +64,14 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
|
||||
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
|
||||
const host = resolveOssUploadHost(this.bucket, this.region);
|
||||
const keyPrefix = resolveOssUploadDir(this.uploadPrefix, dto.bizType);
|
||||
|
||||
const policy = {
|
||||
expiration: expireAt.toISOString(),
|
||||
conditions: [
|
||||
['content-length-range', 0, this.maxUploadBytes],
|
||||
['eq', '$bucket', this.bucket],
|
||||
['starts-with', '$key', `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`],
|
||||
['starts-with', '$key', keyPrefix],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/** 门店资源上传目录(OSS object key 前缀) */
|
||||
const STORE_UPLOAD_DIRS: Record<string, string> = {
|
||||
STORE_TITLE: 'store/title',
|
||||
STORE_ENV: 'store/env',
|
||||
STORE_CONTRACT: 'store/contract',
|
||||
};
|
||||
|
||||
export function resolveOssUploadDir(uploadPrefix: string, bizType: string): string {
|
||||
const storeDir = STORE_UPLOAD_DIRS[bizType];
|
||||
if (storeDir) return `${storeDir}/`;
|
||||
return `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
}
|
||||
|
||||
export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||
const dir = `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
const dir = resolveOssUploadDir(uploadPrefix, bizType);
|
||||
return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature,
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,38 @@ export class PartnerStoreController {
|
||||
return this.storeService.partnerListStores(user.actorId);
|
||||
}
|
||||
|
||||
@Get('cities')
|
||||
cities(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListCities(user.actorId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.storeService.createStore(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreStatus(user.actorId, BigInt(id), body.status);
|
||||
}
|
||||
|
||||
@Put(':id/basic')
|
||||
updateBasic(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/dashboard')
|
||||
|
||||
@@ -51,10 +51,44 @@ export class StoreService {
|
||||
return serializeBigInt(stores.map(mapStoreCompat));
|
||||
}
|
||||
|
||||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
include: { category: true, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
}
|
||||
|
||||
async partnerListCities(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true, name: true, code: true, province: true, partnerId: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
const city = await this.resolvePartnerCity(account.partnerId, 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 contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
@@ -63,12 +97,11 @@ export class StoreService {
|
||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||
name: String(body.name),
|
||||
phone: String(body.phone),
|
||||
province: String(body.province ?? '河南省'),
|
||||
cityName: String(body.city ?? '郑州市'),
|
||||
province: String(body.province ?? city.province ?? '河南省'),
|
||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null,
|
||||
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
|
||||
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
|
||||
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
|
||||
@@ -78,6 +111,52 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||
|
||||
if (coverUrl) {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket,
|
||||
ossKey: coverUrl,
|
||||
url: coverUrl,
|
||||
},
|
||||
});
|
||||
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||
}
|
||||
|
||||
for (let i = 0; i < envPhotoUrls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'ENV',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket,
|
||||
ossKey: envPhotoUrls[i],
|
||||
url: envPhotoUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (contractUrl) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: 'FILE',
|
||||
ossBucket,
|
||||
ossKey: contractUrl,
|
||||
url: contractUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const audit = await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
@@ -95,7 +174,10 @@ export class StoreService {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: String(body.accountPhone ?? body.phone),
|
||||
phone: await this.resolveStoreAccountPhone(
|
||||
String(body.accountPhone ?? body.phone),
|
||||
store.id,
|
||||
),
|
||||
name: String(body.accountName ?? body.name),
|
||||
},
|
||||
});
|
||||
@@ -103,6 +185,71 @@ export class StoreService {
|
||||
return serializeBigInt({ store, audit });
|
||||
}
|
||||
|
||||
async partnerUpdateStoreStatus(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可变更状态');
|
||||
}
|
||||
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
|
||||
throw new BadRequestException('无效的门店状态');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: { status },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat(updated));
|
||||
}
|
||||
|
||||
async partnerUpdateStoreBasic(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可编辑');
|
||||
}
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim() : undefined;
|
||||
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
|
||||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||||
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
||||
|
||||
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
||||
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
}
|
||||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||||
if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 10~500 字');
|
||||
}
|
||||
|
||||
await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
},
|
||||
});
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
}
|
||||
|
||||
async getShopStore(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
@@ -150,4 +297,26 @@ export class StoreService {
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
|
||||
if (cityId) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { id: BigInt(String(cityId)), partnerId },
|
||||
});
|
||||
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
|
||||
return city;
|
||||
}
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
return city;
|
||||
}
|
||||
|
||||
private async resolveStoreAccountPhone(phone: string, storeId: bigint): Promise<string> {
|
||||
const normalized = phone.trim();
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: normalized } });
|
||||
if (!existing) return normalized;
|
||||
const suffix = String(storeId).slice(-4);
|
||||
const candidate = `${normalized.slice(0, 15)}${suffix}`.slice(0, 20);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user