PayrollManagementSystem/frontend/src/views/system/business-roles/index.vue
liuwenxin cfd2a196f2 style(frontend): 表格操作列统一样式与确认框视觉收敛
- 新增 table-actions.css:操作列 flex 居中、间距交给 gap、破坏性动作前
  加分组竖线、链接按钮悬停加深并加下划线(默认浅靛蓝在表格底色上对比度
  不足);包装 span 改 flex,消除行内盒基线导致的约 1.7px 垂直错位
- 11 个列表页操作列套用统一容器与分隔线,列宽按动作自然宽度调整并居中
- 确认框不再传 type:warning(Element Plus 的实心黄圆图标与正文并排成
  两列,标题行反被留空),图标改由全局样式画在标题行左侧,正文与标题
  文字左对齐;图标为 CSS 伪元素,不进无障碍树

验证:pnpm type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:06 +08:00

378 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import type { BusinessRoleItem, TemplateOption } from '@/api/businessRoles'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 岗位角色管理(需求 #28)。
*
* 与后端约定对齐:
* - 内置岗位(营销员/营销经理/信息员)禁删禁停 —— 前端禁用按钮,后端仍会拦截
* - 岗位编码创建后不可修改(它是参数维度值与模板绑定的锚点)
* - 公式模板为受控下拉(代码内注册表的有限集合);新增岗位 = 选模板 + 配参数,
* 无需改代码
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
businessRoleApi.listBusinessRoles,
() => ({
keyword: filters.keyword,
status: filters.status,
}),
)
const templateOptions = ref<TemplateOption[]>([])
async function loadTemplateOptions(): Promise<void> {
try {
templateOptions.value = await businessRoleApi.listTemplateOptions()
} catch (error) {
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
}
}
/** 模板编码 → 名称(表格列展示用,下拉未加载完成时回退显示编码) */
function templateName(code: string): string {
return templateOptions.value.find((tpl) => tpl.code === code)?.name ?? code
}
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
search()
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
/** 编辑中的行(内置岗位的模板仍可改,但编码与删除/停用受保护) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
const rules = computed<FormRules>(() => ({
role_code: isEdit.value
? []
: [
{ required: true, message: '请输入岗位编码', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]{1,31}$/, message: '编码须以小写字母开头,仅可含小写字母、数字与下划线', trigger: 'blur' },
],
role_name: [
{ required: true, message: '请输入岗位名称', trigger: 'blur' },
{ max: 64, message: '岗位名称最长 64 个字符', trigger: 'blur' },
],
calc_template: [{ required: true, message: '请选择计提公式模板', trigger: 'change' }],
description: [{ max: 255, message: '说明最长 255 个字符', trigger: 'blur' }],
}))
const selectedTemplate = computed(() =>
templateOptions.value.find((tpl) => tpl.code === form.calc_template) ?? null,
)
function openCreate(): void {
editingId.value = null
Object.assign(form, {
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: BusinessRoleItem): void {
editingId.value = row.id
Object.assign(form, {
role_code: row.role_code,
role_name: row.role_name,
calc_template: row.calc_template,
description: row.description ?? '',
sort_order: row.sort_order,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await businessRoleApi.updateBusinessRole(editingId.value, {
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description,
sort_order: form.sort_order,
})
ElMessage.success('已保存')
} else {
await businessRoleApi.createBusinessRole({
role_code: form.role_code,
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description || undefined,
sort_order: form.sort_order,
})
ElMessage.success('岗位已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
/** 内置岗位禁删禁停(按钮禁用 + title 说明原因) */
function isBuiltin(row: BusinessRoleItem): boolean {
return row.is_builtin === 1
}
function builtinHint(row: BusinessRoleItem): string {
return isBuiltin(row) ? '内置岗位不可删除或停用' : ''
}
async function handleToggleStatus(row: BusinessRoleItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用岗位「${row.role_name}」?禁用后新核算不再匹配该岗位。`
: `确定启用岗位「${row.role_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => businessRoleApi.setBusinessRoleStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: BusinessRoleItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除岗位「${row.role_name}」?删除为物理删除、不可恢复;该岗位编码相关的参数维度值不会随之清理。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '岗位已删除',
run: () => businessRoleApi.deleteBusinessRole(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadTemplateOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
<el-table-column label="公式模板" min-width="170">
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑岗位' : '新增岗位'"
width="540px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="岗位编码" prop="role_code">
<el-input
v-model="form.role_code"
:disabled="isEdit"
:placeholder="editingRow ? '' : '如 senior_sales,创建后不可修改'"
/>
</el-form-item>
<el-form-item label="岗位名称" prop="role_name">
<el-input v-model="form.role_name" placeholder="如 高级业务员" />
</el-form-item>
<el-form-item label="公式模板" prop="calc_template">
<el-select
v-model="form.calc_template"
placeholder="选择计提公式模板"
style="width: 100%"
>
<el-option
v-for="tpl in templateOptions"
:key="tpl.code"
:label="tpl.name"
:value="tpl.code"
/>
</el-select>
</el-form-item>
<el-form-item v-if="selectedTemplate" label-width="90px">
<div class="tpl-info">
<div>{{ selectedTemplate.description }}</div>
<div v-if="selectedTemplate.params.length" class="tpl-params">
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
</div>
</div>
</el-form-item>
<el-form-item label="岗位说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.tpl-info {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
background: var(--ys-indigo-wash);
color: var(--ys-ink-2);
font-size: 12px;
line-height: 1.7;
}
.tpl-params {
color: var(--ys-ink-3);
}
</style>