fix(supply-chain): 固化人员信息界面配置

This commit is contained in:
居居 2026-09-22 10:33:18 +08:00
parent f94286464b
commit 7b5f66dfcf
8 changed files with 129 additions and 60 deletions

View File

@ -13,7 +13,7 @@ jest.mock('./supply-chain-tabs', () => ({
useSupplyChainTabs: () => ({
tabs: [
{ path: '/supply-chain/dashboard', title: '工作台', closable: false },
{ path: '/supply-chain/management/owner-config', title: '负责人配置', closable: true },
{ path: '/supply-chain/management/owner-config', title: '人员信息', closable: true },
{ path: '/supply-chain/management/custom-table', title: '限价管理', closable: true },
{ path: '/supply-chain/management/blank-pages/blank-page-1', title: '空白页 1', closable: true },
],
@ -47,7 +47,7 @@ describe('SupplyChainTabBar owner config labels', () => {
it('keeps the page title read-only when editing is disabled', () => {
render(<SupplyChainTabBar />)
expect(screen.getByText('负责人配置')).toBeInTheDocument()
expect(screen.getByText('人员信息')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '修改页签名称' })).not.toBeInTheDocument()
})

View File

@ -111,7 +111,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/script-monitor': '脚本监控',
'/supply-chain/operation-logs': '操作日志',
'/supply-chain/user-permissions': '用户权限',
'/supply-chain/management/owner-config': '负责人配置',
'/supply-chain/management/owner-config': '人员信息',
'/supply-chain/management/custom-table': '限价管理',
...Object.fromEntries(MANAGEMENT_BLANK_PAGES.map((page) => [page.route, page.label])),
}

View File

@ -108,7 +108,7 @@ describe('supply chain menu', () => {
expect(flattenSupplyChainMenu()).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: '负责人配置',
label: '人员信息',
module: '经营管理',
route: '/supply-chain/management/owner-config',
}),

View File

@ -88,7 +88,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{
items: [
{
label: '负责人配置',
label: '人员信息',
route: '/supply-chain/management/owner-config',
targetKey: 'businessManagement',
},

View File

@ -45,27 +45,33 @@ beforeEach(() => {
})
afterAll(() => { global.fetch = originalFetch })
describe('负责人配置工作台', () => {
describe('人员信息工作台', () => {
it('只展示三级负责人字段,只读用户不显示编辑控件', async () => {
render(<OwnerConfigView canEdit={false} />)
await screen.findByText('运营甲')
for (const name of ['运营负责人', '部门', '二级部门', '利润中心', '部门负责人', '总负责人', '在职状态']) {
expect(screen.getByRole('columnheader', { name })).toBeInTheDocument()
}
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'人员',
'部门负责人',
'总负责人',
'部门',
'二级部门',
'利润中心',
'在职状态',
])
for (const name of ['SPU', '货号', '商品编码']) {
expect(screen.queryByRole('columnheader', { name })).not.toBeInTheDocument()
}
expect(screen.queryByRole('button', { name: '新增配置' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '编辑负责人配置' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '编辑人员信息' })).not.toBeInTheDocument()
expect(screen.getByText('在职')).toHaveClass('employed')
})
it('选择通讯录员工后自动带出部门和总负责人', async () => {
render(<OwnerConfigView canEdit />)
render(<OwnerConfigView canEdit showManagementActions />)
await screen.findByText('运营甲')
fireEvent.click(screen.getByRole('button', { name: '新增配置' }))
await screen.findByRole('dialog')
fireEvent.change(screen.getByRole('combobox', { name: '运营负责人' }), { target: { value: 'employee-2' } })
fireEvent.change(screen.getByRole('combobox', { name: '人员' }), { target: { value: 'employee-2' } })
expect(screen.getByRole('textbox', { name: '部门' })).toHaveValue('运营二部')
expect(screen.getByRole('textbox', { name: '二级部门' })).toHaveValue('运营二组')
expect(screen.getByRole('textbox', { name: '利润中心' })).toHaveValue('运营中心B')
@ -75,12 +81,12 @@ describe('负责人配置工作台', () => {
})
it('允许开发环境人工修改并保存列标题', async () => {
render(<OwnerConfigView canEdit allowLabelEditing />)
render(<OwnerConfigView canEdit allowLabelEditing showManagementActions />)
await screen.findByText('运营甲')
expect(screen.getByRole('button', { name: '修改操作列标题' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '修改运营负责人列标题' }))
const input = screen.getByRole('textbox', { name: '修改运营负责人列标题' })
fireEvent.click(screen.getByRole('button', { name: '修改人员列标题' }))
const input = screen.getByRole('textbox', { name: '修改人员列标题' })
fireEvent.change(input, { target: { value: '成员' } })
fireEvent.blur(input)
@ -106,11 +112,39 @@ describe('负责人配置工作台', () => {
render(<OwnerConfigView canEdit={false} />)
await screen.findByText('运营甲')
const headers = screen.getAllByRole('columnheader').map((header) => header.textContent)
expect(screen.getByRole('columnheader', { name: '人员' })).toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: '运营负责人' })).not.toBeInTheDocument()
expect(headers.indexOf('二级部门')).toBe(headers.indexOf('部门') + 1)
})
it('把历史人工表头配置补全为当前源码中的完整顺序', async () => {
window.localStorage.setItem(OWNER_CONFIG_SETTINGS_STORAGE_KEY, JSON.stringify({
pageTitle: '负责人配置',
actionsLabel: '操作',
fields: [
{ id: 'operationOwner', label: '运营负责人', builtinKey: 'operationOwner', visible: true },
{ id: 'departmentOwner', label: '部门负责人', builtinKey: 'departmentOwner', visible: true },
{ id: 'generalOwner', label: '总负责人', builtinKey: 'generalOwner', visible: true },
],
}))
render(<OwnerConfigView canEdit={false} />)
await screen.findByText('运营甲')
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'人员',
'部门负责人',
'总负责人',
'部门',
'二级部门',
'利润中心',
'在职状态',
])
expect(screen.getByRole('navigation', { name: '人员信息分页' })).toBeInTheDocument()
})
it('支持新增自定义字段、填写值并删除字段', async () => {
render(<OwnerConfigView canEdit allowLabelEditing />)
render(<OwnerConfigView canEdit allowLabelEditing showManagementActions />)
await screen.findByText('运营甲')
fireEvent.click(screen.getByRole('button', { name: '字段配置' }))
@ -120,7 +154,7 @@ describe('负责人配置工作台', () => {
fireEvent.click(screen.getByRole('button', { name: '完成' }))
expect(screen.getByRole('columnheader', { name: '备注' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '编辑负责人配置' }))
fireEvent.click(screen.getByRole('button', { name: '编辑人员信息' }))
fireEvent.change(screen.getByRole('textbox', { name: '备注' }), { target: { value: '重点成员' } })
mockFetch.mockResolvedValueOnce(reply(row))
.mockResolvedValueOnce(reply({ items: [row], pageInfo }))
@ -145,9 +179,9 @@ describe('负责人配置工作台', () => {
it('保存编辑后重新读取负责人数据', async () => {
render(<OwnerConfigView canEdit />)
await screen.findByText('运营甲')
fireEvent.click(screen.getByRole('button', { name: '编辑负责人配置' }))
fireEvent.click(screen.getByRole('button', { name: '编辑人员信息' }))
await screen.findByRole('dialog')
fireEvent.change(screen.getByRole('combobox', { name: '运营负责人' }), { target: { value: 'employee-2' } })
fireEvent.change(screen.getByRole('combobox', { name: '人员' }), { target: { value: 'employee-2' } })
fireEvent.change(screen.getByRole('textbox', { name: '部门负责人' }), { target: { value: '人工部门负责人' } })
fireEvent.change(screen.getByRole('textbox', { name: '总负责人' }), { target: { value: '人工总负责人' } })
mockFetch.mockResolvedValueOnce(reply({ ...row, operationOwner: '运营丁' }))
@ -201,6 +235,30 @@ describe('负责人配置工作台', () => {
expect(screen.getByRole('button', { name: '导出' })).toBeInTheDocument()
})
it('在底部分页区域显示总条数,单页时禁用翻页按钮', async () => {
render(<OwnerConfigView canEdit={false} />)
await screen.findByText('运营甲')
const pagination = screen.getByRole('navigation', { name: '人员信息分页' })
expect(pagination).toHaveTextContent('共 1 条')
expect(pagination).toHaveTextContent('1 / 1')
expect(screen.getByRole('button', { name: '上一页' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled()
expect(document.querySelector('.owner-config-tools')).not.toHaveTextContent('共 1 条')
})
it('默认隐藏字段配置、批量删除、新增配置和批量勾选列', async () => {
render(<OwnerConfigView canEdit allowLabelEditing />)
await screen.findByText('运营甲')
expect(screen.queryByRole('button', { name: '字段配置' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '批量删除' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新增配置' })).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: '全选当前页' })).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: '选择运营甲' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '编辑人员信息' })).toBeInTheDocument()
})
it('支持勾选当前页记录并批量删除', async () => {
jest.spyOn(window, 'confirm').mockReturnValue(true)
mockFetch.mockImplementation(async (input: RequestInfo | URL, options?: RequestInit) => {
@ -210,7 +268,7 @@ describe('负责人配置工作台', () => {
return reply({ items: [row], pageInfo })
})
render(<OwnerConfigView canEdit />)
render(<OwnerConfigView canEdit showManagementActions />)
await screen.findByText('运营甲')
const bulkDeleteButton = screen.getByRole('button', { name: '批量删除' })
@ -224,7 +282,7 @@ describe('负责人配置工作台', () => {
&& options?.method === 'POST'
&& JSON.parse(options.body as string).ids[0] === 'config-1'
))).toBe(true))
expect(window.confirm).toHaveBeenCalledWith('确认删除已选择的 1 条负责人配置?')
expect(window.confirm).toHaveBeenCalledWith('确认删除已选择的 1 条人员信息?')
})
it('分别筛选五个字段并按当前筛选条件导出全部匹配数据', async () => {
@ -243,7 +301,7 @@ describe('负责人配置工作台', () => {
render(<OwnerConfigView canEdit={false} canExport />)
await screen.findByText('运营甲')
for (const [name, value] of [
['筛选运营负责人', '运营甲'],
['筛选人员', '运营甲'],
['筛选部门负责人', '部门乙'],
['筛选总负责人', '负责人丙'],
['筛选利润中心', '运营中心A'],
@ -288,7 +346,7 @@ describe('负责人配置工作台', () => {
render(<OwnerConfigView canEdit={false} />)
await screen.findByText('运营甲')
const operationOwnerInput = screen.getByRole('combobox', { name: '筛选运营负责人' })
const operationOwnerInput = screen.getByRole('combobox', { name: '筛选人员' })
const profitCenterInput = screen.getByRole('combobox', { name: '筛选利润中心' })
const departmentInput = screen.getByRole('combobox', { name: '筛选部门' })
const statusInput = screen.getByRole('combobox', { name: '筛选在职状态' })

View File

@ -180,11 +180,13 @@ export function OwnerConfigView({
canExport = false,
allowLabelEditing = false,
autoSyncDirectory = false,
showManagementActions = false,
}: {
canEdit: boolean
canExport?: boolean
allowLabelEditing?: boolean
autoSyncDirectory?: boolean
showManagementActions?: boolean
}) {
const {
labels,
@ -378,7 +380,7 @@ export function OwnerConfigView({
}
const deleteItem = async (item: OwnerConfig) => {
if (!canEdit || deletingId || !window.confirm('确认删除该负责人配置?')) return
if (!canEdit || deletingId || !window.confirm('确认删除该人员信息?')) return
setDeletingId(item.id)
try {
await readResponse(await fetch(
@ -397,7 +399,7 @@ export function OwnerConfigView({
const bulkDeleteItems = async () => {
const ids = [...selectedIds]
if (!canEdit || bulkDeleting || ids.length === 0) return
if (!window.confirm(`确认删除已选择的 ${ids.length} 条负责人配置?`)) return
if (!window.confirm(`确认删除已选择的 ${ids.length} 条人员信息?`)) return
setBulkDeleting(true)
setError(null)
try {
@ -536,8 +538,9 @@ export function OwnerConfigView({
setExporting(false)
}
}
const canBulkSelect = canEdit && showManagementActions
const allCurrentPageSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id))
const tableColumnCount = Math.max(1, visibleFields.length + (canEdit ? 2 : 0))
const tableColumnCount = Math.max(1, visibleFields.length + (canEdit ? 1 : 0) + (canBulkSelect ? 1 : 0))
return (
<div className="owner-config-page">
@ -568,7 +571,6 @@ export function OwnerConfigView({
</div>
</form>
<div className="owner-config-tools">
<span>共 {pageInfo.total} 条</span>
<button type="button" className="icon-button" title="刷新" aria-label="刷新" onClick={() => void load()}>
<RefreshCw size={15} />
</button>
@ -577,17 +579,17 @@ export function OwnerConfigView({
<FileDown size={15} />{exporting ? '导出中...' : '导出'}
</button>
) : null}
{allowLabelEditing ? (
{allowLabelEditing && showManagementActions ? (
<button type="button" className="secondary-button" onClick={() => setFieldSettingsOpen(true)}>
<Columns3 size={15} />字段配置
</button>
) : null}
{canEdit ? (
{canBulkSelect ? (
<button type="button" className="danger-button" disabled={selectedIds.size === 0 || bulkDeleting} onClick={() => void bulkDeleteItems()}>
<Trash2 size={15} />{bulkDeleting ? '删除中...' : `批量删除${selectedIds.size > 0 ? `(${selectedIds.size})` : ''}`}
</button>
) : null}
{canEdit ? (
{canEdit && showManagementActions ? (
<button type="button" className="primary-button" onClick={() => openDraft({ ...EMPTY_DRAFT })}>
<Plus size={15} />新增配置
</button>
@ -602,16 +604,16 @@ export function OwnerConfigView({
<table
className="owner-config-table"
aria-label={labels.pageTitle}
style={{ minWidth: Math.max(360, visibleFields.length * 150 + (canEdit ? 92 : 0)) }}
style={{ minWidth: Math.max(360, visibleFields.length * 150 + (canEdit ? 92 : 0) + (canBulkSelect ? 34 : 0)) }}
>
<colgroup>
{canEdit ? <col className="selection-column" /> : null}
{canBulkSelect ? <col className="selection-column" /> : null}
{visibleFields.map((field) => <col key={field.id} className="owner-column" />)}
{canEdit ? <col className="action-column" /> : null}
</colgroup>
<thead>
<tr>
{canEdit ? (
{canBulkSelect ? (
<th className="selection-cell">
<input
className="owner-config-selection-checkbox"
@ -643,12 +645,12 @@ export function OwnerConfigView({
) : null}
{!loading && !error ? items.map((item) => (
<tr key={item.id}>
{canEdit ? (
{canBulkSelect ? (
<td className="selection-cell">
<input
className="owner-config-selection-checkbox"
type="checkbox"
aria-label={`选择${item.operationOwner || '负责人配置'}`}
aria-label={`选择${item.operationOwner || '人员信息'}`}
checked={selectedIds.has(item.id)}
disabled={bulkDeleting}
onChange={(event) => setSelectedIds((current) => {
@ -690,17 +692,16 @@ export function OwnerConfigView({
</table>
</div>
{pageInfo.totalPages > 1 ? (
<nav className="owner-config-pagination" aria-label={`${labels.pageTitle}分页`}>
<button type="button" title="上一页" aria-label="上一页" disabled={loading || page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
<ChevronLeft size={15} />
</button>
<span>{pageInfo.page} / {pageInfo.totalPages}</span>
<button type="button" title="下一页" aria-label="下一页" disabled={loading || page >= pageInfo.totalPages} onClick={() => setPage((value) => Math.min(pageInfo.totalPages, value + 1))}>
<ChevronRight size={15} />
</button>
</nav>
) : null}
<nav className="owner-config-pagination" aria-label={`${labels.pageTitle}分页`}>
<span className="owner-config-total">共 {pageInfo.total} 条</span>
<button type="button" title="上一页" aria-label="上一页" disabled={loading || page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
<ChevronLeft size={15} />
</button>
<span>{pageInfo.page} / {pageInfo.totalPages}</span>
<button type="button" title="下一页" aria-label="下一页" disabled={loading || page >= pageInfo.totalPages} onClick={() => setPage((value) => Math.min(pageInfo.totalPages, value + 1))}>
<ChevronRight size={15} />
</button>
</nav>
{draft ? (
<Modal isOpen onClose={closeDraft} title={`${draft.id ? '编辑' : '新增'}${labels.pageTitle}`} size="medium" closeOnOverlayClick={!saving}>
@ -822,6 +823,7 @@ export function OwnerConfigView({
.owner-config-pagination button{display:inline-flex; width:28px; height:28px; align-items:center; justify-content:center; border:0; border-radius:4px; background:transparent; color:var(--sc-ink); cursor:pointer}
.owner-config-pagination button:hover:not(:disabled){background:var(--sc-surface-muted)}
.owner-config-pagination button:disabled{opacity:.35; cursor:not-allowed}
.owner-config-total{margin-right:4px; padding-right:12px; border-right:1px solid var(--sc-border); white-space:nowrap}
.owner-config-fields{display:grid; grid-template-columns:1fr 1fr; gap:14px}
.owner-config-fields label{display:flex; min-width:0; flex-direction:column; gap:6px; color:var(--sc-muted); font-size:12px}
.owner-config-fields input,.owner-config-fields select{box-sizing:border-box; width:100%; height:36px; border:1px solid var(--sc-border); border-radius:5px; background:var(--sc-surface); padding:0 10px; font-size:13px; outline:0}

View File

@ -40,30 +40,30 @@ type OwnerConfigViewSettings = {
}
const DEFAULT_FIELDS: OwnerConfigField[] = [
{ id: 'operationOwner', label: '运营负责人', builtinKey: 'operationOwner', visible: true },
{ id: 'operationOwner', label: '人员', builtinKey: 'operationOwner', visible: true },
{ id: 'departmentOwner', label: '部门负责人', builtinKey: 'departmentOwner', visible: true },
{ id: 'generalOwner', label: '总负责人', builtinKey: 'generalOwner', visible: true },
{ id: 'department', label: '部门', builtinKey: 'department', visible: true },
{ id: 'secondaryDepartment', label: '二级部门', builtinKey: 'secondaryDepartment', visible: true },
{ id: 'profitCenter', label: '利润中心', builtinKey: 'profitCenter', visible: true },
{ id: 'departmentOwner', label: '部门负责人', builtinKey: 'departmentOwner', visible: true },
{ id: 'generalOwner', label: '总负责人', builtinKey: 'generalOwner', visible: true },
{ id: 'employmentStatus', label: '在职状态', builtinKey: 'employmentStatus', visible: true },
]
const DEFAULT_SETTINGS: OwnerConfigViewSettings = {
pageTitle: '负责人配置',
pageTitle: '人员信息',
actionsLabel: '操作',
fields: DEFAULT_FIELDS,
}
export const DEFAULT_OWNER_CONFIG_LABELS: OwnerConfigLabels = {
pageTitle: DEFAULT_SETTINGS.pageTitle,
operationOwner: DEFAULT_FIELDS[0].label,
department: DEFAULT_FIELDS[1].label,
secondaryDepartment: DEFAULT_FIELDS[2].label,
profitCenter: DEFAULT_FIELDS[3].label,
departmentOwner: DEFAULT_FIELDS[4].label,
generalOwner: DEFAULT_FIELDS[5].label,
employmentStatus: DEFAULT_FIELDS[6].label,
operationOwner: DEFAULT_FIELDS.find((field) => field.builtinKey === 'operationOwner')!.label,
department: DEFAULT_FIELDS.find((field) => field.builtinKey === 'department')!.label,
secondaryDepartment: DEFAULT_FIELDS.find((field) => field.builtinKey === 'secondaryDepartment')!.label,
profitCenter: DEFAULT_FIELDS.find((field) => field.builtinKey === 'profitCenter')!.label,
departmentOwner: DEFAULT_FIELDS.find((field) => field.builtinKey === 'departmentOwner')!.label,
generalOwner: DEFAULT_FIELDS.find((field) => field.builtinKey === 'generalOwner')!.label,
employmentStatus: DEFAULT_FIELDS.find((field) => field.builtinKey === 'employmentStatus')!.label,
actions: DEFAULT_SETTINGS.actionsLabel,
}
@ -84,12 +84,14 @@ function readLegacyLabels(): OwnerConfigLabels {
const value = JSON.parse(window.localStorage.getItem(OWNER_CONFIG_LABELS_STORAGE_KEY) ?? 'null')
if (!value || typeof value !== 'object') return DEFAULT_OWNER_CONFIG_LABELS
const source = value as Partial<Record<OwnerConfigLabelKey, unknown>>
return Object.fromEntries(
const labels = Object.fromEntries(
Object.entries(DEFAULT_OWNER_CONFIG_LABELS).map(([key, fallback]) => [
key,
cleanLabel(source[key as OwnerConfigLabelKey], fallback),
]),
) as OwnerConfigLabels
if (labels.operationOwner === '运营负责人') labels.operationOwner = DEFAULT_OWNER_CONFIG_LABELS.operationOwner
return labels
} catch {
return DEFAULT_OWNER_CONFIG_LABELS
}
@ -144,9 +146,16 @@ function normalizeSettings(value: unknown): OwnerConfigViewSettings {
function readSettings() {
try {
return normalizeSettings(JSON.parse(
const settings = normalizeSettings(JSON.parse(
window.localStorage.getItem(OWNER_CONFIG_SETTINGS_STORAGE_KEY) ?? 'null',
))
if (settings.pageTitle === '负责人配置') settings.pageTitle = DEFAULT_SETTINGS.pageTitle
settings.fields = settings.fields.map((field) => (
field.builtinKey === 'operationOwner' && field.label === '运营负责人'
? { ...field, label: DEFAULT_OWNER_CONFIG_LABELS.operationOwner }
: field
))
return settings
} catch {
return normalizeSettings(null)
}

View File

@ -3,7 +3,7 @@ import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { OwnerConfigView } from '../OwnerConfigView'
export const metadata = { title: '负责人配置 · 供应链中心' }
export const metadata = { title: '人员信息 · 供应链中心' }
export default async function OwnerConfigPage() {
const { authUser } = await requireSession('/login')