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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
|
||||
@Controller('admin/dashboard')
|
||||
@@ -13,6 +14,7 @@ export class AdminDashboardController {
|
||||
}
|
||||
|
||||
@Get('version')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
version() {
|
||||
return this.dashboardService.getLatestVersion();
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(mapStoreCompat({
|
||||
...store,
|
||||
account: store.bindings[0]?.storeAccount ?? null,
|
||||
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
|
||||
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
||||
bindings: undefined,
|
||||
media,
|
||||
audits,
|
||||
@@ -208,49 +210,152 @@ export class AdminStoresService {
|
||||
}
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.benefitUsageRule !== undefined
|
||||
? { benefitUsageRule: String(dto.benefitUsageRule).trim() || null }
|
||||
: {}),
|
||||
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||||
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||||
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||||
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||||
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
},
|
||||
});
|
||||
let categoryId: bigint | undefined;
|
||||
if (dto.categoryId !== undefined) {
|
||||
if (!dto.categoryId?.trim()) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
categoryId = BigInt(dto.categoryId);
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
}
|
||||
|
||||
if (dto.coverUrl) {
|
||||
if (current.coverResourceId) {
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id: current.coverResourceId },
|
||||
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
||||
});
|
||||
} else {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.coverUrl,
|
||||
url: dto.coverUrl,
|
||||
},
|
||||
});
|
||||
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||||
if (dto.phone !== undefined) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
||||
const bankTouched =
|
||||
dto.bankAccountName !== undefined ||
|
||||
dto.bankAccountNo !== undefined ||
|
||||
dto.bankBranch !== undefined;
|
||||
const needAccountSync =
|
||||
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
||||
|
||||
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
|
||||
let primaryBinding: {
|
||||
storeAccount: { id: bigint; phone: string; name: string } | null;
|
||||
} | null = null;
|
||||
if (needAccountSync) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id, storeAccount: { isPrimary: 1 } },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!primaryBinding) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
orderBy: { storeAccountId: 'asc' },
|
||||
});
|
||||
}
|
||||
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
|
||||
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
|
||||
}
|
||||
const primaryAccount = primaryBinding?.storeAccount;
|
||||
if (
|
||||
normalizedPhone !== undefined &&
|
||||
primaryAccount &&
|
||||
normalizedPhone !== primaryAccount.phone
|
||||
) {
|
||||
const occupied = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (occupied && occupied.id !== primaryAccount.id) {
|
||||
throw new BadRequestException('该手机号已被其他门店账号使用');
|
||||
}
|
||||
}
|
||||
if (dto.bankAccountNo !== undefined) {
|
||||
const no = dto.bankAccountNo?.trim() || null;
|
||||
if (no && !/^\d{16,19}$/.test(no)) {
|
||||
throw new BadRequestException('银行卡号须为 16–19 位数字');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.store.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.benefitUsageRule !== undefined
|
||||
? { benefitUsageRule: String(dto.benefitUsageRule).trim() || null }
|
||||
: {}),
|
||||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
|
||||
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
|
||||
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
|
||||
...(categoryId !== undefined ? { categoryId } : {}),
|
||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||||
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||||
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||||
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||||
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.coverUrl) {
|
||||
if (current.coverResourceId) {
|
||||
await tx.commonResource.update({
|
||||
where: { id: current.coverResourceId },
|
||||
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
||||
});
|
||||
} else {
|
||||
const cover = await tx.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.coverUrl,
|
||||
url: dto.coverUrl,
|
||||
},
|
||||
});
|
||||
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryBinding?.storeAccount) {
|
||||
const account = primaryBinding.storeAccount;
|
||||
const accountData: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
} = {};
|
||||
if (dto.name !== undefined) accountData.name = dto.name.trim();
|
||||
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
|
||||
accountData.phone = normalizedPhone;
|
||||
}
|
||||
if (dto.bankAccountName !== undefined) {
|
||||
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
|
||||
}
|
||||
if (dto.bankAccountNo !== undefined) {
|
||||
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
|
||||
}
|
||||
if (dto.bankBranch !== undefined) {
|
||||
accountData.bankBranch = dto.bankBranch?.trim() || null;
|
||||
}
|
||||
if (Object.keys(accountData).length) {
|
||||
await tx.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: accountData,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.detailStore(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,10 +162,22 @@ export class UpdateStoreDto {
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -191,6 +203,18 @@ export class UpdateStoreDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
avgPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankAccountName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankAccountNo?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankBranch?: string | null;
|
||||
}
|
||||
|
||||
export class CreateStoreAccountDto {
|
||||
|
||||
Reference in New Issue
Block a user