feat(admin): store detail edit with login phone sync; tighten HQ UI
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
HQ store update now syncs store_account.phone for shop login; expand editable store fields. System version only for SUPER_ADMIN; permissions checklist layout polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,8 +52,18 @@ export default function DashboardPage() {
|
||||
request<DashboardStats>('/admin/dashboard/stats')
|
||||
.then(setStats)
|
||||
.finally(() => setLoading(false));
|
||||
void loadVersion();
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
request<HqProfile>('/admin/auth/me')
|
||||
.then((p) => {
|
||||
setProfile(p);
|
||||
if (p.adminRole === 'SUPER_ADMIN') {
|
||||
void loadVersion();
|
||||
} else {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setVersionLoading(false);
|
||||
});
|
||||
}, [loadVersion]);
|
||||
|
||||
function handleDeploy() {
|
||||
@@ -83,6 +93,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<Card
|
||||
title="系统版本"
|
||||
loading={versionLoading}
|
||||
@@ -92,16 +103,14 @@ export default function DashboardPage() {
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||||
刷新版本
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={deploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
发布更新
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={deploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
发布更新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -122,6 +131,7 @@ export default function DashboardPage() {
|
||||
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
@@ -36,6 +35,13 @@ const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.lab
|
||||
|
||||
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||
|
||||
function groupColSpan(itemCount: number): number {
|
||||
if (itemCount <= 2) return 12;
|
||||
if (itemCount === 3) return 8;
|
||||
if (itemCount === 4) return 6;
|
||||
return 8;
|
||||
}
|
||||
|
||||
function PermissionChecklist({
|
||||
value,
|
||||
onChange,
|
||||
@@ -54,19 +60,39 @@ function PermissionChecklist({
|
||||
>
|
||||
{CATALOG_GROUPS.map((group) => {
|
||||
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||
const span = groupColSpan(items.length);
|
||||
const compact = items.length <= 3;
|
||||
return (
|
||||
<div key={group} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
<div
|
||||
key={group}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
<Row gutter={[8, 8]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} span={8}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Divider style={{ margin: '12px 0 0' }} />
|
||||
{compact ? (
|
||||
<Space size={[24, 8]} wrap>
|
||||
{items.map((item) => (
|
||||
<Checkbox key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Row gutter={[12, 10]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -88,6 +114,12 @@ export default function HqPermissionsPage() {
|
||||
const [accountLoading, setAccountLoading] = useState(false);
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((a) => a.id === accountId),
|
||||
[accounts, accountId],
|
||||
);
|
||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const previewEffectiveKeys = useMemo(
|
||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||
[roleInheritedKeys, accountKeys],
|
||||
@@ -243,32 +275,57 @@ export default function HqPermissionsPage() {
|
||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||
) : (
|
||||
<>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<span>角色继承:</span>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{!roleInheritedKeys.length ? (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
||||
</Typography.Paragraph>
|
||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<span>合并生效:</span>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>基础权限(全部)</Tag>
|
||||
{accountKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key} color="orange">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||
保存用户权限
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Space,
|
||||
Steps,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
@@ -352,11 +353,14 @@ export default function StoresPage() {
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
|
||||
|
||||
const categoryParentOptions = useMemo(
|
||||
() =>
|
||||
@@ -372,6 +376,130 @@ export default function StoresPage() {
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, selectedCategoryParentId]);
|
||||
|
||||
const editCategoryChildOptions = useMemo(() => {
|
||||
const parent = categoryTree.find((n) => n.id === editCategoryParentId);
|
||||
return (parent?.children ?? [])
|
||||
.filter((n) => n.status !== 'INACTIVE')
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, editCategoryParentId]);
|
||||
|
||||
async function openStoreDetail(row: StoreRow) {
|
||||
const [d, cats] = await Promise.all([
|
||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||
]);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
setDetail(d);
|
||||
const category = d.category && typeof d.category === 'object'
|
||||
? (d.category as { id?: string; parentId?: string | null })
|
||||
: null;
|
||||
const account = d.account && typeof d.account === 'object'
|
||||
? (d.account as {
|
||||
phone?: string | null;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
})
|
||||
: null;
|
||||
const categoryId = category?.id != null ? String(category.id) : undefined;
|
||||
let parentId = category?.parentId != null ? String(category.parentId) : undefined;
|
||||
if (!parentId && categoryId) {
|
||||
for (const parent of cats) {
|
||||
if (parent.id === categoryId) {
|
||||
parentId = parent.id;
|
||||
break;
|
||||
}
|
||||
if ((parent.children ?? []).some((c) => c.id === categoryId)) {
|
||||
parentId = parent.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const loginPhone =
|
||||
(typeof d.loginPhone === 'string' && d.loginPhone) ||
|
||||
account?.phone ||
|
||||
(typeof d.phone === 'string' ? d.phone : undefined);
|
||||
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
|
||||
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
||||
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
||||
phone: storePhone || loginPhone,
|
||||
intro: d.intro,
|
||||
benefitUsageRule: d.benefitUsageRule,
|
||||
coverUrl: d.coverUrl,
|
||||
province: d.province,
|
||||
city: d.cityName,
|
||||
district: d.district,
|
||||
address: d.address,
|
||||
categoryParentId: parentId,
|
||||
categoryId,
|
||||
latitude: d.latitude != null ? Number(d.latitude) : undefined,
|
||||
longitude: d.longitude != null ? Number(d.longitude) : undefined,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
openTime: d.openTime || '10:00',
|
||||
closeTime: d.closeTime || '22:00',
|
||||
openTime2: d.openTime2 || undefined,
|
||||
closeTime2: d.closeTime2 || undefined,
|
||||
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
|
||||
bankAccountName: account?.bankAccountName || undefined,
|
||||
bankAccountNo: account?.bankAccountNo || undefined,
|
||||
bankBranch: account?.bankBranch || undefined,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function saveStoreDetail() {
|
||||
if (!detail) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const hasCoords =
|
||||
v.latitude != null &&
|
||||
v.longitude != null &&
|
||||
Number.isFinite(Number(v.latitude)) &&
|
||||
Number.isFinite(Number(v.longitude));
|
||||
const payload = {
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
coverUrl: v.coverUrl,
|
||||
intro: v.intro,
|
||||
benefitUsageRule: v.benefitUsageRule ?? null,
|
||||
categoryId: v.categoryId,
|
||||
province: v.province,
|
||||
city: v.city,
|
||||
district: v.district,
|
||||
address: v.address,
|
||||
avgPrice: v.avgPrice,
|
||||
openTime: v.openTime,
|
||||
closeTime: v.closeTime,
|
||||
openTime2: v.openTime2 || null,
|
||||
closeTime2: v.closeTime2 || null,
|
||||
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||
bankAccountName: v.bankAccountName ?? null,
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
};
|
||||
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('门店信息已保存');
|
||||
setDetail(updated);
|
||||
setPhoneMismatch(null);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) return;
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
if (!binding) {
|
||||
@@ -398,7 +526,7 @@ export default function StoresPage() {
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
|
||||
}, [selectedRegionCodes, cities, selectedPartnerId]);
|
||||
|
||||
async function loadOptions() {
|
||||
async function loadOptions(opts?: { quiet?: boolean }) {
|
||||
setOptionsLoading(true);
|
||||
try {
|
||||
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||
@@ -410,10 +538,12 @@ export default function StoresPage() {
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
if (!opts?.quiet) {
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||
@@ -579,28 +709,7 @@ export default function StoresPage() {
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
intro: d.intro,
|
||||
benefitUsageRule: d.benefitUsageRule,
|
||||
coverUrl: d.coverUrl,
|
||||
address: d.address,
|
||||
district: d.district,
|
||||
latitude: d.latitude != null ? Number(d.latitude) : undefined,
|
||||
longitude: d.longitude != null ? Number(d.longitude) : undefined,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
openTime: d.openTime || '10:00',
|
||||
closeTime: d.closeTime || '22:00',
|
||||
openTime2: d.openTime2 || undefined,
|
||||
closeTime2: d.closeTime2 || undefined,
|
||||
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -670,7 +779,7 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
|
||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
@@ -678,205 +787,228 @@ export default function StoresPage() {
|
||||
setDetail({ ...detail, status });
|
||||
void reload();
|
||||
}} />
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const hasCoords =
|
||||
v.latitude != null &&
|
||||
v.longitude != null &&
|
||||
Number.isFinite(Number(v.latitude)) &&
|
||||
Number.isFinite(Number(v.longitude));
|
||||
const payload = {
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
coverUrl: v.coverUrl,
|
||||
intro: v.intro,
|
||||
benefitUsageRule: v.benefitUsageRule ?? null,
|
||||
district: v.district,
|
||||
address: v.address,
|
||||
avgPrice: v.avgPrice,
|
||||
openTime: v.openTime,
|
||||
closeTime: v.closeTime,
|
||||
openTime2: v.openTime2 || null,
|
||||
closeTime2: v.closeTime2 || null,
|
||||
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
};
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDetail({
|
||||
...detail,
|
||||
...payload,
|
||||
settlementRate: payload.settlementRate,
|
||||
latitude: hasCoords ? Number(v.latitude) : detail.latitude,
|
||||
longitude: hasCoords ? Number(v.longitude) : detail.longitude,
|
||||
});
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
||||
</Space>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店分类">
|
||||
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
|
||||
? String((detail.category as { name?: string }).name || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||
}>
|
||||
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="经纬度">
|
||||
{detail.latitude != null && detail.longitude != null
|
||||
? `${Number(detail.latitude).toFixed(6)}, ${Number(detail.longitude).toFixed(6)}`
|
||||
: '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="营业时间">
|
||||
{[
|
||||
detail.openTime && detail.closeTime
|
||||
? `${String(detail.openTime)}-${String(detail.closeTime)}`
|
||||
: null,
|
||||
detail.openTime2 && detail.closeTime2
|
||||
? `${String(detail.openTime2)}-${String(detail.closeTime2)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',') || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="人均费用">
|
||||
{detail.avgPrice != null ? `¥${Number(detail.avgPrice).toFixed(0)}` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算户名">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountName' in detail.account
|
||||
? String((detail.account as { bankAccountName?: string | null }).bankAccountName || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankBranch' in detail.account
|
||||
? String((detail.account as { bankBranch?: string | null }).bankBranch || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="银行卡号">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountNo' in detail.account
|
||||
? String((detail.account as { bankAccountNo?: string | null }).bankAccountNo || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Form form={editForm} 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="coverUrl" label="封面图">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item
|
||||
name="benefitUsageRule"
|
||||
label="好客权益券使用规则"
|
||||
extra="展示在用户端门店详情「门店详情」下方"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 34.7466000"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 113.6253000"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
loading={locating}
|
||||
onClick={() => {
|
||||
fillGeolocation(
|
||||
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
|
||||
setLocating,
|
||||
);
|
||||
}}
|
||||
>
|
||||
获取当前位置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => {
|
||||
setMapPickerTarget('edit');
|
||||
setMapPickerOpen(true);
|
||||
}}
|
||||
>
|
||||
腾讯地图选点
|
||||
</Button>
|
||||
<Typography.Text type="secondary">
|
||||
搜索或拖图确认位置后自动填入经纬度
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本信息',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||
}>
|
||||
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>编辑门店信息</Typography.Title>
|
||||
{phoneMismatch ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机号"
|
||||
rules={[{ required: true }]}
|
||||
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryParentId"
|
||||
label="门店分类(大类)"
|
||||
rules={[{ required: true, message: '请选择门店大类' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
options={categoryParentOptions}
|
||||
onChange={() => editForm.setFieldValue('categoryId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryId"
|
||||
label="门店分类(细类)"
|
||||
rules={[{ required: true, message: '请选择门店细类' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={editCategoryParentId ? '选择细类' : '请先选大类'}
|
||||
disabled={!editCategoryParentId}
|
||||
options={editCategoryChildOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图 / 门头照">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item
|
||||
name="benefitUsageRule"
|
||||
label="好客权益券使用规则"
|
||||
extra="展示在用户端门店详情「门店详情」下方"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="city" label="城市" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 34.7466000"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 113.6253000"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
loading={locating}
|
||||
onClick={() => {
|
||||
fillGeolocation(
|
||||
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
|
||||
setLocating,
|
||||
);
|
||||
}}
|
||||
>
|
||||
获取当前位置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => {
|
||||
setMapPickerTarget('edit');
|
||||
setMapPickerOpen(true);
|
||||
}}
|
||||
>
|
||||
腾讯地图选点
|
||||
</Button>
|
||||
</Space>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settlement',
|
||||
label: '结算资质',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="结算户名">
|
||||
<Input placeholder="开户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户银行">
|
||||
<Input placeholder="如 中国工商银行某某支行" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccountNo"
|
||||
label="银行卡号"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return;
|
||||
if (!/^\d{16,19}$/.test(v)) {
|
||||
throw new Error('银行卡号须为 16–19 位数字');
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="16–19 位数字" maxLength={19} />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary">
|
||||
修改后点右上角「保存修改」一并提交。
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'media',
|
||||
label: '审核材料',
|
||||
children: <StoreAuditMediaSection detail={detail} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user