feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
type AdminCellLineProps = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||
export function AdminCellLine({
|
||||
primary,
|
||||
secondary,
|
||||
separator = ' · ',
|
||||
}: AdminCellLineProps) {
|
||||
const main = primary?.trim() || '—';
|
||||
const sub = secondary?.trim();
|
||||
const full = sub ? `${main}${separator}${sub}` : main;
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
className="admin-cell-line"
|
||||
ellipsis={{ tooltip: full }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{main}
|
||||
{sub ? (
|
||||
<span className="admin-cell-line-secondary">
|
||||
{separator}
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd';
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type PackageRow = StorePackageItemDto;
|
||||
|
||||
function emptyRow(index = 0): PackageRow {
|
||||
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
|
||||
}
|
||||
|
||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => {
|
||||
setItems(
|
||||
data.live?.length
|
||||
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
||||
: [emptyRow()],
|
||||
);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [storeId]);
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const filled = items
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || null,
|
||||
otherNotes: item.otherNotes?.trim() || null,
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.dishes || item.price);
|
||||
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) {
|
||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||
return;
|
||||
}
|
||||
if (!item.dishes) {
|
||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||
return;
|
||||
}
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`/admin/stores/${storeId}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||
}),
|
||||
});
|
||||
message.success('套餐已保存并生效');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||
/>
|
||||
|
||||
{items.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => toggleCollapse(index)}
|
||||
style={{ paddingLeft: 0, height: 'auto' }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{displayName}
|
||||
</Typography.Title>
|
||||
</Button>
|
||||
{items.length > 1 ? (
|
||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="元"
|
||||
placeholder="198"
|
||||
value={item.price === '' ? undefined : Number(item.price)}
|
||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存套餐
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaProvinceCityCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaProvinceCityCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市',
|
||||
}: ChinaProvinceCityCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaRegionCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaRegionCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市 / 区县',
|
||||
}: ChinaRegionCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { getDistrictOptionsByCityCode } from '../lib/china-region';
|
||||
|
||||
type Props = {
|
||||
cityCode?: string | null;
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
|
||||
export default function CityDistrictMultiSelect({
|
||||
cityCode,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
|
||||
const ready = Boolean(cityCode) && options.length > 0;
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || !ready}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
|
||||
}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<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="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<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="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Space } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
parseMiniHomeBanners,
|
||||
serializeMiniHomeBanners,
|
||||
} from '@dukang/shared-types';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
const MAX_BANNERS = 8;
|
||||
|
||||
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
|
||||
function parseBannersForEdit(raw?: string | null): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim()) as unknown;
|
||||
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
|
||||
return parsed
|
||||
.filter((u): u is string => typeof u === 'string')
|
||||
.map((u) => u.trim())
|
||||
.slice(0, MAX_BANNERS);
|
||||
} catch {
|
||||
return parseMiniHomeBanners(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer) */
|
||||
export function ConfigImageField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'footer',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
return (
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={value ?? ''}
|
||||
onChange={(url) => onChange?.(url ?? '')}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper) */
|
||||
export function ConfigImageListField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'swiper',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (json: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
|
||||
|
||||
useEffect(() => {
|
||||
setUrls(parseBannersForEdit(value));
|
||||
}, [value]);
|
||||
|
||||
/** 本地可含空位;写入 Form 时去掉空串 */
|
||||
function commit(next: string[]) {
|
||||
const clipped = next.slice(0, MAX_BANNERS);
|
||||
setUrls(clipped);
|
||||
onChange?.(serializeMiniHomeBanners(clipped));
|
||||
}
|
||||
|
||||
function updateAt(index: number, url: string) {
|
||||
const next = [...urls];
|
||||
next[index] = url;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
commit(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= urls.length) return;
|
||||
const next = [...urls];
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (urls.length >= MAX_BANNERS) return;
|
||||
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
|
||||
setUrls((prev) => [...prev, '']);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{urls.map((url, index) => (
|
||||
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(u) => updateAt(index, u)}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === urls.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
/>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
|
||||
</Space>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
disabled={urls.length >= MAX_BANNERS}
|
||||
onClick={add}
|
||||
>
|
||||
添加轮播图({urls.length}/{MAX_BANNERS})
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button, Form, Space, Typography } from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
type Props = {
|
||||
name?: string;
|
||||
label: string;
|
||||
bizType?: string;
|
||||
/** 最多可添加张数;不传则不限制 */
|
||||
maxCount?: number;
|
||||
};
|
||||
|
||||
export default function DetailImageUrlList({
|
||||
name = 'detailImageUrls',
|
||||
label,
|
||||
bizType = 'DETAIL',
|
||||
maxCount,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{maxCount != null && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
最多 {maxCount} 张{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
{(!maxCount || fields.length < maxCount) && (
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||
|
||||
type OssUploadProps = {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
onUploaded?: (result: UploadFileResult) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
export default function OssUpload({
|
||||
value,
|
||||
onChange,
|
||||
onUploaded,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
maxSizeMb = DEFAULT_MAX_MB,
|
||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||
}: OssUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const raw = file as File;
|
||||
if (raw.size > maxSizeMb * 1024 * 1024) {
|
||||
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
onUploaded?.(result);
|
||||
message.success('上传成功');
|
||||
onSuccess?.(result);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败');
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{value && mediaType === 'IMAGE' && (
|
||||
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
)}
|
||||
{value && mediaType === 'VIDEO' && (
|
||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||
)}
|
||||
<Space wrap>
|
||||
<Upload
|
||||
accept={resolvedAccept}
|
||||
showUploadList={false}
|
||||
customRequest={customRequest}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading}>
|
||||
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import {
|
||||
getProductDetailTemplate,
|
||||
mapDtoToProductDetailTemplate,
|
||||
type ProductDetailTemplate,
|
||||
type ProductDetailTemplateDto,
|
||||
} from '../lib/product-detail-templates';
|
||||
|
||||
type Props = {
|
||||
form: FormInstance;
|
||||
/** 当前商品香型,用于推荐匹配模板 */
|
||||
aromaType?: string;
|
||||
};
|
||||
|
||||
function hasDetailContent(form: FormInstance) {
|
||||
const storyTitle = form.getFieldValue('storyTitle') as string | undefined;
|
||||
const storyText = form.getFieldValue('storyText') as string | undefined;
|
||||
const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined;
|
||||
const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined;
|
||||
const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim());
|
||||
const hasImages = (detailImageUrls ?? []).some((u) => u?.trim());
|
||||
return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages);
|
||||
}
|
||||
|
||||
function applyTemplate(form: FormInstance, template: ProductDetailTemplate) {
|
||||
const { content } = template;
|
||||
const detailImageUrls = content.detailImageUrls?.length
|
||||
? [...content.detailImageUrls]
|
||||
: [''];
|
||||
form.setFieldsValue({
|
||||
storyTitle: content.storyTitle ?? '',
|
||||
storyText: content.storyText ?? '',
|
||||
detailImageUrls,
|
||||
features:
|
||||
content.features && content.features.length > 0
|
||||
? content.features.map((f) => ({ ...f }))
|
||||
: [{ icon: 'star', title: '', desc: '' }],
|
||||
});
|
||||
const imageHint = detailImageUrls.filter(Boolean).length;
|
||||
message.success(
|
||||
imageHint > 0
|
||||
? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)`
|
||||
: `已应用模板「${template.label}」`,
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetailTemplatePicker({ form, aromaType }: Props) {
|
||||
const [templates, setTemplates] = useState<ProductDetailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
request<Paginated<ProductDetailTemplateDto>>(
|
||||
'/admin/product-detail-templates?status=ACTIVE&pageSize=100',
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const mapped = res.items.map(mapDtoToProductDetailTemplate);
|
||||
setTemplates(mapped);
|
||||
const defaultId =
|
||||
(aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) ||
|
||||
mapped.find((t) => t.code === 'dukang-classic')?.id ||
|
||||
mapped[0]?.id;
|
||||
setSelectedId(defaultId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) message.error('加载详情模板失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [aromaType]);
|
||||
|
||||
const selected = getProductDetailTemplate(templates, selectedId ?? '');
|
||||
|
||||
function doApply() {
|
||||
if (!selected) return;
|
||||
applyTemplate(form, selected);
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (!selected) return;
|
||||
if (hasDetailContent(form)) {
|
||||
return;
|
||||
}
|
||||
doApply();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Spin size="small" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">暂无可用模板,请先在「详情模板」菜单中创建。</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
选择模板可一键填充详情长图、故事与卖点;套用后可在下方逐张替换图片。
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择模板"
|
||||
value={selectedId}
|
||||
onChange={setSelectedId}
|
||||
options={templates.map((t) => ({
|
||||
value: t.id,
|
||||
label: t.label,
|
||||
}))}
|
||||
/>
|
||||
{selected && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={selected.description}
|
||||
description={
|
||||
<div style={{ fontSize: 12 }}>
|
||||
{selected.content.storyTitle && (
|
||||
<div><strong>标题:</strong>{selected.content.storyTitle}</div>
|
||||
)}
|
||||
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
详情图:<strong>{selected.content.detailImageUrls?.length}</strong> 张(套用后可逐张修改)
|
||||
</div>
|
||||
)}
|
||||
{(selected.content.features?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
卖点:{selected.content.features?.map((f) => f.title).join('、')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Space>
|
||||
{hasDetailContent(form) ? (
|
||||
<Popconfirm
|
||||
title="将覆盖当前详情图、故事与卖点,是否继续?"
|
||||
onConfirm={doApply}
|
||||
okText="覆盖应用"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="primary">应用模板</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button type="primary" onClick={handleApply} disabled={!selected}>
|
||||
应用模板
|
||||
</Button>
|
||||
)}
|
||||
{aromaType && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const matched = templates.find((t) => t.aromaType === aromaType);
|
||||
if (matched) {
|
||||
setSelectedId(matched.id);
|
||||
if (!hasDetailContent(form)) {
|
||||
applyTemplate(form, matched);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
按香型推荐
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
QRCode,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type {
|
||||
HqProxyOrderCreateRequest,
|
||||
PartnerProxyDeliveryMode,
|
||||
PartnerProxyOrderOptions,
|
||||
PartnerProxyOrderPreviewResult,
|
||||
ProxyOrderCreateResponse,
|
||||
ProxyOrderPayResponse,
|
||||
} from '@dukang/shared-types';
|
||||
import ChinaRegionCascader from './ChinaRegionCascader';
|
||||
import { parseRegionCodes } from '../lib/china-region';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type ProxyOrderModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (order: { id: string; orderNo: string }) => void;
|
||||
};
|
||||
|
||||
type PayStatus = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
};
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
|
||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||
const [addressDetail, setAddressDetail] = useState('');
|
||||
const [productId, setProductId] = useState<string>();
|
||||
const [quantity, setQuantity] = useState(2);
|
||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||
const [autoReceive, setAutoReceive] = useState(false);
|
||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [step, setStep] = useState<'form' | 'pay'>('form');
|
||||
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
|
||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||
const [payLoading, setPayLoading] = useState(false);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||
|
||||
function stopPoll() {
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingOptions(true);
|
||||
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
|
||||
.then((data) => {
|
||||
setOptions(data);
|
||||
if (data.products[0]) setProductId(data.products[0].id);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoadingOptions(false));
|
||||
}, [open]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||
setDeliveryMode('ON_SITE_PICKUP');
|
||||
return;
|
||||
}
|
||||
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||
setDeliveryMode('ADDRESS');
|
||||
}
|
||||
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !productId || quantity < 1 || step !== 'form') {
|
||||
if (step === 'form') setPreview(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(() => setPreview(null))
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
function resetForm() {
|
||||
stopPoll();
|
||||
setPhone('');
|
||||
setReceiverName('');
|
||||
setRegionCodes([]);
|
||||
setAddressDetail('');
|
||||
setQuantity(2);
|
||||
setPromoCodeId(undefined);
|
||||
setDeliveryMode('ADDRESS');
|
||||
setAutoReceive(false);
|
||||
setPreview(null);
|
||||
setProductId(options?.products[0]?.id);
|
||||
setStep('form');
|
||||
setCreated(null);
|
||||
setCodeUrl(null);
|
||||
setPayLoading(false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||
if (!productId) return '请选择商品';
|
||||
if (deliveryMode === 'ADDRESS') {
|
||||
if (!allowOnline) return '该商品不支持线上购买';
|
||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||
if (!addressDetail.trim()) return '请填写详细地址';
|
||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||
} else if (!allowOnSite) {
|
||||
return '该商品不支持现场提货';
|
||||
}
|
||||
if (!preview) return '请等待费用计算完成';
|
||||
return null;
|
||||
}
|
||||
|
||||
function startPoll(orderId: string) {
|
||||
stopPoll();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
|
||||
.then((st) => {
|
||||
if (st.payStatus === 'PAID') {
|
||||
stopPoll();
|
||||
message.success(`支付成功:${st.orderNo}`);
|
||||
const payload = { id: st.id, orderNo: st.orderNo };
|
||||
resetForm();
|
||||
onSuccess(payload);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const err = validateForm();
|
||||
if (err) {
|
||||
message.warning(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HqProxyOrderCreateRequest = {
|
||||
phone: phone.trim(),
|
||||
deliveryMode,
|
||||
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||
receiverName: receiverName.trim() || undefined,
|
||||
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
||||
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
||||
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
||||
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
||||
productId: productId!,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setCreated(order);
|
||||
setStep('pay');
|
||||
setPayLoading(true);
|
||||
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||
});
|
||||
setCodeUrl(pay.codeUrl ?? null);
|
||||
startPoll(order.id);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '下单失败');
|
||||
setStep('form');
|
||||
setCreated(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setPayLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryLabel =
|
||||
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '现场提货'
|
||||
: preview?.deliveryType === 'CROSS_CITY'
|
||||
? '跨城配送'
|
||||
: '同城配送';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
footer={
|
||||
step === 'pay' ? (
|
||||
<Space>
|
||||
<Button onClick={handleClose}>关闭</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
|
||||
提交并收款
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{step === 'pay' && created ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, textAlign: 'left' }}
|
||||
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
|
||||
description={
|
||||
created.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '支付完成后订单即为已完成,并发放好客权益'
|
||||
: '支付完成后将进入待发货,由总部履约发货'
|
||||
}
|
||||
/>
|
||||
{payLoading && !codeUrl ? (
|
||||
<Typography.Text type="secondary">正在生成收款码…</Typography.Text>
|
||||
) : codeUrl ? (
|
||||
<Space direction="vertical" size={12} align="center">
|
||||
<QRCode value={codeUrl} size={200} />
|
||||
<Typography.Text type="secondary">请使用微信扫一扫完成支付,成功后自动关闭</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{codeUrl}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="danger">收款码生成失败,请关闭后重试</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
|
||||
/>
|
||||
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="用户手机号" required>
|
||||
<Input
|
||||
placeholder="11 位手机号"
|
||||
value={phone}
|
||||
maxLength={11}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="酒品" required>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={loadingOptions}
|
||||
placeholder="选择商品"
|
||||
value={productId}
|
||||
onChange={setProductId}
|
||||
options={(options?.products ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="数量" required>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{(options?.promoCodes.length ?? 0) > 0 ? (
|
||||
<Form.Item label="绑定推广码(选填)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不绑定"
|
||||
value={promoCodeId}
|
||||
onChange={setPromoCodeId}
|
||||
options={(options?.promoCodes ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.code} · ${p.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item label="履约方式" required>
|
||||
{allowOnline && allowOnSite ? (
|
||||
<Radio.Group
|
||||
value={deliveryMode}
|
||||
onChange={(e) => {
|
||||
setDeliveryMode(e.target.value);
|
||||
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'ADDRESS', label: '配送到址' },
|
||||
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
{!allowOnline && allowOnSite
|
||||
? '该商品仅支持现场提货'
|
||||
: allowOnline && !allowOnSite
|
||||
? allowCrossCity
|
||||
? '该商品仅支持配送到址(含跨城)'
|
||||
: '该商品仅支持配送到址(不可跨城)'
|
||||
: '该商品暂无可选履约方式'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||
<>
|
||||
<Form.Item label="收货人(选填)">
|
||||
<Input
|
||||
placeholder="默认:用户+手机尾号"
|
||||
value={receiverName}
|
||||
maxLength={32}
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="省市区" required>
|
||||
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
|
||||
</Form.Item>
|
||||
<Form.Item label="详细地址" required>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="街道门牌等"
|
||||
value={addressDetail}
|
||||
maxLength={256}
|
||||
onChange={(e) => setAddressDetail(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
|
||||
同意自动收货(配送到址必选)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
|
||||
<Typography.Text type="secondary">费用预览</Typography.Text>
|
||||
{previewLoading ? (
|
||||
<div>计算中…</div>
|
||||
) : preview ? (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
|
||||
<div>履约:{deliveryLabel}</div>
|
||||
<div>商品金额:¥{fmtMoney(preview.productAmount)}</div>
|
||||
<div>实付:¥{fmtMoney(preview.payAmount)}</div>
|
||||
<div>好客权益:¥{fmtMoney(preview.benefitAmount)}</div>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text type="secondary">请完善商品与数量</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
placeToPicked,
|
||||
type LbsPlaceItem,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
/** 城市名,提升搜索相关性 */
|
||||
region?: string | null;
|
||||
};
|
||||
|
||||
function hasCoords(lat: unknown, lng: unknown): lat is number {
|
||||
const a = typeof lat === 'number' ? lat : Number(lat);
|
||||
const b = typeof lng === 'number' ? lng : Number(lng);
|
||||
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
|
||||
}
|
||||
|
||||
export default function TencentLocPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
}: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
|
||||
const seqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setItems([]);
|
||||
setPending(null);
|
||||
setHint('输入地点名称搜索,或加载附近地点');
|
||||
return;
|
||||
}
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (hasCoords(lat, lng)) {
|
||||
void loadNearby(lat, lng);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
async function loadNearby(lat: number, lng: number) {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('正在加载附近地点…');
|
||||
try {
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
|
||||
);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '加载附近地点失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
message.warning('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('搜索中…');
|
||||
try {
|
||||
const params = new URLSearchParams({ keyword: trimmed });
|
||||
if (region?.trim()) params.set('region', region.trim());
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
params.set('lat', String(lat));
|
||||
params.set('lng', String(lng));
|
||||
}
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '搜索失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function useBrowserLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('当前浏览器不支持定位');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
|
||||
void loadNearby(lat, lng);
|
||||
},
|
||||
() => {
|
||||
setLoading(false);
|
||||
message.error('定位失败,请检查浏览器定位权限');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 12000 },
|
||||
);
|
||||
}
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) {
|
||||
message.warning('请先从列表中选择一个地点');
|
||||
return;
|
||||
}
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="地图选点(腾讯位置服务)"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
|
||||
{pending
|
||||
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
|
||||
pending.name ? ` · ${pending.name}` : ''
|
||||
}`
|
||||
: '搜索或选择附近地点后确认'}
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" disabled={!pending} onClick={confirmPick}>
|
||||
确认选点
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => void runSearch(keyword)}
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space wrap>
|
||||
<Button onClick={useBrowserLocation} disabled={loading}>
|
||||
定位当前位置
|
||||
</Button>
|
||||
<Typography.Text type="secondary">{hint}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||
{loading && !items.length ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="加载中…" />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
renderItem={(item) => {
|
||||
const active =
|
||||
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
|
||||
paddingInline: 12,
|
||||
}}
|
||||
onClick={() => setPending(placeToPicked(item))}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={
|
||||
<span>
|
||||
{item.address}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 80 }} description={hint} />
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user