Compare commits

..

10 Commits

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:43:09 +08:00
jacy 0cb2b2cebb feat(admin): store detail edit with login phone sync; tighten HQ UI
CI / verify (pull_request) Has been cancelled
HQ store update now syncs store_account.phone for shop login; expand editable store fields. System version only for SUPER_ADMIN; permissions checklist layout polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:10:14 +08:00
jacy daeb24bbeb feat(admin): gate user/order/city delete behind assignable HQ permissions
CI / verify (pull_request) Has been cancelled
Add users_delete, orders_delete, cities_delete; default HQ (incl. super admin) has none until explicitly granted.

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:47:08 +08:00
jacy 50349dd8e9 fix(mini-user): center qualification image on translucent mask
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:41:13 +08:00
42 changed files with 2333 additions and 695 deletions
@@ -281,11 +281,11 @@ export default function CityPartnersPanel({
label: '主账号',
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>
);
}
+1
View File
@@ -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: '删除门店分类' },
+13 -47
View File
@@ -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,
};
}
+270 -6
View File
@@ -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>
);
}
+24 -12
View File
@@ -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>
+22 -12
View File
@@ -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}>
+102 -42
View File
@@ -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 -10
View File
@@ -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,
+5 -6
View File
@@ -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>
+45 -2
View File
@@ -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>
+449 -228
View File
@@ -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('银行卡号须为 1619 位数字');
}
},
},
]}
>
<Input placeholder="1619 位数字" maxLength={19} />
</Form.Item>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
</>
),
},
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
},
]}
/>
</Form>
)}
</Drawer>
<Modal
+20 -12
View File
@@ -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>
);
}
+13 -46
View File
@@ -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) : '',
});
+8 -5
View File
@@ -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;
+14 -3
View File
@@ -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;
}
+8
View File
@@ -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'] }],
+1 -6
View File
@@ -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 -1
View File
@@ -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',
+3 -2
View File
@@ -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=
# 阿里云 OSSali-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('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const q = (keyword ?? '').trim();
if (!q) return { items: [] };
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
const result = await this.tencentLbs.suggestPlaces(q, {
region: region?.trim() || undefined,
latitude,
longitude,
});
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('nearby')
async nearby(@Query('lat') lat?: string, @Query('lng') lng?: string, @Query('radius') radius?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const r = radius != null && radius !== '' ? Number(radius) : 1000;
const result = await this.tencentLbs.exploreNearby(latitude, longitude, Number.isFinite(r) ? r : 1000);
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('reverse')
async reverse(@Query('lat') lat?: string, @Query('lng') lng?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const result = await this.tencentLbs.reverseGeocodeDetail(latitude, longitude);
if (!result.item) {
throw new BadRequestException(result.error || '逆地理编码失败');
}
return {
latitude: result.item.latitude,
longitude: result.item.longitude,
address: result.item.address,
name: result.item.name,
province: result.item.province,
city: result.item.city,
district: result.item.district,
};
}
}
@@ -1,11 +1,22 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsString, MinLength } from 'class-validator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
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('银行卡号须为 1619 位数字');
}
}
}
await this.prisma.$transaction(async (tx) => {
await tx.store.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
: {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
...(categoryId !== undefined ? { categoryId } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} = {};
if (dto.name !== undefined) accountData.name = dto.name.trim();
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
accountData.phone = normalizedPhone;
}
if (dto.bankAccountName !== undefined) {
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
}
if (dto.bankAccountNo !== undefined) {
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
}
if (dto.bankBranch !== undefined) {
accountData.bankBranch = dto.bankBranch?.trim() || null;
}
if (Object.keys(accountData).length) {
await tx.storeAccount.update({
where: { id: account.id },
data: accountData,
});
}
}
});
return this.detailStore(id);
}
@@ -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
? {