Compare commits

..

10 Commits

Author SHA1 Message Date
jacy 309b180087 feat(admin): link city-partner store count; fix benefitUsageRule null string
CI / verify (pull_request) Has been cancelled
City partner storeCount jumps to /stores?partnerId=; normalize empty/null text so usage rule is not saved as literal "null.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:03:56 +08:00
jacy 118d57d710 feat(admin): delete store staff; city store count links to filtered list
CI / verify (pull_request) Has been cancelled
HQ can remove store sub-accounts from primary account detail; cities table store count jumps to /stores?cityId=.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:43:09 +08:00
jacy 0cb2b2cebb feat(admin): store detail edit with login phone sync; tighten HQ UI
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>
2026-07-28 15:10:14 +08:00
jacy daeb24bbeb feat(admin): gate user/order/city delete behind assignable HQ permissions
CI / verify (pull_request) Has been cancelled
Add users_delete, orders_delete, cities_delete; default HQ (incl. super admin) has none until explicitly granted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:01:18 +08:00
jacy f4766d32ff fix(admin): make partner company/address/district optional on create UI
CI / verify (pull_request) Has been cancelled
Remove required asterisks on CityPartnersPage create form (main entry).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:44:19 +08:00
jacy b920c894b9 feat(admin): city delete with impact preview; optional partner fields
CI / verify (pull_request) Has been cancelled
Allow deleting open cities after listing partners/stores; company name, address, and districts optional on partner create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:33:12 +08:00
jacy 37038a7591 fix(api): surface LLM network errors for WeCom AI replies
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:59:49 +08:00
jacy 944306e45c fix(admin,partner): replace Tencent iframe picker with server LBS search
CI / verify (pull_request) Has been cancelled
Avoid locpicker formatted_addresses crash; proxy suggest/nearby/reverse via /common/lbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:56:50 +08:00
jacy 9ed0c24d11 fix(admin,partner): harden Tencent locpicker coords and confirm flow
CI / verify (pull_request) Has been cancelled
Accept latlng-only postMessage, confirm before apply, and read LBS key per request.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:47:08 +08:00
jacy 50349dd8e9 fix(mini-user): center qualification image on translucent mask
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:41:13 +08:00
42 changed files with 2333 additions and 695 deletions
@@ -281,11 +281,11 @@ export default function CityPartnersPanel({
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></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="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
} }
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></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="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Modal, Spin, Typography, message } from 'antd'; import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { import {
buildTencentLocPickerUrl, placeToPicked,
parseTencentLocPickerMessage, type LbsPlaceItem,
type TencentPickedLocation, type TencentPickedLocation,
} from '../lib/tencentLocPicker'; } from '../lib/tencentLocPicker';
@@ -14,19 +13,14 @@ type Props = {
onPick: (loc: TencentPickedLocation) => void; onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
}; };
let cachedKey: string | null | undefined; function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
async function loadTencentLbsKey(): Promise<string> { const b = typeof lng === 'number' ? lng : Number(lng);
if (cachedKey) return cachedKey; return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
const cfg = await request<ClientRuntimeConfig>('/common/client-config');
const key = (cfg.tencentLbsKey || '').trim();
if (!key) {
throw new Error('未配置腾讯位置服务 Key,请在系统设置中填写 TENCENT_LBS_KEY');
}
cachedKey = key;
return key;
} }
export default function TencentLocPickerModal({ export default function TencentLocPickerModal({
@@ -35,81 +29,202 @@ export default function TencentLocPickerModal({
onPick, onPick,
latitude, latitude,
longitude, longitude,
region,
}: Props) { }: Props) {
const [key, setKey] = useState<string | null>(cachedKey ?? null); const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
let cancelled = false; setKeyword('');
setError(''); setItems([]);
if (key) return; setPending(null);
setLoading(true); setHint('输入地点名称搜索,或加载附近地点');
void loadTencentLbsKey() return;
.then((k) => {
if (!cancelled) setKey(k);
})
.catch((e) => {
if (!cancelled) {
const msg = e instanceof Error ? e.message : '加载地图配置失败';
setError(msg);
message.error(msg);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, key]);
useEffect(() => {
if (!open) return;
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
onPick(picked);
onClose();
} }
window.addEventListener('message', onMessage); const lat = latitude != null ? Number(latitude) : NaN;
return () => window.removeEventListener('message', onMessage); const lng = longitude != null ? Number(longitude) : NaN;
}, [open, onPick, onClose]); if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const src = useMemo(() => { async function loadNearby(lat: number, lng: number) {
if (!key) return ''; const seq = ++seqRef.current;
return buildTencentLocPickerUrl(key, { setLoading(true);
latitude: latitude != null ? Number(latitude) : undefined, setHint('正在加载附近地点…');
longitude: longitude != null ? Number(longitude) : undefined, try {
}); const res = await request<{ items: LbsPlaceItem[] }>(
}, [key, latitude, longitude]); `/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 ( return (
<Modal <Modal
title="腾讯地图选点" title="地图选点(腾讯位置服务)"
open={open} open={open}
onCancel={onClose} onCancel={onClose}
footer={null}
width={720} width={720}
destroyOnClose destroyOnClose
styles={{ body: { padding: 0, height: 560 } }} 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>
}
> >
{loading ? ( <Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ height: 560, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Space.Compact style={{ width: '100%' }}>
<Spin tip="加载地图…" /> <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> </div>
) : error ? ( </Space>
<div style={{ padding: 24 }}>
<Typography.Text type="danger">{error}</Typography.Text>
</div>
) : src ? (
<iframe
title="腾讯地图选点"
src={src}
style={{ width: '100%', height: 560, border: 0, display: 'block' }}
/>
) : null}
</Modal> </Modal>
); );
} }
+1
View File
@@ -27,6 +27,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' }, { value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' }, { value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' }, { value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' }, { value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
+13 -47
View File
@@ -6,55 +6,21 @@ export type TencentPickedLocation = {
cityname?: string; cityname?: string;
}; };
type LocPickerMessage = { export type LbsPlaceItem = {
module?: string; id: string;
latlng?: { lat?: number; lng?: number }; title: string;
poiaddress?: string; address: string;
poiname?: string; latitude: number;
cityname?: string; longitude: number;
city?: string;
}; };
const REFERER = 'dukang-haoke'; export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
export function buildTencentLocPickerUrl(
key: string,
options?: { latitude?: number; longitude?: number },
): string {
const params = new URLSearchParams({
search: '1',
type: '1',
key,
referer: REFERER,
policy: '1',
});
const lat = options?.latitude;
const lng = options?.longitude;
if (
lat != null &&
lng != null &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
params.set('coord', `${lat},${lng}`);
params.set('coordtype', '5');
}
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = data as LocPickerMessage;
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
// 列表滑动过程中偶发空 POI,忽略无名称且无地址的噪声
if (!loc.poiname && !loc.poiaddress) return null;
return { return {
latitude: lat, latitude: item.latitude,
longitude: lng, longitude: item.longitude,
address: loc.poiaddress?.trim() || undefined, address: item.address || undefined,
name: loc.poiname?.trim() || undefined, name: item.title || undefined,
cityname: loc.cityname?.trim() || undefined, cityname: item.city || undefined,
}; };
} }
+270 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -17,7 +18,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -50,6 +51,40 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null }; type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() { export default function CitiesPage() {
@@ -82,6 +117,18 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm); const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => { const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,22 +176,85 @@ export default function CitiesPage() {
setDrawerOpen(true); setDrawerOpen(true);
}; };
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, { title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ title: '门店', dataIndex: 'storeCount', width: 70 }, {
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 }, { title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
), ),
}, },
]; ];
@@ -264,6 +374,26 @@ export default function CitiesPage() {
setDetail(refreshed); setDetail(refreshed);
void reload(); void reload();
}}></Button> }}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form> </Form>
</> </>
), ),
@@ -407,6 +537,140 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div> </div>
); );
} }
+24 -12
View File
@@ -301,7 +301,16 @@ export default function CityPartnersPage() {
ellipsis: true, ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ title: '门店', dataIndex: 'storeCount', width: 60 }, {
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
@@ -444,7 +453,11 @@ export default function CityPartnersPage() {
<> <>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item> <Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item> <Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}> <Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? ( {detail.managedWarehouseName ?? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -460,8 +473,8 @@ export default function CityPartnersPage() {
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -472,8 +485,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话"> <Form.Item name="contactPhone" label="业务联系电话">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
@@ -580,8 +593,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)} onChange={(id) => void onCreateCityChange(id)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -589,8 +602,8 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
@@ -599,8 +612,7 @@ export default function CityPartnersPage() {
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={createCityCode} /> <CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
+22 -12
View File
@@ -52,8 +52,18 @@ export default function DashboardPage() {
request<DashboardStats>('/admin/dashboard/stats') request<DashboardStats>('/admin/dashboard/stats')
.then(setStats) .then(setStats)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
void loadVersion(); request<HqProfile>('/admin/auth/me')
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); .then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]); }, [loadVersion]);
function handleDeploy() { function handleDeploy() {
@@ -83,6 +93,7 @@ export default function DashboardPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card <Card
title="系统版本" title="系统版本"
loading={versionLoading} loading={versionLoading}
@@ -92,16 +103,14 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}> <Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button> </Button>
{isSuperAdmin && ( <Button
<Button type="primary"
type="primary" icon={<CloudUploadOutlined />}
icon={<CloudUploadOutlined />} loading={deploying}
loading={deploying} onClick={handleDeploy}
onClick={handleDeploy} >
>
</Button>
</Button>
)}
</Space> </Space>
} }
> >
@@ -122,6 +131,7 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
)} )}
</Card> </Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
+102 -42
View File
@@ -5,7 +5,6 @@ import {
Card, Card,
Checkbox, Checkbox,
Col, Col,
Divider,
Form, Form,
Row, Row,
Select, 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 ?? '其他'))]; 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({ function PermissionChecklist({
value, value,
onChange, onChange,
@@ -54,19 +60,39 @@ function PermissionChecklist({
> >
{CATALOG_GROUPS.map((group) => { {CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group); const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return ( return (
<div key={group} style={{ marginBottom: 16 }}> <div
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}> 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} {group}
</Typography.Text> </Typography.Text>
<Row gutter={[8, 8]}> {compact ? (
{items.map((item) => ( <Space size={[24, 8]} wrap>
<Col key={item.key} span={8}> {items.map((item) => (
<Checkbox value={item.key}>{item.label}</Checkbox> <Checkbox key={item.key} value={item.key}>
</Col> {item.label}
))} </Checkbox>
</Row> ))}
<Divider style={{ margin: '12px 0 0' }} /> </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> </div>
); );
})} })}
@@ -88,6 +114,12 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = 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( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys], [roleInheritedKeys, accountKeys],
@@ -172,7 +204,8 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
//
// //
</Typography.Paragraph> </Typography.Paragraph>
@@ -198,7 +231,11 @@ export default function HqPermissionsPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{role === 'SUPER_ADMIN' ? ( {role === 'SUPER_ADMIN' ? (
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" /> <Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : ( ) : (
<> <>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} /> <PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -227,12 +264,10 @@ export default function HqPermissionsPage() {
value={accountId} value={accountId}
onChange={setAccountId} onChange={setAccountId}
optionFilterProp="label" optionFilterProp="label"
options={accounts options={accounts.map((a) => ({
.filter((a) => a.adminRole !== 'SUPER_ADMIN') value: a.id,
.map((a) => ({ label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
value: a.id, }))}
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -240,32 +275,57 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<Space wrap style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{roleInheritedKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); <Tag color="blue"></Tag>
return ( ) : (
<Tag key={key} color="blue"> <Space wrap size={[4, 4]}>
{item?.group ? `${item.group}·${item.label}` : item?.label || key} {roleInheritedKeys.map((key) => {
</Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
); return (
})} <Tag key={key} color="blue">
</Space> {item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<Space wrap style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{previewEffectiveKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); <Space wrap size={[4, 4]}>
return ( <Tag></Tag>
<Tag key={key}> {accountKeys.map((key) => {
{item?.group ? `${item.group}·${item.label}` : item?.label || key} const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
</Tag> return (
); <Tag key={key} color="orange">
})} {item?.label || key}
</Space> </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 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
+18 -10
View File
@@ -18,7 +18,7 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
@@ -154,6 +154,7 @@ export default function OrdersPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null); const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -174,12 +175,17 @@ export default function OrdersPage() {
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null); const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const selectedOrders = useMemo( const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
[data?.items, selectedRowKeys], [data?.items, selectedRowKeys],
); );
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
setRedeemDrawerOpen(true); setRedeemDrawerOpen(true);
@@ -454,13 +460,15 @@ export default function OrdersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button {canDeleteOrders ? (
danger <Button
disabled={!selectedRowKeys.length} danger
onClick={() => setBatchDeleteOpen(true)} disabled={!selectedRowKeys.length}
> onClick={() => setBatchDeleteOpen(true)}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} >
</Button> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号"> <Form.Item name="orderNo" label="订单号">
@@ -504,10 +512,10 @@ export default function OrdersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={canDeleteOrders ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} } : undefined}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+5 -6
View File
@@ -248,11 +248,11 @@ export default function PartnersPage() {
label: '基本信息', label: '基本信息',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></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="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
@@ -348,10 +348,10 @@ export default function PartnersPage() {
}} }}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></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="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
@@ -359,8 +359,7 @@ export default function PartnersPage() {
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]} extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={createCityCode} /> <CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
+45 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() { async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items); setStores(res.items);
} }
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
dataIndex: 'status', dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
}, },
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]} ]}
/> />
</> </>
) : null} ) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</> </>
)} )}
</Drawer> </Drawer>
+449 -228
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -14,6 +14,7 @@ import {
Space, Space,
Steps, Steps,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
@@ -321,10 +322,18 @@ type CategoryNode = {
export default function StoresPage() { export default function StoresPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>(); const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
'/admin/stores', '/admin/stores',
() => { () => {
@@ -333,10 +342,14 @@ export default function StoresPage() {
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus); if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
return qs; return qs;
}, },
[filters], [filters],
); );
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
@@ -352,11 +365,48 @@ export default function StoresPage() {
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]); const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterCities(res.items))
.catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterPartners(res.items))
.catch(() => {});
}, []);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
let changed = false;
setFilters((prev) => {
const next = { ...prev };
if ((prev.cityId ?? '') !== cityId) {
changed = true;
if (cityId) next.cityId = cityId;
else delete next.cityId;
}
if ((prev.partnerId ?? '') !== partnerId) {
changed = true;
if (partnerId) next.partnerId = partnerId;
else delete next.partnerId;
}
return changed ? next : prev;
});
form.setFieldsValue({
cityId: cityId || undefined,
partnerId: partnerId || undefined,
});
if (cityId || partnerId) setPage(1);
}, [searchParams, form, setPage]);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm); const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm); const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm); const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm); const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
const categoryParentOptions = useMemo( const categoryParentOptions = useMemo(
() => () =>
@@ -372,6 +422,140 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name })); .map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, selectedCategoryParentId]); }, [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 != null &&
String(d.benefitUsageRule).trim() &&
!/^null$/i.test(String(d.benefitUsageRule).trim())
? String(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:
typeof v.benefitUsageRule === 'string' &&
v.benefitUsageRule.trim() &&
!/^null$/i.test(v.benefitUsageRule.trim())
? v.benefitUsageRule.trim()
: 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) { function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId); const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
if (!binding) { if (!binding) {
@@ -398,7 +582,7 @@ export default function StoresPage() {
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`; return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]); }, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() { async function loadOptions(opts?: { quiet?: boolean }) {
setOptionsLoading(true); setOptionsLoading(true);
try { try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`; const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
@@ -410,10 +594,12 @@ export default function StoresPage() {
setPartners(p.items); setPartners(p.items);
setCities(c.items); setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []); setCategoryTree(Array.isArray(cats) ? cats : []);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人'); if (!opts?.quiet) {
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建'); if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!Array.isArray(cats) || !cats.length) { if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置'); if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
}
} }
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败'); message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
@@ -579,28 +765,7 @@ export default function StoresPage() {
{ {
title: '操作', width: 80, title: '操作', width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
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>
), ),
}, },
]; ];
@@ -617,6 +782,26 @@ export default function StoresPage() {
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item> <Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={filterCities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="partnerId" label="城市合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
placeholder="全部"
options={filterPartners.map((p) => ({ value: p.id, label: p.companyName || p.id }))}
/>
</Form.Item>
<Form.Item name="status" label="营业状态"> <Form.Item name="status" label="营业状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
@@ -629,7 +814,20 @@ export default function StoresPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button></Form.Item> <Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
if (searchParams.has('cityId') || searchParams.has('partnerId')) {
navigate('/stores', { replace: true });
}
}}
>
</Button>
</Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
@@ -670,7 +868,7 @@ export default function StoresPage() {
</Button> </Button>
</> </>
) : null} ) : 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 }))} options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => { onChange={async (status) => {
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) }); await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
@@ -678,205 +876,228 @@ export default function StoresPage() {
setDetail({ ...detail, status }); setDetail({ ...detail, status });
void reload(); void reload();
}} /> }} />
<Button type="primary" onClick={async () => { <Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}></Button>
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>
</Space> </Space>
)}> )}>
{detail && ( {detail && (
<> <Form form={editForm} layout="vertical">
<StoreAuditMediaSection detail={detail} /> <Tabs
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> destroyInactiveTabPane={false}
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> items={[
<Descriptions.Item label="门店分类"> {
{detail.category && typeof detail.category === 'object' && 'name' in detail.category key: 'basic',
? String((detail.category as { name?: string }).name || '—') label: '基本信息',
: '—'} forceRender: true,
</Descriptions.Item> children: (
<Descriptions.Item label="审核状态"> <>
<Tag color={ <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
String(detail.auditStatus) === 'PENDING' ? 'orange' <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green' <Descriptions.Item label="审核状态">
}> <Tag color={
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)} String(detail.auditStatus) === 'PENDING' ? 'orange'
</Tag> : String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
</Descriptions.Item> }>
{String(detail.auditStatus) === 'REJECTED' ? ( {STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item> </Tag>
) : null} </Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item> {String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="经纬度"> <Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
{detail.latitude != null && detail.longitude != null ) : null}
? `${Number(detail.latitude).toFixed(6)}, ${Number(detail.longitude).toFixed(6)}` <Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
: '未设置'} <Descriptions.Item label="操作">
</Descriptions.Item> <Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
<Descriptions.Item label="营业时间">
{[ </Button>
detail.openTime && detail.closeTime </Descriptions.Item>
? `${String(detail.openTime)}-${String(detail.closeTime)}` {Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
: null, <Descriptions.Item label="审核记录">
detail.openTime2 && detail.closeTime2 <Space direction="vertical" size={4} style={{ width: '100%' }}>
? `${String(detail.openTime2)}-${String(detail.closeTime2)}` {(detail.audits as Array<Record<string, unknown>>).map((a) => (
: null, <Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
] {fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
.filter(Boolean) </Typography.Text>
.join('') || '—'} ))}
</Descriptions.Item> </Space>
<Descriptions.Item label="人均费用"> </Descriptions.Item>
{detail.avgPrice != null ? `¥${Number(detail.avgPrice).toFixed(0)}` : '—'} ) : null}
</Descriptions.Item> </Descriptions>
<Descriptions.Item label="核销结算比例"> <Typography.Title level={5} style={{ marginTop: 0 }}></Typography.Title>
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'} {phoneMismatch ? (
</Descriptions.Item> <Alert
<Descriptions.Item label="结算户名"> type="warning"
{detail.account && typeof detail.account === 'object' && 'bankAccountName' in detail.account showIcon
? String((detail.account as { bankAccountName?: string | null }).bankAccountName || '—') style={{ marginBottom: 16 }}
: '—'} message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
</Descriptions.Item> />
<Descriptions.Item label="开户银行"> ) : null}
{detail.account && typeof detail.account === 'object' && 'bankBranch' in detail.account <Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
? String((detail.account as { bankBranch?: string | null }).bankBranch || '—') <Form.Item
: '—'} name="phone"
</Descriptions.Item> label="登录手机号"
<Descriptions.Item label="银行卡号"> rules={[{ required: true }]}
{detail.account && typeof detail.account === 'object' && 'bankAccountNo' in detail.account extra="门店端短信登录使用此号码;修改后需用新号重新登录"
? String((detail.account as { bankAccountNo?: string | null }).bankAccountNo || '—') >
: '—'} <Input />
</Descriptions.Item> </Form.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item> <Form.Item
<Descriptions.Item label="操作"> name="categoryParentId"
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}> label="门店分类(大类)"
rules={[{ required: true, message: '请选择门店大类' }]}
</Button> >
</Descriptions.Item> <Select
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? ( showSearch
<Descriptions.Item label="审核记录"> loading={optionsLoading}
<Space direction="vertical" size={4} style={{ width: '100%' }}> optionFilterProp="label"
{(detail.audits as Array<Record<string, unknown>>).map((a) => ( options={categoryParentOptions}
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}> onChange={() => editForm.setFieldValue('categoryId', undefined)}
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')} />
</Typography.Text> </Form.Item>
))} <Form.Item
</Space> name="categoryId"
</Descriptions.Item> label="门店分类(细类)"
) : null} rules={[{ required: true, message: '请选择门店细类' }]}
</Descriptions> >
<Form form={editForm} layout="vertical"> <Select
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item> showSearch
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item> optionFilterProp="label"
<Form.Item name="coverUrl" label="封面图"> placeholder={editCategoryParentId ? '选择细类' : '请先选大类'}
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" /> disabled={!editCategoryParentId}
</Form.Item> options={editCategoryChildOptions}
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item> />
<Form.Item </Form.Item>
name="benefitUsageRule" <Form.Item name="coverUrl" label="封面图 / 门头照">
label="好客权益券使用规则" <OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
extra="展示在用户端门店详情「门店详情」下方" </Form.Item>
> <Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} /> <Form.Item
</Form.Item> name="benefitUsageRule"
<Form.Item name="district" label="区县"><Input /></Form.Item> label="好客权益券使用规则"
<Form.Item name="address" label="详细地址"><Input /></Form.Item> extra="展示在用户端门店详情「门店详情」下方"
<Space wrap style={{ width: '100%' }}> >
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}> <Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
<InputNumber </Form.Item>
style={{ width: 180 }} <Space wrap style={{ width: '100%' }}>
precision={7} <Form.Item name="province" label="省份" rules={[{ required: true }]}>
step={0.000001} <Input style={{ width: 140 }} />
placeholder="如 34.7466000" </Form.Item>
/> <Form.Item name="city" label="城市" rules={[{ required: true }]}>
</Form.Item> <Input style={{ width: 140 }} />
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}> </Form.Item>
<InputNumber <Form.Item name="district" label="区县" rules={[{ required: true }]}>
style={{ width: 180 }} <Input style={{ width: 140 }} />
precision={7} </Form.Item>
step={0.000001} </Space>
placeholder="如 113.6253000" <Form.Item name="address" label="详细地址" rules={[{ required: true }]}>
/> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
</Space> <Space wrap style={{ width: '100%' }}>
<Space wrap style={{ marginBottom: 16 }}> <Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<Button <InputNumber
loading={locating} style={{ width: 180 }}
onClick={() => { precision={7}
fillGeolocation( step={0.000001}
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }), placeholder="如 34.7466000"
setLocating, />
); </Form.Item>
}} <Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
> <InputNumber
style={{ width: 180 }}
</Button> precision={7}
<Button step={0.000001}
icon={<EnvironmentOutlined />} placeholder="如 113.6253000"
onClick={() => { />
setMapPickerTarget('edit'); </Form.Item>
setMapPickerOpen(true); </Space>
}} <Space wrap style={{ marginBottom: 16 }}>
> <Button
loading={locating}
</Button> onClick={() => {
<Typography.Text type="secondary"> fillGeolocation(
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
</Typography.Text> setLocating,
</Space> );
<Form.Item name="avgPrice" label="人均费用(选填)"> }}
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" /> >
</Form.Item>
<Space wrap style={{ width: '100%' }}> </Button>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}> <Button
<Input type="time" style={{ width: 140 }} /> icon={<EnvironmentOutlined />}
</Form.Item> onClick={() => {
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}> setMapPickerTarget('edit');
<Input type="time" style={{ width: 140 }} /> setMapPickerOpen(true);
</Form.Item> }}
</Space> >
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime2" label="第二段开始(选填)"> </Button>
<Input type="time" style={{ width: 140 }} /> </Space>
</Form.Item> <Form.Item name="avgPrice" label="人均费用(选填)">
<Form.Item name="closeTime2" label="第二段结束"> <InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
<Input type="time" style={{ width: 140 }} /> </Form.Item>
</Form.Item> <Space wrap style={{ width: '100%' }}>
</Space> <Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}> <Input type="time" style={{ width: 140 }} />
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" /> </Form.Item>
</Form.Item> <Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
</Form> <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('银行卡号须为 1619 位数字');
}
},
},
]}
>
<Input placeholder="1619 位数字" maxLength={19} />
</Form.Item>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
</>
),
},
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
},
]}
/>
</Form>
)} )}
</Drawer> </Drawer>
<Modal <Modal
+20 -12
View File
@@ -18,7 +18,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
@@ -66,6 +66,7 @@ export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -82,6 +83,11 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -309,13 +315,15 @@ export default function UsersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button {canDeleteUsers ? (
danger <Button
disabled={!selectedRowKeys.length} danger
onClick={() => void openBatchDeleteModal()} disabled={!selectedRowKeys.length}
> onClick={() => void openBatchDeleteModal()}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} >
</Button> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
@@ -353,11 +361,11 @@ export default function UsersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} } : undefined}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -375,9 +383,9 @@ export default function UsersPage() {
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}></Button> <Button danger onClick={openDeleteModal}></Button>
)} ) : undefined}
> >
{detail && ( {detail && (
<> <>
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { import {
buildTencentLocPickerUrl, placeToPicked,
parseTencentLocPickerMessage, type LbsPlaceItem,
type TencentPickedLocation, type TencentPickedLocation,
} from '../lib/tencentLocPicker'; } from '../lib/tencentLocPicker';
@@ -13,77 +12,126 @@ type Props = {
onPick: (loc: TencentPickedLocation) => void; onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
region?: string | null;
}; };
let cachedKey: string | null | undefined;
async function loadTencentLbsKey(): Promise<string> {
if (cachedKey) return cachedKey;
const cfg = await request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config', {
silent: true,
});
const key = (cfg.tencentLbsKey || '').trim();
if (!key) {
throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置');
}
cachedKey = key;
return key;
}
export default function TencentLocPickerOverlay({ export default function TencentLocPickerOverlay({
open, open,
onClose, onClose,
onPick, onPick,
latitude, latitude,
longitude, longitude,
region,
}: Props) { }: Props) {
const [key, setKey] = useState<string | null>(cachedKey ?? null); const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索');
const seqRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
let cancelled = false; setKeyword('');
setError(''); setItems([]);
if (key) return; setPending(null);
setLoading(true); setHint('输入地点名称搜索');
void loadTencentLbsKey() return;
.then((k) => {
if (!cancelled) setKey(k);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, key]);
useEffect(() => {
if (!open) return;
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
onPick(picked);
onClose();
} }
window.addEventListener('message', onMessage); const lat = latitude != null ? Number(latitude) : NaN;
return () => window.removeEventListener('message', onMessage); const lng = longitude != null ? Number(longitude) : NaN;
}, [open, onPick, onClose]); if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const src = useMemo(() => { async function loadNearby(lat: number, lng: number) {
if (!key) return ''; const seq = ++seqRef.current;
return buildTencentLocPickerUrl(key, { setLoading(true);
latitude: latitude != null ? Number(latitude) : undefined, setHint('正在加载附近地点…');
longitude: longitude != null ? Number(longitude) : undefined, try {
}); const res = await request<{ items: LbsPlaceItem[] }>(
}, [key, latitude, longitude]); 'PARTNER_H5',
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '加载附近地点失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch() {
const trimmed = keyword.trim();
if (!trimmed) {
setHint('请输入搜索关键词');
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[] }>(
'PARTNER_H5',
`/common/lbs/suggest?${params.toString()}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '搜索失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
setHint('当前浏览器不支持定位');
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);
setHint('定位失败,请检查定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
if (!open) return null; if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return ( return (
<div <div
style={{ style={{
@@ -103,31 +151,114 @@ export default function TencentLocPickerOverlay({
padding: '12px 16px', padding: '12px 16px',
borderBottom: '1px solid rgba(0,0,0,0.06)', borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0, flexShrink: 0,
gap: 8,
}} }}
> >
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}> <button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
</button> </button>
<span style={{ fontWeight: 600 }}></span> <span style={{ fontWeight: 600 }}></span>
<span style={{ width: 52 }} /> <button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div> </div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{loading ? ( <div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}> <div style={{ display: 'flex', gap: 8 }}>
<input
</p> value={keyword}
) : error ? ( onChange={(e) => setKeyword(e.target.value)}
<p className="label-md" style={{ padding: 24, color: 'var(--color-heritage-red, #a61d24)' }}> onKeyDown={(e) => {
{error} if (e.key === 'Enter') void runSearch();
</p> }}
) : src ? ( placeholder="输入小区 / 写字楼 / 门店名称"
<iframe style={{
title="腾讯地图选点" flex: 1,
src={src} padding: '10px 12px',
style={{ width: '100%', height: '100%', border: 0, display: 'block' }} border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 8,
fontSize: 14,
}}
/> />
<button
type="button"
className="partner-btn-primary"
style={{ width: 'auto', padding: '8px 14px' }}
disabled={loading}
onClick={() => void runSearch()}
>
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '6px 12px' }}
disabled={loading}
onClick={useBrowserLocation}
>
</button>
<span className="label-md text-muted">{hint}</span>
</div>
{pending ? (
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null} ) : null}
</div> </div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && !items.length ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : items.length ? (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
<button
type="button"
onClick={() => setPending(placeToPicked(item))}
style={{
width: '100%',
textAlign: 'left',
padding: '12px 16px',
border: 0,
borderBottom: '1px solid rgba(0,0,0,0.06)',
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
<div className="label-md text-muted" style={{ marginTop: 4 }}>
{item.address}
</div>
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</div>
</button>
</li>
);
})}
</ul>
) : (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
{hint}
</p>
)}
</div>
</div> </div>
); );
} }
+13 -46
View File
@@ -6,54 +6,21 @@ export type TencentPickedLocation = {
cityname?: string; cityname?: string;
}; };
type LocPickerMessage = { export type LbsPlaceItem = {
module?: string; id: string;
latlng?: { lat?: number; lng?: number }; title: string;
poiaddress?: string; address: string;
poiname?: string; latitude: number;
cityname?: string; longitude: number;
city?: string;
}; };
const REFERER = 'dukang-haoke'; export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
export function buildTencentLocPickerUrl(
key: string,
options?: { latitude?: number; longitude?: number },
): string {
const params = new URLSearchParams({
search: '1',
type: '1',
key,
referer: REFERER,
policy: '1',
});
const lat = options?.latitude;
const lng = options?.longitude;
if (
lat != null &&
lng != null &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
params.set('coord', `${lat},${lng}`);
params.set('coordtype', '5');
}
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = data as LocPickerMessage;
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (!loc.poiname && !loc.poiaddress) return null;
return { return {
latitude: lat, latitude: item.latitude,
longitude: lng, longitude: item.longitude,
address: loc.poiaddress?.trim() || undefined, address: item.address || undefined,
name: loc.poiname?.trim() || undefined, name: item.title || undefined,
cityname: loc.cityname?.trim() || undefined, cityname: item.city || undefined,
}; };
} }
@@ -67,7 +67,10 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''), phone: String(data.phone || ''),
address: String(data.address || ''), address: String(data.address || ''),
intro: String(data.intro || ''), intro: String(data.intro || ''),
benefitUsageRule: String(data.benefitUsageRule || ''), benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '', latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '', longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
}); });
+8 -5
View File
@@ -558,16 +558,19 @@ export default function MinePage() {
> >
<ScrollView <ScrollView
scrollY scrollY
enableFlex
className="mine-qualification-scroll" className="mine-qualification-scroll"
style={{ height: '100%' }} style={{ height: '100%' }}
enhanced enhanced
showScrollbar showScrollbar
> >
<Image <View className="mine-qualification-body">
className="mine-qualification-img" <Image
src={QUALIFICATION_DISCLOSURE_URL} className="mine-qualification-img"
mode="widthFix" src={QUALIFICATION_DISCLOSURE_URL}
/> mode="widthFix"
/>
</View>
</ScrollView> </ScrollView>
<Text className="mine-qualification-hint"></Text> <Text className="mine-qualification-hint"></Text>
</View> </View>
@@ -174,7 +174,9 @@ export default function StoreDetailPage() {
]); ]);
const intro = store.intro?.trim() || ''; const intro = store.intro?.trim() || '';
const benefitRule = store.benefitUsageRule?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
function previewEnv(index: number) { function previewEnv(index: number) {
if (!envPhotos.length) return; if (!envPhotos.length) return;
+14 -3
View File
@@ -509,7 +509,7 @@
height: 100%; height: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
background: #000; background: rgba(0, 0, 0, 0.55);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -521,7 +521,17 @@
max-height: none; max-height: none;
border-radius: 0; border-radius: 0;
overflow: hidden; overflow: hidden;
background: #000; background: transparent;
}
.mine-qualification-body {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 100%;
width: 100%;
padding: 24px 16px 56px;
} }
.mine-qualification-img { .mine-qualification-img {
@@ -539,6 +549,7 @@
z-index: 1; z-index: 1;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.82); color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
pointer-events: none; pointer-events: none;
} }
+8
View File
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it('allows district partner without district codes', () => {
const result = validatePartnerCityBinding(
[],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: [] },
);
expect(result.ok).toBe(true);
});
it('allows valid district binding', () => { it('allows valid district binding', () => {
const result = validatePartnerCityBinding( const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }], [{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
+1 -6
View File
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
return { ok: true }; return { ok: true };
} }
const districts = normalizeDistrictCodes(input.districtCodes); // 区域合伙人所选区县可为空(录入时可稍后补全)
if (!districts.length) {
return { ok: false, message: '区域合伙人须至少选择一个区县' };
}
// 区县仅为标识,允许多个区域合伙人选择相同区县
return { ok: true }; return { ok: true };
} }
+19 -1
View File
@@ -19,6 +19,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'knowledge_bases', label: '知识库', group: '业务' }, { key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' }, { key: 'logs', label: '日志', group: '业务' },
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
{ key: 'orders_delete', label: '删除订单', group: '危险操作' },
{ key: 'cities_delete', label: '删除城市', group: '危险操作' },
{ key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_permissions', label: '权限分配', group: '管理' },
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' }, { key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' }, { key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
@@ -33,6 +36,21 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:角色默认与超管自动权限均不含,需在权限分配中显式勾选 */
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
'cities_delete',
] as const satisfies readonly HqPermissionKey[];
export function isHqDangerousPermission(key: string): boolean {
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function hqBasePermissionKeys(): HqPermissionKey[] {
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
}
/** 系统配置 registry group → 权限 key */ /** 系统配置 registry group → 权限 key */
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = { export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
feature: 'system_settings_feature', feature: 'system_settings_feature',
@@ -76,7 +94,7 @@ export const HQ_ADMIN_ROLES = [
] as const; ] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = { export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key), SUPER_ADMIN: hqBasePermissionKeys(),
OPS: [ OPS: [
'dashboard', 'dashboard',
'users', 'users',
+3 -2
View File
@@ -59,8 +59,9 @@ WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建) # 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(地理编码 / 逆地理 / 地图选点组件 # 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用
# 地图选点需在控制台为 Key 配置域名白名单,并允许组件域名 apis.map.qq.com # 控制台须开启 WebServiceAPI;服务端调用建议 Key 不设域名白名单,或改用 IP 白名单
# (浏览器内嵌官方选点组件已弃用,避免 mapapi.qq.com / formatted_addresses 崩溃)
TENCENT_LBS_KEY= TENCENT_LBS_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错) # 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
@@ -7,10 +7,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { import {
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys, expandHqPermissionKeys,
hasAnySystemSettingsPermission, hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module'; import { PrismaService } from '../prisma/prisma.module';
@@ -35,30 +35,29 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') { if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用'); throw new ForbiddenException('HQ 账号不可用');
} }
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
});
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
return HQ_PERMISSION_CATALOG.map((p) => p.key); // 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选
return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]);
} }
const [roleRows, userRows] = await Promise.all([ const roleRows = await this.prisma.hqRolePermission.findMany({
this.prisma.hqRolePermission.findMany({ where: { adminRole: account.adminRole },
where: { adminRole: account.adminRole }, select: { permissionKey: true },
select: { permissionKey: true }, });
}),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
}),
]);
const roleKeys = const roleKeys =
roleRows.length > 0 roleRows.length > 0
? roleRows.map((r) => r.permissionKey) ? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; : [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([ return expandHqPermissionKeys([...roleKeys, ...userKeys]);
...roleKeys,
...userRows.map((r) => r.permissionKey),
]);
} }
} }
@@ -28,6 +28,7 @@ export const HqOperationAction = {
STORE_AUDIT: 'STORE_AUDIT', STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE', STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE', STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE', STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE', STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE', STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
@@ -127,6 +128,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_AUDIT]: '门店审核', [HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户', [HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户', [HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源', [HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源', [HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源', [HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
@@ -107,7 +107,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key(地理编码 / 地图选点', group: G.app, type: 'password', secret: true, requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key须开 WebServiceAPI;服务端地点搜索/地理编码)', group: G.app, type: 'password', secret: true, requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false }, { key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false }, { key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
@@ -50,6 +50,14 @@ export class LlmChatClient {
Authorization: `Bearer ${params.apiKey}`, Authorization: `Bearer ${params.apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}).catch((err: unknown) => {
const cause =
err && typeof err === 'object' && 'cause' in err
? (err as { cause?: { code?: string; message?: string } }).cause
: undefined;
const detail = cause?.code || cause?.message || (err instanceof Error ? err.message : String(err));
this.logger.warn(`llm chat network error ${url}: ${detail}`);
throw new Error(`无法连接语言模型服务(${detail})。请检查 Base URL 是否可从服务器访问,或更换可达的模型网关`);
}); });
const text = await res.text(); const text = await res.text();
@@ -16,19 +16,51 @@ export type GeocodeAddressResult = {
logId: bigint; logId: bigint;
}; };
export type PlaceSuggestItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export type ReverseGeocodeDetailResult = {
latitude: number;
longitude: number;
address: string;
name?: string;
province: string;
city: string;
district: string;
logId: bigint;
};
function normalizeCityName(name: string) { function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim(); return name.replace(/市$/, '').trim();
} }
type TencentPlaceRow = {
id?: string;
title?: string;
address?: string;
city?: string;
location?: { lat?: number; lng?: number };
};
@Injectable() @Injectable()
export class TencentLbsProvider { export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name); private readonly logger = new Logger(TencentLbsProvider.name);
private readonly config = loadAppConfig();
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
isEnabled() { isEnabled() {
return !!this.config.tencentLbsKey; return !!this.getLbsKey();
} }
/** 地址 → 坐标(正向地理编码) */ /** 地址 → 坐标(正向地理编码) */
@@ -61,7 +93,7 @@ export class TencentLbsProvider {
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/'); const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('address', trimmed); url.searchParams.set('address', trimmed);
url.searchParams.set('key', this.config.tencentLbsKey); url.searchParams.set('key', this.getLbsKey());
try { try {
const res = await fetch(url.toString()); const res = await fetch(url.toString());
@@ -140,7 +172,7 @@ export class TencentLbsProvider {
const location = `${latitude},${longitude}`; const location = `${latitude},${longitude}`;
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/'); const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', location); url.searchParams.set('location', location);
url.searchParams.set('key', this.config.tencentLbsKey); url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '0'); url.searchParams.set('get_poi', '0');
try { try {
@@ -199,4 +231,202 @@ export class TencentLbsProvider {
return null; return null;
} }
} }
private mapPlaceRows(rows: TencentPlaceRow[] | undefined): PlaceSuggestItem[] {
if (!rows?.length) return [];
const out: PlaceSuggestItem[] = [];
for (const row of rows) {
const lat = Number(row.location?.lat);
const lng = Number(row.location?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const title = (row.title || '').trim();
const address = (row.address || '').trim();
if (!title && !address) continue;
out.push({
id: String(row.id || `${lat},${lng}`),
title: title || address,
address: address || title,
latitude: lat,
longitude: lng,
city: row.city?.trim() || undefined,
});
}
return out;
}
/** 关键词输入提示(地点搜索) */
async suggestPlaces(
keyword: string,
options?: { region?: string; latitude?: number; longitude?: number },
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
const trimmed = keyword.trim();
if (!trimmed) return { items: [] };
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/place/v1/suggestion');
url.searchParams.set('keyword', trimmed.slice(0, 64));
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('policy', '1');
url.searchParams.set('page_index', '1');
url.searchParams.set('page_size', '20');
const region = options?.region?.trim();
if (region) url.searchParams.set('region', region);
if (
options?.latitude != null &&
options?.longitude != null &&
Number.isFinite(options.latitude) &&
Number.isFinite(options.longitude)
) {
url.searchParams.set('location', `${options.latitude},${options.longitude}`);
}
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS suggest failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '地点搜索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS suggest failed: ${message}`);
return { items: [], error: message };
}
}
/** 周边地点(打开选点时预填附近列表) */
async exploreNearby(
latitude: number,
longitude: number,
radiusMeters = 1000,
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { items: [], error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const radius = Math.min(5000, Math.max(200, Math.round(radiusMeters)));
const url = new URL('https://apis.map.qq.com/ws/place/v1/explore');
url.searchParams.set('boundary', `nearby(${latitude},${longitude},${radius})`);
url.searchParams.set('policy', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('key', this.getLbsKey());
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS explore failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '周边检索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS explore failed: ${message}`);
return { items: [], error: message };
}
}
/** 逆地理(含地址文案,供选点回填) */
async reverseGeocodeDetail(
latitude: number,
longitude: number,
): Promise<{ item: ReverseGeocodeDetailResult | null; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { item: null, error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { item: null, error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', `${latitude},${longitude}`);
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '1');
try {
const res = await fetch(url.toString());
const data = (await res.json()) as {
status?: number;
message?: string;
result?: {
address?: string;
formatted_addresses?: { recommend?: string; rough?: string };
address_component?: {
province?: string;
city?: string;
district?: string;
street?: string;
street_number?: string;
};
ad_info?: {
province?: string;
city?: string;
district?: string;
};
pois?: Array<{ title?: string; address?: string }>;
};
};
if (data.status !== 0 || !data.result) {
return { item: null, error: data.message || '逆地理编码失败' };
}
const result = data.result;
const ad = result.ad_info ?? result.address_component;
const province = ad?.province ?? '';
const city = normalizeCityName(ad?.city ?? '');
const district = ad?.district ?? '';
const recommend =
result.formatted_addresses?.recommend?.trim() ||
result.formatted_addresses?.rough?.trim() ||
result.address?.trim() ||
'';
const poiTitle = result.pois?.[0]?.title?.trim();
if (!recommend && !poiTitle) {
return { item: null, error: '未解析到地址' };
}
const log = await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_MAP',
scene: 'REVERSE_GEOCODE',
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: { latitude, longitude, detail: true },
responseBody: {
status: data.status,
address: recommend,
city,
},
status: 'SUCCESS',
},
});
return {
item: {
latitude,
longitude,
address: recommend || poiTitle || '',
name: poiTitle || recommend || undefined,
province,
city,
district,
logId: log.id,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS reverse detail failed: ${message}`);
return { item: null, error: message };
}
}
} }
@@ -17,7 +17,7 @@ export class ClientConfigController {
mockSms: cfg.mockSms, mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat, mockWechat: cfg.mockWechat,
wxAuthorize: cfg.wxAuthorize, wxAuthorize: cfg.wxAuthorize,
/** 浏览器地图选点用;建议在腾讯控制台按域名限制 Key */ /** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined, tencentLbsKey: cfg.tencentLbsKey || undefined,
miniHome: { miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
@@ -14,6 +14,7 @@ import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller'; import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller'; import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller'; import { ClientConfigController } from './client-config.controller';
import { LbsController } from './lbs.controller';
import { WechatLocationService } from './wechat-location.service'; import { WechatLocationService } from './wechat-location.service';
@Module({ @Module({
@@ -25,6 +26,7 @@ import { WechatLocationService } from './wechat-location.service';
ThirdPartyLogController, ThirdPartyLogController,
WechatController, WechatController,
ClientConfigController, ClientConfigController,
LbsController,
], ],
providers: [ providers: [
ResourceService, ResourceService,
@@ -0,0 +1,91 @@
import {
BadRequestException,
Controller,
Get,
Query,
ServiceUnavailableException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
function parseCoord(raw: string | undefined, label: string): number | undefined {
if (raw == null || raw === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) throw new BadRequestException(`${label}无效`);
return n;
}
@Controller('common/lbs')
@UseGuards(JwtAuthGuard)
export class LbsController {
constructor(private readonly tencentLbs: TencentLbsProvider) {}
@Get('suggest')
async suggest(
@Query('keyword') keyword?: string,
@Query('region') region?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const q = (keyword ?? '').trim();
if (!q) return { items: [] };
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
const result = await this.tencentLbs.suggestPlaces(q, {
region: region?.trim() || undefined,
latitude,
longitude,
});
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('nearby')
async nearby(@Query('lat') lat?: string, @Query('lng') lng?: string, @Query('radius') radius?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const r = radius != null && radius !== '' ? Number(radius) : 1000;
const result = await this.tencentLbs.exploreNearby(latitude, longitude, Number.isFinite(r) ? r : 1000);
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('reverse')
async reverse(@Query('lat') lat?: string, @Query('lng') lng?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const result = await this.tencentLbs.reverseGeocodeDetail(latitude, longitude);
if (!result.item) {
throw new BadRequestException(result.error || '逆地理编码失败');
}
return {
latitude: result.item.latitude,
longitude: result.item.longitude,
address: result.item.address,
name: result.item.name,
province: result.item.province,
city: result.item.city,
district: result.item.district,
};
}
}
@@ -1,11 +1,22 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsString, MinLength } from 'class-validator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminCitiesService } from './admin-cities.service'; import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto'; import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
class DeleteCityDto {
@IsString()
@MinLength(1)
confirmName!: string;
}
@Controller('admin/cities') @Controller('admin/cities')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard)
export class AdminCitiesController { export class AdminCitiesController {
@@ -16,6 +27,13 @@ export class AdminCitiesController {
return this.service.list(query); return this.service.list(query);
} }
@Get(':id/delete-preview')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
deletePreview(@Param('id') id: string) {
return this.service.deletePreview(BigInt(id));
}
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
return this.service.detail(BigInt(id)); return this.service.detail(BigInt(id));
@@ -37,4 +55,17 @@ export class AdminCitiesController {
update(@Param('id') id: string, @Body() dto: UpdateCityDto) { update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
return this.service.update(BigInt(id), dto); return this.service.update(BigInt(id), dto);
} }
@Delete(':id')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
@HqOperation({
action: HqOperationAction.CITY_DELETE,
refType: 'CITY',
refIdParam: 'id',
includeBody: true,
})
remove(@Param('id') id: string, @Body() dto: DeleteCityDto) {
return this.service.deleteCity(BigInt(id), dto.confirmName);
}
} }
@@ -144,4 +144,242 @@ export class AdminCitiesService {
}); });
return serializeBigInt(city); return serializeBigInt(city);
} }
/** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */
async deletePreview(id: bigint) {
const city = await this.prisma.commonCity.findUnique({
where: { id },
select: { id: true, code: true, name: true, province: true, status: true },
});
if (!city) throw new NotFoundException('开城城市不存在');
const [primaries, staff, stores, warehouses, orderCount] = await Promise.all([
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
bindingStatus: true,
scopeType: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
parentAccountId: true,
staffRole: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.store.findMany({
where: { cityId: id },
select: {
id: true,
name: true,
phone: true,
status: true,
auditStatus: true,
address: true,
partnerAccountId: true,
partnerAccount: { select: { companyName: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.findMany({
where: { cityId: id },
select: { id: true, name: true, status: true, address: true },
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({ where: { cityId: id } }),
]);
const storeIds = stores.map((s) => s.id);
const partnerIds = [...primaries, ...staff].map((p) => p.id);
const [redeemCount, partnerBillCount] = await Promise.all([
storeIds.length
? this.prisma.redeemRecord.count({ where: { storeId: { in: storeIds } } })
: Promise.resolve(0),
partnerIds.length
? this.prisma.partnerBill.count({ where: { partnerAccountId: { in: partnerIds } } })
: Promise.resolve(0),
]);
const warnings: string[] = [];
const blockers: string[] = [];
if (orderCount > 0) blockers.push(`该城市下已有 ${orderCount} 笔订单,无法删除`);
if (redeemCount > 0) warnings.push(`门店核销记录 ${redeemCount} 笔将删除,并回滚对应权益券余额`);
if (partnerBillCount > 0) warnings.push(`合伙人账单 ${partnerBillCount} 条将一并删除`);
if (stores.length) warnings.push(`将删除 ${stores.length} 家门店及其门店账号绑定`);
if (primaries.length || staff.length) {
warnings.push(`将删除 ${primaries.length} 个合伙人主账号、${staff.length} 个子账号`);
}
if (warehouses.length) warnings.push(`将删除 ${warehouses.length} 个城市仓库`);
return serializeBigInt({
city,
canDelete: blockers.length === 0,
blockers,
warnings,
summary: {
primaryPartnerCount: primaries.length,
staffCount: staff.length,
storeCount: stores.length,
warehouseCount: warehouses.length,
orderCount,
redeemCount,
partnerBillCount,
},
partners: primaries.map((p) => ({
...p,
staff: staff.filter((s) => s.parentAccountId === p.id),
})),
orphanStaff: staff.filter(
(s) => !s.parentAccountId || !primaries.some((p) => p.id === s.parentAccountId),
),
stores,
warehouses,
});
}
async deleteCity(id: bigint, confirmName: string) {
const preview = await this.deletePreview(id);
if (!preview.canDelete) {
throw new BadRequestException(preview.blockers.join('') || '当前城市不可删除');
}
const expected = String(preview.city.name || '').trim();
if (!confirmName?.trim() || confirmName.trim() !== expected) {
throw new BadRequestException(`请输入城市名称「${expected}」以确认删除`);
}
await this.prisma.$transaction(async (tx) => {
const storeIds = (preview.stores as Array<{ id: string | number | bigint }>).map((s) =>
BigInt(s.id),
);
const partnerIdSet = new Set<string>();
for (const p of preview.partners as Array<{
id: string | number | bigint;
staff?: Array<{ id: string | number | bigint }>;
}>) {
partnerIdSet.add(String(p.id));
for (const s of p.staff ?? []) partnerIdSet.add(String(s.id));
}
for (const s of preview.orphanStaff as Array<{ id: string | number | bigint }>) {
partnerIdSet.add(String(s.id));
}
const uniquePartnerIds = [...partnerIdSet].map(BigInt);
if (storeIds.length) {
await this.purgeStoresInTx(tx, storeIds);
}
if (uniquePartnerIds.length) {
await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: uniquePartnerIds } } });
await tx.$executeRaw`
DELETE FROM log_partner_analytics WHERE partner_account_id IN (${Prisma.join(uniquePartnerIds)})
`;
await tx.partnerAccount.updateMany({
where: { id: { in: uniquePartnerIds } },
data: { managedWarehouseId: null },
});
await tx.cityWarehouse.updateMany({
where: { cityId: id },
data: { partnerAccountId: null },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 0 },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 1 },
});
}
await tx.cityWarehouse.deleteMany({ where: { cityId: id } });
await tx.commonCity.delete({ where: { id } });
});
return { ok: true, id: id.toString(), name: preview.city.name };
}
/** 事务内清除门店及核销/结算/绑定(回滚权益券核销额) */
private async purgeStoresInTx(tx: Prisma.TransactionClient, storeIds: bigint[]) {
const redeems = await tx.redeemRecord.findMany({
where: { storeId: { in: storeIds } },
include: { allocations: true },
});
const restoreMap = new Map<string, Prisma.Decimal>();
for (const r of redeems) {
if (r.allocations.length) {
for (const a of r.allocations) {
const key = a.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(a.amount));
}
} else {
const key = r.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(r.amount));
}
}
for (const [couponId, amount] of restoreMap) {
const coupon = await tx.benefitCoupon.findUnique({ where: { id: BigInt(couponId) } });
if (!coupon) continue;
const used = new Prisma.Decimal(coupon.usedAmount).sub(amount);
const balance = new Prisma.Decimal(coupon.balance).add(amount);
const nextUsed = used.lt(0) ? new Prisma.Decimal(0) : used;
const nextBalance = Prisma.Decimal.min(balance, coupon.totalAmount);
await tx.benefitCoupon.update({
where: { id: BigInt(couponId) },
data: {
usedAmount: nextUsed,
balance: nextBalance,
status: nextBalance.gt(0) ? 'ACTIVE' : coupon.status,
version: { increment: 1 },
},
});
}
const redeemIds = redeems.map((r) => r.id);
await tx.storePayout.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.storeRating.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.redeemPendingRecord.deleteMany({ where: { storeId: { in: storeIds } } });
if (redeemIds.length) {
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.$executeRaw`DELETE FROM log_store_analytics WHERE store_id IN (${Prisma.join(storeIds)})`;
const bindings = await tx.storeAccountStore.findMany({
where: { storeId: { in: storeIds } },
select: { storeAccountId: true },
});
const accountIds = [...new Set(bindings.map((b) => b.storeAccountId.toString()))].map(BigInt);
await tx.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
const orphanAccountIds: bigint[] = [];
for (const aid of accountIds) {
const other = await tx.storeAccountStore.count({
where: { storeAccountId: aid, storeId: { notIn: storeIds } },
});
if (other === 0) orphanAccountIds.push(aid);
}
if (orphanAccountIds.length) {
await tx.storeAccount.deleteMany({ where: { parentAccountId: { in: orphanAccountIds } } });
await tx.storeAccount.deleteMany({ where: { id: { in: orphanAccountIds } } });
}
await tx.store.updateMany({ where: { id: { in: storeIds } }, data: { coverResourceId: null } });
await tx.store.deleteMany({ where: { id: { in: storeIds } } });
}
} }
@@ -1,5 +1,6 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminDashboardService } from './admin-dashboard.service'; import { AdminDashboardService } from './admin-dashboard.service';
@Controller('admin/dashboard') @Controller('admin/dashboard')
@@ -13,6 +14,7 @@ export class AdminDashboardController {
} }
@Get('version') @Get('version')
@UseGuards(SuperAdminGuard)
version() { version() {
return this.dashboardService.getLatestVersion(); return this.dashboardService.getLatestVersion();
} }
@@ -4,6 +4,7 @@ import {
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY, LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys, expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
@@ -49,7 +50,7 @@ export class AdminHqPermissionsService {
async saveRolePermissions(role: string, permissionKeys: string[]) { async saveRolePermissions(role: string, permissionKeys: string[]) {
if (role === 'SUPER_ADMIN') { if (role === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置'); throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
} }
assertPermissionKeys(permissionKeys); assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys);
@@ -73,24 +74,28 @@ export class AdminHqPermissionsService {
select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true }, select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true },
}); });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
const userPerms = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
});
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = hqBasePermissionKeys();
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
return serializeBigInt({ return serializeBigInt({
account, account,
permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), permissionKeys: userPermissionKeys,
rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), rolePermissionKeys,
userPermissionKeys: [], userPermissionKeys,
effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), effectivePermissionKeys,
}); });
} }
const [rolePerms, userPerms] = await Promise.all([ const rolePerms = await this.getRolePermissions(account.adminRole);
this.getRolePermissions(account.adminRole),
this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
}),
]);
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
const effectivePermissionKeys = [ const effectivePermissionKeys = [
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[]; ] as HqPermissionKey[];
@@ -107,9 +112,6 @@ export class AdminHqPermissionsService {
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
if (account.adminRole === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
}
assertPermissionKeys(permissionKeys); assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys);
await this.prisma.$transaction([ await this.prisma.$transaction([
@@ -1,6 +1,9 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminOrdersService } from './admin-orders.service'; import { AdminOrdersService } from './admin-orders.service';
@@ -18,7 +21,8 @@ export class AdminOrdersController {
} }
@Post('batch-delete') @Post('batch-delete')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.ORDER_BATCH_DELETE, action: HqOperationAction.ORDER_BATCH_DELETE,
refType: 'ORDER', refType: 'ORDER',
@@ -191,8 +191,8 @@ export class AdminPartnersService {
orderCommissionRate: orderCommissionRate, orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate, redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus, bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName.trim(), companyName: dto.companyName?.trim() || null,
address: dto.address.trim(), address: dto.address?.trim() || null,
contactPhone: dto.contactPhone?.trim() ?? phone, contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo, contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName, bankAccountName: dto.bankAccountName,
@@ -94,6 +94,16 @@ export class AdminStoreAccountsController {
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) { update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
return this.service.updateStoreAccount(BigInt(id), dto); return this.service.updateStoreAccount(BigInt(id), dto);
} }
@Delete(':id/staff/:staffId')
@HqOperation({
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
refType: 'STORE_ACCOUNT',
refIdParam: 'staffId',
})
deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) {
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId));
}
} }
@Controller('admin/store-media') @Controller('admin/store-media')
@@ -17,6 +17,14 @@ import type {
UpdateStoreStatusDto, UpdateStoreStatusDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
@Injectable() @Injectable()
export class AdminStoresService { export class AdminStoresService {
constructor( constructor(
@@ -106,6 +114,8 @@ export class AdminStoresService {
return serializeBigInt(mapStoreCompat({ return serializeBigInt(mapStoreCompat({
...store, ...store,
account: store.bindings[0]?.storeAccount ?? null, account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
bindings: undefined, bindings: undefined,
media, media,
audits, audits,
@@ -208,49 +218,152 @@ export class AdminStoresService {
} }
} }
const store = await this.prisma.store.update({ let categoryId: bigint | undefined;
where: { id }, if (dto.categoryId !== undefined) {
data: { if (!dto.categoryId?.trim()) {
...(dto.name !== undefined ? { name: dto.name } : {}), throw new BadRequestException('请选择门店分类');
...(dto.phone !== undefined ? { phone: dto.phone } : {}), }
...(dto.intro !== undefined ? { intro: dto.intro } : {}), categoryId = BigInt(dto.categoryId);
...(dto.benefitUsageRule !== undefined await this.storeCategoryService.assertLeafCategoryId(categoryId);
? { 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 } : {}),
},
});
if (dto.coverUrl) { if (dto.phone !== undefined) {
if (current.coverResourceId) { const normalizedPhone = dto.phone.trim();
await this.prisma.commonResource.update({ if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
where: { id: current.coverResourceId }, throw new BadRequestException('请输入正确的手机号码');
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 } });
} }
} }
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('银行卡号须为 1619 位数字');
}
}
}
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: normalizeStoreOptionalText(dto.benefitUsageRule) }
: {}),
...(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); return this.detailStore(id);
} }
@@ -312,7 +425,7 @@ export class AdminStoresService {
if (intro && (intro.length < 2 || intro.length > 500)) { if (intro && (intro.length < 2 || intro.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字'); throw new BadRequestException('门店简介须为 2~500 字');
} }
const benefitUsageRule = dto.benefitUsageRule?.trim() || null; const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
if (benefitUsageRule && benefitUsageRule.length > 1000) { if (benefitUsageRule && benefitUsageRule.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字'); throw new BadRequestException('好客权益券使用规则最多 1000 字');
} }
@@ -612,4 +725,26 @@ export class AdminStoresService {
}); });
return serializeBigInt(account); return serializeBigInt(account);
} }
/** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在');
}
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId, isPrimary: 0 },
});
if (!staff) throw new NotFoundException('子账号不存在');
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: staffId },
});
if (pending > 0) {
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
}
await this.prisma.storeAccount.delete({ where: { id: staffId } });
return { ok: true };
}
} }
@@ -1,6 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminUsersService } from './admin-users.service'; import { AdminUsersService } from './admin-users.service';
@@ -18,13 +21,15 @@ export class AdminUsersController {
} }
@Post('batch-delete/preview') @Post('batch-delete/preview')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) { previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id))); return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
} }
@Post('batch-delete') @Post('batch-delete')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.USER_BATCH_DELETE, action: HqOperationAction.USER_BATCH_DELETE,
refType: 'USER', refType: 'USER',
@@ -44,7 +49,8 @@ export class AdminUsersController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(SuperAdminGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.USER_DELETE, action: HqOperationAction.USER_DELETE,
refType: 'USER', refType: 'USER',
@@ -162,10 +162,22 @@ export class UpdateStoreDto {
@IsNumber() @IsNumber()
longitude?: number; longitude?: number;
@IsOptional()
@IsString()
province?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
district?: string; district?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
@@ -191,6 +203,18 @@ export class UpdateStoreDto {
@IsNumber() @IsNumber()
@Min(0) @Min(0)
avgPrice?: number | null; avgPrice?: number | null;
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
} }
export class CreateStoreAccountDto { export class CreateStoreAccountDto {
@@ -234,13 +258,13 @@ export class CreatePartnerDto {
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() companyName?: string;
companyName: string;
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() address?: string;
address: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -28,6 +28,14 @@ function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number)
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
} }
/** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null { function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
@@ -301,9 +309,8 @@ export class StoreService {
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) { if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字'); throw new BadRequestException('门店简介须为 2~500 字');
} }
const benefitUsageRuleRaw = const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
body.benefitUsageRule != null ? String(body.benefitUsageRule).trim() : ''; if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
if (benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字'); throw new BadRequestException('好客权益券使用规则最多 1000 字');
} }
@@ -325,7 +332,7 @@ export class StoreService {
district: String(body.district ?? ''), district: String(body.district ?? ''),
address: String(body.address), address: String(body.address),
intro: introRaw || null, intro: introRaw || null,
benefitUsageRule: benefitUsageRuleRaw || null, benefitUsageRule: benefitUsageRuleRaw,
avgPrice: avgPriceRaw, avgPrice: avgPriceRaw,
openTime, openTime,
closeTime, closeTime,
@@ -522,7 +529,9 @@ export class StoreService {
const address = body.address !== undefined ? String(body.address).trim() : undefined; const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined; const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const benefitUsageRuleRaw = const benefitUsageRuleRaw =
body.benefitUsageRule !== undefined ? String(body.benefitUsageRule).trim() : undefined; body.benefitUsageRule !== undefined
? normalizeOptionalTextField(body.benefitUsageRule)
: undefined;
const latitude = const latitude =
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined; body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
const longitude = const longitude =
@@ -536,7 +545,7 @@ export class StoreService {
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) { if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字'); throw new BadRequestException('门店简介须为 2~500 字');
} }
if (benefitUsageRuleRaw !== undefined && benefitUsageRuleRaw.length > 1000) { if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字'); throw new BadRequestException('好客权益券使用规则最多 1000 字');
} }
if ( if (
@@ -561,7 +570,7 @@ export class StoreService {
...(hasCoordsUpdate ? { latitude, longitude } : {}), ...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}), ...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(benefitUsageRuleRaw !== undefined ...(benefitUsageRuleRaw !== undefined
? { benefitUsageRule: benefitUsageRuleRaw || null } ? { benefitUsageRule: benefitUsageRuleRaw }
: {}), : {}),
...(resubmitAudit ...(resubmitAudit
? { ? {