Compare commits
10 Commits
e2fd28a35b
..
v3.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 309b180087 | |||
| 118d57d710 | |||
| 0cb2b2cebb | |||
| daeb24bbeb | |||
| f4766d32ff | |||
| b920c894b9 | |||
| 37038a7591 | |||
| 944306e45c | |||
| 9ed0c24d11 | |||
| 50349dd8e9 |
@@ -281,11 +281,11 @@ export default function CityPartnersPanel({
|
||||
label: '主账号',
|
||||
children: (
|
||||
<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="phone" label="登录手机号" rules={[{ required: true }]}><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 }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
|
||||
}
|
||||
}}>
|
||||
<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="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 }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Spin, Typography, message } from 'antd';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
buildTencentLocPickerUrl,
|
||||
parseTencentLocPickerMessage,
|
||||
placeToPicked,
|
||||
type LbsPlaceItem,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
@@ -14,19 +13,14 @@ type Props = {
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: 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>('/common/client-config');
|
||||
const key = (cfg.tencentLbsKey || '').trim();
|
||||
if (!key) {
|
||||
throw new Error('未配置腾讯位置服务 Key,请在系统设置中填写 TENCENT_LBS_KEY');
|
||||
}
|
||||
cachedKey = key;
|
||||
return key;
|
||||
function hasCoords(lat: unknown, lng: unknown): lat is number {
|
||||
const a = typeof lat === 'number' ? lat : Number(lat);
|
||||
const b = typeof lng === 'number' ? lng : Number(lng);
|
||||
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
|
||||
}
|
||||
|
||||
export default function TencentLocPickerModal({
|
||||
@@ -35,81 +29,202 @@ export default function TencentLocPickerModal({
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
}: Props) {
|
||||
const [key, setKey] = useState<string | null>(cachedKey ?? null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setError('');
|
||||
if (key) return;
|
||||
setLoading(true);
|
||||
void loadTencentLbsKey()
|
||||
.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();
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setItems([]);
|
||||
setPending(null);
|
||||
setHint('输入地点名称搜索,或加载附近地点');
|
||||
return;
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [open, onPick, onClose]);
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (hasCoords(lat, lng)) {
|
||||
void loadNearby(lat, lng);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (!key) return '';
|
||||
return buildTencentLocPickerUrl(key, {
|
||||
latitude: latitude != null ? Number(latitude) : undefined,
|
||||
longitude: longitude != null ? Number(longitude) : undefined,
|
||||
});
|
||||
}, [key, latitude, longitude]);
|
||||
async function loadNearby(lat: number, lng: number) {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('正在加载附近地点…');
|
||||
try {
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
|
||||
);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '加载附近地点失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
message.warning('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('搜索中…');
|
||||
try {
|
||||
const params = new URLSearchParams({ keyword: trimmed });
|
||||
if (region?.trim()) params.set('region', region.trim());
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
params.set('lat', String(lat));
|
||||
params.set('lng', String(lng));
|
||||
}
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '搜索失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function useBrowserLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('当前浏览器不支持定位');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
|
||||
void loadNearby(lat, lng);
|
||||
},
|
||||
() => {
|
||||
setLoading(false);
|
||||
message.error('定位失败,请检查浏览器定位权限');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 12000 },
|
||||
);
|
||||
}
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) {
|
||||
message.warning('请先从列表中选择一个地点');
|
||||
return;
|
||||
}
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="腾讯地图选点"
|
||||
title="地图选点(腾讯位置服务)"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={720}
|
||||
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 ? (
|
||||
<div style={{ height: 560, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="加载地图…" />
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => void runSearch(keyword)}
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space wrap>
|
||||
<Button onClick={useBrowserLocation} disabled={loading}>
|
||||
定位当前位置
|
||||
</Button>
|
||||
<Typography.Text type="secondary">{hint}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||
{loading && !items.length ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="加载中…" />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
renderItem={(item) => {
|
||||
const active =
|
||||
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
|
||||
paddingInline: 12,
|
||||
}}
|
||||
onClick={() => setPending(placeToPicked(item))}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={
|
||||
<span>
|
||||
{item.address}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 80 }} description={hint} />
|
||||
)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<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}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||
|
||||
@@ -6,55 +6,21 @@ export type TencentPickedLocation = {
|
||||
cityname?: string;
|
||||
};
|
||||
|
||||
type LocPickerMessage = {
|
||||
module?: string;
|
||||
latlng?: { lat?: number; lng?: number };
|
||||
poiaddress?: string;
|
||||
poiname?: string;
|
||||
cityname?: string;
|
||||
export type LbsPlaceItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
const REFERER = 'dukang-haoke';
|
||||
|
||||
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;
|
||||
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
address: loc.poiaddress?.trim() || undefined,
|
||||
name: loc.poiname?.trim() || undefined,
|
||||
cityname: loc.cityname?.trim() || undefined,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
address: item.address || undefined,
|
||||
name: item.title || undefined,
|
||||
cityname: item.city || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
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 { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -50,6 +51,40 @@ type Row = {
|
||||
|
||||
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 }));
|
||||
|
||||
export default function CitiesPage() {
|
||||
@@ -82,6 +117,18 @@ export default function CitiesPage() {
|
||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const [warehouseManagerType, setWarehouseManagerType] = 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 res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
||||
@@ -129,22 +176,85 @@ export default function CitiesPage() {
|
||||
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> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ 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: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</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);
|
||||
void reload();
|
||||
}}>保存</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>
|
||||
</>
|
||||
),
|
||||
@@ -407,6 +537,140 @@ export default function CitiesPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +301,16 @@ export default function CityPartnersPage() {
|
||||
ellipsis: true,
|
||||
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: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
@@ -444,7 +453,11 @@ export default function CityPartnersPage() {
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<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}>
|
||||
{detail.managedWarehouseName ?? (
|
||||
<Typography.Text type="secondary">
|
||||
@@ -460,8 +473,8 @@ export default function CityPartnersPage() {
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item name="companyName" label="公司名">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
@@ -472,8 +485,8 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="contactPhone" label="业务联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
@@ -580,8 +593,8 @@ export default function CityPartnersPage() {
|
||||
onChange={(id) => void onCreateCityChange(id)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item name="companyName" label="公司名">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
@@ -589,8 +602,8 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
@@ -599,8 +612,7 @@ export default function CityPartnersPage() {
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -52,8 +52,18 @@ export default function DashboardPage() {
|
||||
request<DashboardStats>('/admin/dashboard/stats')
|
||||
.then(setStats)
|
||||
.finally(() => setLoading(false));
|
||||
void loadVersion();
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
request<HqProfile>('/admin/auth/me')
|
||||
.then((p) => {
|
||||
setProfile(p);
|
||||
if (p.adminRole === 'SUPER_ADMIN') {
|
||||
void loadVersion();
|
||||
} else {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setVersionLoading(false);
|
||||
});
|
||||
}, [loadVersion]);
|
||||
|
||||
function handleDeploy() {
|
||||
@@ -83,6 +93,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<Card
|
||||
title="系统版本"
|
||||
loading={versionLoading}
|
||||
@@ -92,16 +103,14 @@ export default function DashboardPage() {
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||||
刷新版本
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={deploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
发布更新
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={deploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
发布更新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -122,6 +131,7 @@ export default function DashboardPage() {
|
||||
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
@@ -36,6 +35,13 @@ const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.lab
|
||||
|
||||
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||
|
||||
function groupColSpan(itemCount: number): number {
|
||||
if (itemCount <= 2) return 12;
|
||||
if (itemCount === 3) return 8;
|
||||
if (itemCount === 4) return 6;
|
||||
return 8;
|
||||
}
|
||||
|
||||
function PermissionChecklist({
|
||||
value,
|
||||
onChange,
|
||||
@@ -54,19 +60,39 @@ function PermissionChecklist({
|
||||
>
|
||||
{CATALOG_GROUPS.map((group) => {
|
||||
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||
const span = groupColSpan(items.length);
|
||||
const compact = items.length <= 3;
|
||||
return (
|
||||
<div key={group} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
<div
|
||||
key={group}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
<Row gutter={[8, 8]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} span={8}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Divider style={{ margin: '12px 0 0' }} />
|
||||
{compact ? (
|
||||
<Space size={[24, 8]} wrap>
|
||||
{items.map((item) => (
|
||||
<Checkbox key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Row gutter={[12, 10]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -88,6 +114,12 @@ export default function HqPermissionsPage() {
|
||||
const [accountLoading, setAccountLoading] = useState(false);
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((a) => a.id === accountId),
|
||||
[accounts, accountId],
|
||||
);
|
||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const previewEffectiveKeys = useMemo(
|
||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||
[roleInheritedKeys, accountKeys],
|
||||
@@ -172,7 +204,8 @@ export default function HqPermissionsPage() {
|
||||
<div>
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||
</Typography.Paragraph>
|
||||
|
||||
@@ -198,7 +231,11 @@ export default function HqPermissionsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{role === 'SUPER_ADMIN' ? (
|
||||
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
||||
@@ -227,12 +264,10 @@ export default function HqPermissionsPage() {
|
||||
value={accountId}
|
||||
onChange={setAccountId}
|
||||
optionFilterProp="label"
|
||||
options={accounts
|
||||
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
|
||||
.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||||
}))}
|
||||
options={accounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -240,32 +275,57 @@ export default function HqPermissionsPage() {
|
||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||
) : (
|
||||
<>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<span>角色继承:</span>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{!roleInheritedKeys.length ? (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。
|
||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
||||
</Typography.Paragraph>
|
||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<span>合并生效:</span>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>基础权限(全部)</Tag>
|
||||
{accountKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key} color="orange">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||
保存用户权限
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
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 {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
@@ -154,6 +154,7 @@ export default function OrdersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -174,12 +175,17 @@ export default function OrdersPage() {
|
||||
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
[data?.items, selectedRowKeys],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
@@ -454,13 +460,15 @@ export default function OrdersPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => setBatchDeleteOpen(true)}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
{canDeleteOrders ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => setBatchDeleteOpen(true)}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
@@ -504,10 +512,10 @@ export default function OrdersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1500 }}
|
||||
rowSelection={{
|
||||
rowSelection={canDeleteOrders ? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -248,11 +248,11 @@ export default function PartnersPage() {
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<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="phone" label="登录手机号" rules={[{ required: true }]}><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 }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
@@ -348,10 +348,10 @@ export default function PartnersPage() {
|
||||
}}
|
||||
/>
|
||||
</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="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 }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
@@ -359,8 +359,7 @@ export default function PartnersPage() {
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
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> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
|
||||
dataIndex: 'status',
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Space,
|
||||
Steps,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
@@ -321,10 +322,18 @@ type CategoryNode = {
|
||||
|
||||
export default function StoresPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCityId = searchParams.get('cityId') ?? '';
|
||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
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>(
|
||||
'/admin/stores',
|
||||
() => {
|
||||
@@ -333,10 +342,14 @@ export default function StoresPage() {
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
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;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
|
||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
@@ -352,11 +365,48 @@ export default function StoresPage() {
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||
|
||||
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 selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
|
||||
|
||||
const categoryParentOptions = useMemo(
|
||||
() =>
|
||||
@@ -372,6 +422,140 @@ export default function StoresPage() {
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, selectedCategoryParentId]);
|
||||
|
||||
const editCategoryChildOptions = useMemo(() => {
|
||||
const parent = categoryTree.find((n) => n.id === editCategoryParentId);
|
||||
return (parent?.children ?? [])
|
||||
.filter((n) => n.status !== 'INACTIVE')
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, editCategoryParentId]);
|
||||
|
||||
async function openStoreDetail(row: StoreRow) {
|
||||
const [d, cats] = await Promise.all([
|
||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||
]);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
setDetail(d);
|
||||
const category = d.category && typeof d.category === 'object'
|
||||
? (d.category as { id?: string; parentId?: string | null })
|
||||
: null;
|
||||
const account = d.account && typeof d.account === 'object'
|
||||
? (d.account as {
|
||||
phone?: string | null;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
})
|
||||
: null;
|
||||
const categoryId = category?.id != null ? String(category.id) : undefined;
|
||||
let parentId = category?.parentId != null ? String(category.parentId) : undefined;
|
||||
if (!parentId && categoryId) {
|
||||
for (const parent of cats) {
|
||||
if (parent.id === categoryId) {
|
||||
parentId = parent.id;
|
||||
break;
|
||||
}
|
||||
if ((parent.children ?? []).some((c) => c.id === categoryId)) {
|
||||
parentId = parent.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const loginPhone =
|
||||
(typeof d.loginPhone === 'string' && d.loginPhone) ||
|
||||
account?.phone ||
|
||||
(typeof d.phone === 'string' ? d.phone : undefined);
|
||||
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
|
||||
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
||||
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
||||
phone: storePhone || loginPhone,
|
||||
intro: d.intro,
|
||||
benefitUsageRule:
|
||||
d.benefitUsageRule != 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) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
if (!binding) {
|
||||
@@ -398,7 +582,7 @@ export default function StoresPage() {
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
|
||||
}, [selectedRegionCodes, cities, selectedPartnerId]);
|
||||
|
||||
async function loadOptions() {
|
||||
async function loadOptions(opts?: { quiet?: boolean }) {
|
||||
setOptionsLoading(true);
|
||||
try {
|
||||
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||
@@ -410,10 +594,12 @@ export default function StoresPage() {
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
if (!opts?.quiet) {
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||
@@ -579,28 +765,7 @@ export default function StoresPage() {
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
intro: d.intro,
|
||||
benefitUsageRule: d.benefitUsageRule,
|
||||
coverUrl: d.coverUrl,
|
||||
address: d.address,
|
||||
district: d.district,
|
||||
latitude: d.latitude != null ? Number(d.latitude) : undefined,
|
||||
longitude: d.longitude != null ? Number(d.longitude) : undefined,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
openTime: d.openTime || '10:00',
|
||||
closeTime: d.closeTime || '22:00',
|
||||
openTime2: d.openTime2 || undefined,
|
||||
closeTime2: d.closeTime2 || undefined,
|
||||
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -617,6 +782,26 @@ export default function StoresPage() {
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="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="营业状态">
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
@@ -629,7 +814,20 @@ export default function StoresPage() {
|
||||
/>
|
||||
</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>
|
||||
<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); } }} />
|
||||
@@ -670,7 +868,7 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
|
||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
@@ -678,205 +876,228 @@ export default function StoresPage() {
|
||||
setDetail({ ...detail, status });
|
||||
void reload();
|
||||
}} />
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const hasCoords =
|
||||
v.latitude != null &&
|
||||
v.longitude != null &&
|
||||
Number.isFinite(Number(v.latitude)) &&
|
||||
Number.isFinite(Number(v.longitude));
|
||||
const payload = {
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
coverUrl: v.coverUrl,
|
||||
intro: v.intro,
|
||||
benefitUsageRule: v.benefitUsageRule ?? null,
|
||||
district: v.district,
|
||||
address: v.address,
|
||||
avgPrice: v.avgPrice,
|
||||
openTime: v.openTime,
|
||||
closeTime: v.closeTime,
|
||||
openTime2: v.openTime2 || null,
|
||||
closeTime2: v.closeTime2 || null,
|
||||
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
};
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDetail({
|
||||
...detail,
|
||||
...payload,
|
||||
settlementRate: payload.settlementRate,
|
||||
latitude: hasCoords ? Number(v.latitude) : detail.latitude,
|
||||
longitude: hasCoords ? Number(v.longitude) : detail.longitude,
|
||||
});
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
||||
</Space>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店分类">
|
||||
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
|
||||
? String((detail.category as { name?: string }).name || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||
}>
|
||||
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="经纬度">
|
||||
{detail.latitude != null && detail.longitude != null
|
||||
? `${Number(detail.latitude).toFixed(6)}, ${Number(detail.longitude).toFixed(6)}`
|
||||
: '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="营业时间">
|
||||
{[
|
||||
detail.openTime && detail.closeTime
|
||||
? `${String(detail.openTime)}-${String(detail.closeTime)}`
|
||||
: null,
|
||||
detail.openTime2 && detail.closeTime2
|
||||
? `${String(detail.openTime2)}-${String(detail.closeTime2)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',') || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="人均费用">
|
||||
{detail.avgPrice != null ? `¥${Number(detail.avgPrice).toFixed(0)}` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算户名">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountName' in detail.account
|
||||
? String((detail.account as { bankAccountName?: string | null }).bankAccountName || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankBranch' in detail.account
|
||||
? String((detail.account as { bankBranch?: string | null }).bankBranch || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="银行卡号">
|
||||
{detail.account && typeof detail.account === 'object' && 'bankAccountNo' in detail.account
|
||||
? String((detail.account as { bankAccountNo?: string | null }).bankAccountNo || '—')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item
|
||||
name="benefitUsageRule"
|
||||
label="好客权益券使用规则"
|
||||
extra="展示在用户端门店详情「门店详情」下方"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 34.7466000"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 113.6253000"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
loading={locating}
|
||||
onClick={() => {
|
||||
fillGeolocation(
|
||||
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
|
||||
setLocating,
|
||||
);
|
||||
}}
|
||||
>
|
||||
获取当前位置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => {
|
||||
setMapPickerTarget('edit');
|
||||
setMapPickerOpen(true);
|
||||
}}
|
||||
>
|
||||
腾讯地图选点
|
||||
</Button>
|
||||
<Typography.Text type="secondary">
|
||||
搜索或拖图确认位置后自动填入经纬度
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本信息',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||
}>
|
||||
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>编辑门店信息</Typography.Title>
|
||||
{phoneMismatch ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机号"
|
||||
rules={[{ required: true }]}
|
||||
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryParentId"
|
||||
label="门店分类(大类)"
|
||||
rules={[{ required: true, message: '请选择门店大类' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
options={categoryParentOptions}
|
||||
onChange={() => editForm.setFieldValue('categoryId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryId"
|
||||
label="门店分类(细类)"
|
||||
rules={[{ required: true, message: '请选择门店细类' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={editCategoryParentId ? '选择细类' : '请先选大类'}
|
||||
disabled={!editCategoryParentId}
|
||||
options={editCategoryChildOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图 / 门头照">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item
|
||||
name="benefitUsageRule"
|
||||
label="好客权益券使用规则"
|
||||
extra="展示在用户端门店详情「门店详情」下方"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="city" label="城市" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 34.7466000"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
|
||||
<InputNumber
|
||||
style={{ width: 180 }}
|
||||
precision={7}
|
||||
step={0.000001}
|
||||
placeholder="如 113.6253000"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
loading={locating}
|
||||
onClick={() => {
|
||||
fillGeolocation(
|
||||
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
|
||||
setLocating,
|
||||
);
|
||||
}}
|
||||
>
|
||||
获取当前位置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => {
|
||||
setMapPickerTarget('edit');
|
||||
setMapPickerOpen(true);
|
||||
}}
|
||||
>
|
||||
腾讯地图选点
|
||||
</Button>
|
||||
</Space>
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTime2" label="第二段结束">
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settlement',
|
||||
label: '结算资质',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="结算户名">
|
||||
<Input placeholder="开户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户银行">
|
||||
<Input placeholder="如 中国工商银行某某支行" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccountNo"
|
||||
label="银行卡号"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return;
|
||||
if (!/^\d{16,19}$/.test(v)) {
|
||||
throw new Error('银行卡号须为 16–19 位数字');
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="16–19 位数字" maxLength={19} />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary">
|
||||
修改后点右上角「保存修改」一并提交。
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'media',
|
||||
label: '审核材料',
|
||||
children: <StoreAuditMediaSection detail={detail} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
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';
|
||||
|
||||
type UserOrderRow = {
|
||||
@@ -66,6 +66,7 @@ export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [form] = Form.useForm();
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -82,6 +83,11 @@ export default function UsersPage() {
|
||||
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
|
||||
const [batchDeleting, setBatchDeleting] = 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 () => {
|
||||
setLoading(true);
|
||||
@@ -309,13 +315,15 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => void openBatchDeleteModal()}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
{canDeleteUsers ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => void openBatchDeleteModal()}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
@@ -353,11 +361,11 @@ export default function UsersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1500 }}
|
||||
rowSelection={{
|
||||
rowSelection={canDeleteUsers ? {
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -375,9 +383,9 @@ export default function UsersPage() {
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
extra={detail && canDeleteUsers ? (
|
||||
<Button danger onClick={openDeleteModal}>删除用户</Button>
|
||||
)}
|
||||
) : undefined}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
buildTencentLocPickerUrl,
|
||||
parseTencentLocPickerMessage,
|
||||
placeToPicked,
|
||||
type LbsPlaceItem,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
@@ -13,77 +12,126 @@ type Props = {
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: 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({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
}: Props) {
|
||||
const [key, setKey] = useState<string | null>(cachedKey ?? null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setError('');
|
||||
if (key) return;
|
||||
setLoading(true);
|
||||
void loadTencentLbsKey()
|
||||
.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();
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setItems([]);
|
||||
setPending(null);
|
||||
setHint('输入地点名称搜索');
|
||||
return;
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [open, onPick, onClose]);
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
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(() => {
|
||||
if (!key) return '';
|
||||
return buildTencentLocPickerUrl(key, {
|
||||
latitude: latitude != null ? Number(latitude) : undefined,
|
||||
longitude: longitude != null ? Number(longitude) : undefined,
|
||||
});
|
||||
}, [key, latitude, longitude]);
|
||||
async function loadNearby(lat: number, lng: number) {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('正在加载附近地点…');
|
||||
try {
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||
'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;
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) return;
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -103,31 +151,114 @@ export default function TencentLocPickerOverlay({
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<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 style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{loading ? (
|
||||
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
|
||||
加载地图…
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="label-md" style={{ padding: 24, color: 'var(--color-heritage-red, #a61d24)' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : src ? (
|
||||
<iframe
|
||||
title="腾讯地图选点"
|
||||
src={src}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void runSearch();
|
||||
}}
|
||||
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,54 +6,21 @@ export type TencentPickedLocation = {
|
||||
cityname?: string;
|
||||
};
|
||||
|
||||
type LocPickerMessage = {
|
||||
module?: string;
|
||||
latlng?: { lat?: number; lng?: number };
|
||||
poiaddress?: string;
|
||||
poiname?: string;
|
||||
cityname?: string;
|
||||
export type LbsPlaceItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
const REFERER = 'dukang-haoke';
|
||||
|
||||
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;
|
||||
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
address: loc.poiaddress?.trim() || undefined,
|
||||
name: loc.poiname?.trim() || undefined,
|
||||
cityname: loc.cityname?.trim() || undefined,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
address: item.address || undefined,
|
||||
name: item.title || undefined,
|
||||
cityname: item.city || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ export default function StoreDetailPage() {
|
||||
phone: String(data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
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) : '',
|
||||
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
||||
});
|
||||
|
||||
@@ -558,16 +558,19 @@ export default function MinePage() {
|
||||
>
|
||||
<ScrollView
|
||||
scrollY
|
||||
enableFlex
|
||||
className="mine-qualification-scroll"
|
||||
style={{ height: '100%' }}
|
||||
enhanced
|
||||
showScrollbar
|
||||
>
|
||||
<Image
|
||||
className="mine-qualification-img"
|
||||
src={QUALIFICATION_DISCLOSURE_URL}
|
||||
mode="widthFix"
|
||||
/>
|
||||
<View className="mine-qualification-body">
|
||||
<Image
|
||||
className="mine-qualification-img"
|
||||
src={QUALIFICATION_DISCLOSURE_URL}
|
||||
mode="widthFix"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<Text className="mine-qualification-hint">点击任意处关闭</Text>
|
||||
</View>
|
||||
|
||||
@@ -174,7 +174,9 @@ export default function StoreDetailPage() {
|
||||
]);
|
||||
|
||||
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) {
|
||||
if (!envPhotos.length) return;
|
||||
|
||||
@@ -509,7 +509,7 @@
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -521,7 +521,17 @@
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
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 {
|
||||
@@ -539,6 +549,7 @@
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
|
||||
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', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
|
||||
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const districts = normalizeDistrictCodes(input.districtCodes);
|
||||
if (!districts.length) {
|
||||
return { ok: false, message: '区域合伙人须至少选择一个区县' };
|
||||
}
|
||||
|
||||
// 区县仅为标识,允许多个区域合伙人选择相同区县
|
||||
// 区域合伙人所选区县可为空(录入时可稍后补全)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||
{ key: 'resources', label: 'OSS 资源库', 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_accounts', label: 'HQ 账户', 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 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 */
|
||||
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
||||
feature: 'system_settings_feature',
|
||||
@@ -76,7 +94,7 @@ export const HQ_ADMIN_ROLES = [
|
||||
] as const;
|
||||
|
||||
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
SUPER_ADMIN: hqBasePermissionKeys(),
|
||||
OPS: [
|
||||
'dashboard',
|
||||
'users',
|
||||
|
||||
@@ -59,8 +59,9 @@ WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 腾讯位置服务(地理编码 / 逆地理 / 地图选点组件)
|
||||
# 地图选点需在控制台为 Key 配置域名白名单,并允许组件域名 apis.map.qq.com
|
||||
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
|
||||
# 控制台须开启 WebServiceAPI;服务端调用建议 Key 不设域名白名单,或改用 IP 白名单
|
||||
# (浏览器内嵌官方选点组件已弃用,避免 mapapi.qq.com / formatted_addresses 崩溃)
|
||||
TENCENT_LBS_KEY=
|
||||
|
||||
# 阿里云 OSS(ali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
expandHqPermissionKeys,
|
||||
hasAnySystemSettingsPermission,
|
||||
hqBasePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
@@ -35,30 +35,29 @@ export class HqPermissionsResolver {
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
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') {
|
||||
return HQ_PERMISSION_CATALOG.map((p) => p.key);
|
||||
// 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选
|
||||
return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]);
|
||||
}
|
||||
|
||||
const [roleRows, userRows] = await Promise.all([
|
||||
this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: account.adminRole },
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
]);
|
||||
const roleRows = await this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: account.adminRole },
|
||||
select: { permissionKey: true },
|
||||
});
|
||||
|
||||
const roleKeys =
|
||||
roleRows.length > 0
|
||||
? roleRows.map((r) => r.permissionKey)
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||
|
||||
return expandHqPermissionKeys([
|
||||
...roleKeys,
|
||||
...userRows.map((r) => r.permissionKey),
|
||||
]);
|
||||
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export const HqOperationAction = {
|
||||
STORE_AUDIT: 'STORE_AUDIT',
|
||||
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
||||
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
|
||||
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
|
||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
||||
@@ -127,6 +128,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
||||
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
||||
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
|
||||
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
|
||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||
[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: '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_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||
|
||||
@@ -50,6 +50,14 @@ export class LlmChatClient {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
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();
|
||||
|
||||
@@ -16,19 +16,51 @@ export type GeocodeAddressResult = {
|
||||
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) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
|
||||
type TencentPlaceRow = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
location?: { lat?: number; lng?: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TencentLbsProvider {
|
||||
private readonly logger = new Logger(TencentLbsProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
|
||||
private getLbsKey() {
|
||||
return (loadAppConfig().tencentLbsKey || '').trim();
|
||||
}
|
||||
|
||||
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/');
|
||||
url.searchParams.set('address', trimmed);
|
||||
url.searchParams.set('key', this.config.tencentLbsKey);
|
||||
url.searchParams.set('key', this.getLbsKey());
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
@@ -140,7 +172,7 @@ export class TencentLbsProvider {
|
||||
const location = `${latitude},${longitude}`;
|
||||
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
|
||||
url.searchParams.set('location', location);
|
||||
url.searchParams.set('key', this.config.tencentLbsKey);
|
||||
url.searchParams.set('key', this.getLbsKey());
|
||||
url.searchParams.set('get_poi', '0');
|
||||
|
||||
try {
|
||||
@@ -199,4 +231,202 @@ export class TencentLbsProvider {
|
||||
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,
|
||||
mockWechat: cfg.mockWechat,
|
||||
wxAuthorize: cfg.wxAuthorize,
|
||||
/** 浏览器地图选点用;建议在腾讯控制台按域名限制 Key */
|
||||
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
|
||||
tencentLbsKey: cfg.tencentLbsKey || undefined,
|
||||
miniHome: {
|
||||
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TicketController } from './ticket.controller';
|
||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||
import { WechatController } from './wechat.controller';
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { LbsController } from './lbs.controller';
|
||||
import { WechatLocationService } from './wechat-location.service';
|
||||
|
||||
@Module({
|
||||
@@ -25,6 +26,7 @@ import { WechatLocationService } from './wechat-location.service';
|
||||
ThirdPartyLogController,
|
||||
WechatController,
|
||||
ClientConfigController,
|
||||
LbsController,
|
||||
],
|
||||
providers: [
|
||||
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('未配置腾讯位置服务 Key(TENCENT_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('未配置腾讯位置服务 Key(TENCENT_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('未配置腾讯位置服务 Key(TENCENT_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 {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminCitiesService } from './admin-cities.service';
|
||||
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
|
||||
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
||||
|
||||
class DeleteCityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
confirmName!: string;
|
||||
}
|
||||
|
||||
@Controller('admin/cities')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminCitiesController {
|
||||
@@ -16,6 +27,13 @@ export class AdminCitiesController {
|
||||
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')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
@@ -37,4 +55,17 @@ export class AdminCitiesController {
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */
|
||||
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 { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
|
||||
@Controller('admin/dashboard')
|
||||
@@ -13,6 +14,7 @@ export class AdminDashboardController {
|
||||
}
|
||||
|
||||
@Get('version')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
version() {
|
||||
return this.dashboardService.getLatestVersion();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
expandHqPermissionKeys,
|
||||
hqBasePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -49,7 +50,7 @@ export class AdminHqPermissionsService {
|
||||
|
||||
async saveRolePermissions(role: string, permissionKeys: string[]) {
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||
throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
|
||||
}
|
||||
assertPermissionKeys(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 },
|
||||
});
|
||||
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') {
|
||||
const rolePermissionKeys = hqBasePermissionKeys();
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
return serializeBigInt({
|
||||
account,
|
||||
permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
userPermissionKeys: [],
|
||||
effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
permissionKeys: userPermissionKeys,
|
||||
rolePermissionKeys,
|
||||
userPermissionKeys,
|
||||
effectivePermissionKeys,
|
||||
});
|
||||
}
|
||||
|
||||
const [rolePerms, userPerms] = await Promise.all([
|
||||
this.getRolePermissions(account.adminRole),
|
||||
this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: accountId },
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
]);
|
||||
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||
const rolePerms = await this.getRolePermissions(account.adminRole);
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
@@ -107,9 +112,6 @@ export class AdminHqPermissionsService {
|
||||
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
|
||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||
}
|
||||
assertPermissionKeys(permissionKeys);
|
||||
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||
await this.prisma.$transaction([
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
@@ -18,7 +21,8 @@ export class AdminOrdersController {
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_BATCH_DELETE,
|
||||
refType: 'ORDER',
|
||||
|
||||
@@ -191,8 +191,8 @@ export class AdminPartnersService {
|
||||
orderCommissionRate: orderCommissionRate,
|
||||
redeemCommissionRate: redeemCommissionRate,
|
||||
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
|
||||
companyName: dto.companyName.trim(),
|
||||
address: dto.address.trim(),
|
||||
companyName: dto.companyName?.trim() || null,
|
||||
address: dto.address?.trim() || null,
|
||||
contactPhone: dto.contactPhone?.trim() ?? phone,
|
||||
contractNo: dto.contractNo,
|
||||
bankAccountName: dto.bankAccountName,
|
||||
|
||||
@@ -94,6 +94,16 @@ export class AdminStoreAccountsController {
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
|
||||
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')
|
||||
|
||||
@@ -17,6 +17,14 @@ import type {
|
||||
UpdateStoreStatusDto,
|
||||
} 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()
|
||||
export class AdminStoresService {
|
||||
constructor(
|
||||
@@ -106,6 +114,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(mapStoreCompat({
|
||||
...store,
|
||||
account: store.bindings[0]?.storeAccount ?? null,
|
||||
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
|
||||
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
||||
bindings: undefined,
|
||||
media,
|
||||
audits,
|
||||
@@ -208,49 +218,152 @@ export class AdminStoresService {
|
||||
}
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.benefitUsageRule !== undefined
|
||||
? { benefitUsageRule: String(dto.benefitUsageRule).trim() || null }
|
||||
: {}),
|
||||
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||||
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||||
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||||
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||||
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
},
|
||||
});
|
||||
let categoryId: bigint | undefined;
|
||||
if (dto.categoryId !== undefined) {
|
||||
if (!dto.categoryId?.trim()) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
categoryId = BigInt(dto.categoryId);
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
}
|
||||
|
||||
if (dto.coverUrl) {
|
||||
if (current.coverResourceId) {
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id: current.coverResourceId },
|
||||
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
||||
});
|
||||
} else {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.coverUrl,
|
||||
url: dto.coverUrl,
|
||||
},
|
||||
});
|
||||
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||||
if (dto.phone !== undefined) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
||||
const bankTouched =
|
||||
dto.bankAccountName !== undefined ||
|
||||
dto.bankAccountNo !== undefined ||
|
||||
dto.bankBranch !== undefined;
|
||||
const needAccountSync =
|
||||
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
||||
|
||||
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
|
||||
let primaryBinding: {
|
||||
storeAccount: { id: bigint; phone: string; name: string } | null;
|
||||
} | null = null;
|
||||
if (needAccountSync) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id, storeAccount: { isPrimary: 1 } },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!primaryBinding) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
orderBy: { storeAccountId: 'asc' },
|
||||
});
|
||||
}
|
||||
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
|
||||
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
|
||||
}
|
||||
const primaryAccount = primaryBinding?.storeAccount;
|
||||
if (
|
||||
normalizedPhone !== undefined &&
|
||||
primaryAccount &&
|
||||
normalizedPhone !== primaryAccount.phone
|
||||
) {
|
||||
const occupied = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (occupied && occupied.id !== primaryAccount.id) {
|
||||
throw new BadRequestException('该手机号已被其他门店账号使用');
|
||||
}
|
||||
}
|
||||
if (dto.bankAccountNo !== undefined) {
|
||||
const no = dto.bankAccountNo?.trim() || null;
|
||||
if (no && !/^\d{16,19}$/.test(no)) {
|
||||
throw new BadRequestException('银行卡号须为 16–19 位数字');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.store.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.benefitUsageRule !== undefined
|
||||
? { benefitUsageRule: 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);
|
||||
}
|
||||
|
||||
@@ -312,7 +425,7 @@ export class AdminStoresService {
|
||||
if (intro && (intro.length < 2 || intro.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
const benefitUsageRule = dto.benefitUsageRule?.trim() || null;
|
||||
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
|
||||
if (benefitUsageRule && benefitUsageRule.length > 1000) {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
@@ -612,4 +725,26 @@ export class AdminStoresService {
|
||||
});
|
||||
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 { 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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
@@ -18,13 +21,15 @@ export class AdminUsersController {
|
||||
}
|
||||
|
||||
@Post('batch-delete/preview')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
|
||||
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_BATCH_DELETE,
|
||||
refType: 'USER',
|
||||
@@ -44,7 +49,8 @@ export class AdminUsersController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_DELETE,
|
||||
refType: 'USER',
|
||||
|
||||
@@ -162,10 +162,22 @@ export class UpdateStoreDto {
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -191,6 +203,18 @@ export class UpdateStoreDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
avgPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankAccountName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankAccountNo?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankBranch?: string | null;
|
||||
}
|
||||
|
||||
export class CreateStoreAccountDto {
|
||||
@@ -234,13 +258,13 @@ export class CreatePartnerDto {
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyName: string;
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
address: string;
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@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)));
|
||||
}
|
||||
|
||||
/** 选填文案:空 / 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 {
|
||||
if (value == null || value === '') return null;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
@@ -301,9 +309,8 @@ export class StoreService {
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
const benefitUsageRuleRaw =
|
||||
body.benefitUsageRule != null ? String(body.benefitUsageRule).trim() : '';
|
||||
if (benefitUsageRuleRaw.length > 1000) {
|
||||
const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
|
||||
if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
|
||||
@@ -325,7 +332,7 @@ export class StoreService {
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: introRaw || null,
|
||||
benefitUsageRule: benefitUsageRuleRaw || null,
|
||||
benefitUsageRule: benefitUsageRuleRaw,
|
||||
avgPrice: avgPriceRaw,
|
||||
openTime,
|
||||
closeTime,
|
||||
@@ -522,7 +529,9 @@ export class StoreService {
|
||||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||||
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
||||
const benefitUsageRuleRaw =
|
||||
body.benefitUsageRule !== undefined ? String(body.benefitUsageRule).trim() : undefined;
|
||||
body.benefitUsageRule !== undefined
|
||||
? normalizeOptionalTextField(body.benefitUsageRule)
|
||||
: undefined;
|
||||
const latitude =
|
||||
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
|
||||
const longitude =
|
||||
@@ -536,7 +545,7 @@ export class StoreService {
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
if (benefitUsageRuleRaw !== undefined && benefitUsageRuleRaw.length > 1000) {
|
||||
if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
if (
|
||||
@@ -561,7 +570,7 @@ export class StoreService {
|
||||
...(hasCoordsUpdate ? { latitude, longitude } : {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
...(benefitUsageRuleRaw !== undefined
|
||||
? { benefitUsageRule: benefitUsageRuleRaw || null }
|
||||
? { benefitUsageRule: benefitUsageRuleRaw }
|
||||
: {}),
|
||||
...(resubmitAudit
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user