- 新增 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>
378 lines
12 KiB
Vue
378 lines
12 KiB
Vue
<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>
|