diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 9eebfc8..9122d68 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -5,6 +5,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'PARTNER_UPDATE', label: '编辑城市合伙人' }, { value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' }, { value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' }, + { value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, diff --git a/apps/admin-web/src/pages/PartnerAccountsPage.tsx b/apps/admin-web/src/pages/PartnerAccountsPage.tsx index 8fb3d31..3177781 100644 --- a/apps/admin-web/src/pages/PartnerAccountsPage.tsx +++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx @@ -1,15 +1,27 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { - Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message, + Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; -type Row = { - id: string; phone: string; name: string; status: string; isPrimary: number; createdAt: string; +type PartnerOption = { id: string; companyName: string }; + +type AccountTreeRow = { + id: string; + phone: string; + name: string; + status: string; + isPrimary: number; + staffRole: string | null; + parentAccountId: string | null; + createdAt: string; + lastLoginAt: string | null; partner?: { id: string; companyName: string }; + children?: AccountTreeRow[]; }; type BillRow = { @@ -22,40 +34,94 @@ type OrderRow = { user?: { userNo: string; phone: string | null }; }; -type PartnerOption = { id: string; companyName: string }; +type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] }; -type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] }; +const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({ + value, + label, +})); + +function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] { + const phoneQ = phone?.trim(); + const match = (row: AccountTreeRow) => { + const phoneOk = !phoneQ || row.phone.includes(phoneQ); + const statusOk = !status || row.status === status; + return phoneOk && statusOk; + }; + + const walk = (list: AccountTreeRow[]): AccountTreeRow[] => + list + .map((row) => { + const children = row.children?.length ? walk(row.children) : undefined; + if (match(row) || (children && children.length > 0)) { + return { ...row, children: children?.length ? children : undefined }; + } + return null; + }) + .filter((row): row is AccountTreeRow => row !== null); + + return walk(rows); +} export default function PartnerAccountsPage() { const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/partner-accounts', - () => { - const qs = new URLSearchParams(); - if (filters.phone) qs.set('phone', filters.phone); - if (filters.status) qs.set('status', filters.status); - return qs; - }, - [filters], - ); + const [subForm] = Form.useForm(); + const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({}); + const [treeData, setTreeData] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [subOpen, setSubOpen] = useState(false); + const [subParent, setSubParent] = useState(null); const [partners, setPartners] = useState([]); + const loadTree = useCallback(async () => { + setLoading(true); + try { + const qs = new URLSearchParams(); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree'; + const res = await request(path); + setTreeData(filterTree(res, filters.phone, filters.status)); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + void loadPartners(); + }, []); + + useEffect(() => { + void loadTree(); + }, [loadTree]); + + useEffect(() => { + if (!drawerOpen || !detail) return; + editForm.setFieldsValue({ + name: detail.name, + phone: detail.phone, + status: detail.status, + }); + }, [drawerOpen, detail, editForm]); + async function loadPartners() { const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setPartners(res.items); } async function openAccount(id: string) { - const d = await request(`/admin/partner-accounts/${id}`); - setDetail(d); - editForm.setFieldsValue({ name: d.name, phone: d.phone, status: d.status }); - setDrawerOpen(true); + try { + const d = await request(`/admin/partner-accounts/${id}`); + setDetail(d); + setDrawerOpen(true); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载账户详情失败'); + } } async function saveAccount() { @@ -64,22 +130,86 @@ export default function PartnerAccountsPage() { await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) }); message.success('已保存'); setDrawerOpen(false); - void reload(); + void loadTree(); } - const columns: ColumnsType = [ - { title: '姓名', dataIndex: 'name', width: 100 }, + async function deleteSubAccount(row: AccountTreeRow) { + await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' }); + message.success('子账号已删除'); + void loadTree(); + } + + function openAddSub(parent: AccountTreeRow) { + setSubParent(parent); + subForm.resetFields(); + subForm.setFieldsValue({ staffRole: 'INTERNAL' }); + setSubOpen(true); + } + + function collectExpandableKeys(rows: AccountTreeRow[]): string[] { + const keys: string[] = []; + for (const row of rows) { + if (row.children?.length) { + keys.push(row.id); + keys.push(...collectExpandableKeys(row.children)); + } + } + return keys; + } + + const columns: ColumnsType = [ + { + title: '姓名 / 类型', + width: 200, + ellipsis: true, + render: (_, row) => ( + + ), + }, { title: '手机', dataIndex: 'phone', width: 120 }, - { title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 }, - { title: '主账号', dataIndex: 'isPrimary', width: 80, render: (v) => (v === 1 ? '是' : '否') }, + { + title: '开城合伙人', + width: 140, + ellipsis: true, + render: (_, row) => row.partner?.companyName || '—', + }, + { + title: '标识', + width: 100, + render: (_, row) => + row.parentAccountId ? ( + 子账号 + ) : row.isPrimary === 1 ? ( + 主账号 + ) : ( + 账号 + ), + }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 120, + title: '操作', + width: 180, render: (_, row) => ( - + e.stopPropagation()}> - + {!row.parentAccountId && row.isPrimary === 1 ? ( + + ) : null} + {row.parentAccountId ? ( + void deleteSubAccount(row)}> + + + ) : null} ), }, @@ -105,23 +235,70 @@ export default function PartnerAccountsPage() { return (
- 开城合伙人账户 +
+ 开城合伙人账户 + 主账号下可展开/收起子账号,支持添加与删除子账号 +
-
{ setFilters(v); setPage(1); }}> + { + setFilters({ + phone: v.phone?.trim() || undefined, + status: v.status || undefined, + partnerId: v.partnerId || undefined, + }); + }} + > + + + {detail.parentAccountId ? ( + + + + ) : null} @@ -198,6 +392,46 @@ export default function PartnerAccountsPage() { + setSubOpen(false)} + onOk={async () => { + if (!subParent) return; + const v = await subForm.validateFields(); + await request('/admin/partner-accounts', { + method: 'POST', + body: JSON.stringify({ + partnerId: subParent.partner?.id, + parentAccountId: subParent.id, + name: v.name, + phone: v.phone, + staffRole: v.staffRole, + }), + }); + message.success('子账号已创建'); + setSubOpen(false); + setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]); + void loadTree(); + }} + > +
+ + + + + +