feat(admin): delete store staff; city store count links to filtered list
CI / verify (pull_request) Has been cancelled
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>
This commit is contained in:
@@ -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: '删除门店分类' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -228,7 +229,16 @@ export default function CitiesPage() {
|
||||
{ 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 },
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
async function refreshDetail(accountId: string) {
|
||||
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
|
||||
setDetail(d);
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function deleteStaff(staffId: string) {
|
||||
if (!detail) return;
|
||||
setDeletingStaffId(staffId);
|
||||
try {
|
||||
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
await refreshDetail(detail.id);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeletingStaffId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
|
||||
dataIndex: 'status',
|
||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, staff) => (
|
||||
<Popconfirm
|
||||
title="确认删除该子账号?"
|
||||
description={`${staff.name}(${staff.phone})删除后将无法登录门店端`}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
|
||||
cancelText="取消"
|
||||
onConfirm={() => void deleteStaff(staff.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
) : (
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
|
||||
暂无子账号
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -322,10 +322,14 @@ type CategoryNode = {
|
||||
|
||||
export default function StoresPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCityId = searchParams.get('cityId') ?? '';
|
||||
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>>(() =>
|
||||
initialCityId ? { cityId: initialCityId } : {},
|
||||
);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||
'/admin/stores',
|
||||
() => {
|
||||
@@ -334,10 +338,12 @@ 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);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
@@ -356,6 +362,25 @@ export default function StoresPage() {
|
||||
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(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cityId = searchParams.get('cityId') ?? '';
|
||||
setFilters((prev) => {
|
||||
if ((prev.cityId ?? '') === cityId) return prev;
|
||||
const next = { ...prev };
|
||||
if (cityId) next.cityId = cityId;
|
||||
else delete next.cityId;
|
||||
return next;
|
||||
});
|
||||
form.setFieldsValue({ cityId: cityId || undefined });
|
||||
if (cityId) setPage(1);
|
||||
}, [searchParams, form, setPage]);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
@@ -726,6 +751,16 @@ 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="status" label="营业状态">
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
@@ -738,7 +773,18 @@ 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')) 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); } }} />
|
||||
|
||||
@@ -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]: '删除门店资源',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -717,4 +717,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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user