修复快捷工具无法显示

This commit is contained in:
hero920103 committed 2026-10-30 17:29:28 +08:00
1 parent 9898962836
commit 2e2b837a20
23 files changed
+1941 -38

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import request from '@/utils/request';
/**
* 职级管理接口
*
* 职级(Job Rank)定义「职位等级」:每个部门可以各设一套职级阶梯,
* department_id = 0 表示全公司通用职级,任何部门都能选用。
*
* 数据按租户隔离:后端一律从 JWT 里取 tid,前端传的 tenant_id 一律忽略。
*/
/** 职级列表。scope=assign 时返回「该部门可用」的职级(部门自身 + 全公司通用) */
export function getJobRankList(params = {}) {
return request({
url: '/backend/jobrank/list',
method: 'get',
params,
});
}
export function getJobRankDetail(id) {
return request({
url: `/backend/jobrank/detail/${id}`,
method: 'get',
});
}
export function createJobRank(data) {
return request({
url: '/backend/jobrank/create',
method: 'post',
data,
});
}
export function updateJobRank(id, data) {
return request({
url: `/backend/jobrank/update/${id}`,
method: 'post',
data,
});
}
export function deleteJobRank(id) {
return request({
url: `/backend/jobrank/delete/${id}`,
method: 'delete',
});
}
/** 职级编码唯一性校验(excludeId 为编辑时要排除的自身) */
export function checkJobRankCodeUnique(rankCode, excludeId) {
return request({
url: '/backend/jobrank/checkCodeUnique',
method: 'get',
params: { rank_code: rankCode, exclude_id: excludeId || '' },
});
}
export default {
getJobRankList,
getJobRankDetail,
createJobRank,
updateJobRank,
deleteJobRank,
checkJobRankCodeUnique,
};
+31
View File
@@ -0,0 +1,31 @@
/**
* 「复制修改」通用字段处理。
*
* 复制 = 用已有记录把表单预填好,把「同租户内不能重复」的字段改掉,
* 让用户只改几项就能保存成一条新记录。
*
* 各模块的复制差异都体现在调用处,这里只提供字段级的小工具。
*/
/** 名称加「- 副本」后缀,一眼能看出这是复制出来的那条 */
export function copyName(name) {
const value = String(name || '').trim();
return value ? `${value} - 副本` : '- 副本';
}
/**
* 编码加「-COPY」后缀。
*
* 编码在同租户内唯一,复制时必须换一个新的。后缀只用字母与横线,
* 以符合各处的编码校验 /^[a-zA-Z0-9_-]*$/。
*
* maxLength 传该字段的长度上限:加上后缀后超长会被前端的
* maxlength 直接截断,所以这里先按上限裁剪原值再拼后缀。
*/
export function copyCode(code, maxLength = 50) {
const value = String(code || '').trim();
if (!value) return '';
const suffix = '-COPY';
const keep = Math.max(1, maxLength - suffix.length);
return `${value.slice(0, keep)}${suffix}`;
}
@@ -238,6 +238,7 @@
<el-table :data="schemes" max-height="420">
<el-table-column prop="scheme_name" label="方案名称" min-width="150" />
<el-table-column label="员工" width="130"><template #default="{ row }">{{ employeeName(row.employee_id) }}</template></el-table-column>
<el-table-column label="职级" width="130"><template #default="{ row }"><el-tag v-if="row.rank_name" size="small" type="primary">{{ row.rank_name }}</el-tag><span v-else>-</span></template></el-table-column>
<el-table-column prop="effective_date" label="生效日期" width="115" />
<el-table-column prop="expiry_date" label="失效日期" width="115"><template #default="{ row }">{{ row.expiry_date || "长期" }}</template></el-table-column>
<el-table-column label="固定月薪" width="130" align="right"><template #default="{ row }">¥{{ money(fixedSchemeAmount(row)) }}</template></el-table-column>
@@ -251,7 +252,13 @@
<el-form ref="schemeFormRef" :model="schemeForm" :rules="schemeRules" label-width="120px">
<div class="form-grid">
<el-form-item label="方案名称" prop="scheme_name"><el-input v-model="schemeForm.scheme_name" /></el-form-item>
<el-form-item label="适用员工" prop="employee_id"><el-select v-model="schemeForm.employee_id" filterable style="width: 100%"><el-option v-for="employee in employees" :key="employee.id" :label="employeeLabel(employee)" :value="employee.id" /></el-select></el-form-item>
<el-form-item label="适用员工" prop="employee_id"><el-select v-model="schemeForm.employee_id" filterable style="width: 100%" @change="onSchemeEmployeeChange"><el-option v-for="employee in employees" :key="employee.id" :label="employeeLabel(employee)" :value="employee.id" /></el-select></el-form-item>
<el-form-item label="职级" prop="rank_id">
<el-select v-model="schemeForm.rank_id" clearable placeholder="按职级带出标准薪资" style="width: 100%" :loading="rankLoading">
<el-option v-for="item in schemeRankOptions" :key="item.id" :label="rankLabel(item)" :value="item.id" />
</el-select>
<div v-if="schemeRankHint" class="form-tip">{{ schemeRankHint }}</div>
</el-form-item>
<el-form-item label="生效日期" prop="effective_date"><el-date-picker v-model="schemeForm.effective_date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
<el-form-item label="失效日期"><el-date-picker v-model="schemeForm.expiry_date" value-format="YYYY-MM-DD" clearable style="width: 100%" /></el-form-item>
</div>
@@ -278,6 +285,7 @@ import {
getCompensationDashboard, getCompensationEmployees, getCompensationSchemes,
getPayrollDetail, getPayrollList, updateCompensationScheme, updatePayroll, updatePayrollStatus
} from "@/api/oaCompensation"
import { getJobRankList } from "@/api/jobRank"
const MoneyInput = defineComponent({
props: { modelValue: Number, label: String },
@@ -315,13 +323,58 @@ function emptyPayroll() {
return { employee_id: null, scheme_id: null, payroll_month: currentMonth(), base_salary: 0, post_allowance: 0, performance_salary: 0, transport_allowance: 0, meal_allowance: 0, communication_allowance: 0, overtime_pay: 0, bonus: 0, other_addition: 0, leave_deduction: 0, late_deduction: 0, other_deduction: 0, social_insurance: 0, housing_fund: 0, personal_income_tax: 0, remark: "", items: [] }
}
function emptyScheme() {
return { employee_id: null, scheme_name: "", effective_date: new Date().toISOString().slice(0, 10), expiry_date: "", base_salary: 0, post_allowance: 0, performance_salary: 0, transport_allowance: 0, meal_allowance: 0, communication_allowance: 0, social_insurance_base: 0, housing_fund_base: 0, social_insurance_rate: 0, housing_fund_rate: 0, status: 0, remark: "" }
return { employee_id: null, rank_id: null, scheme_name: "", effective_date: new Date().toISOString().slice(0, 10), expiry_date: "", base_salary: 0, post_allowance: 0, performance_salary: 0, transport_allowance: 0, meal_allowance: 0, communication_allowance: 0, social_insurance_base: 0, housing_fund_base: 0, social_insurance_rate: 0, housing_fund_rate: 0, status: 0, remark: "" }
}
const payrollForm = reactive(emptyPayroll())
const schemeForm = reactive(emptyScheme())
const payrollRules = { employee_id: [{ required: true, message: "请选择员工", trigger: "change" }], payroll_month: [{ required: true, message: "请选择工资月份", trigger: "change" }] }
const schemeRules = { employee_id: [{ required: true, message: "请选择员工", trigger: "change" }], scheme_name: [{ required: true, message: "请输入方案名称", trigger: "blur" }], effective_date: [{ required: true, message: "请选择生效日期", trigger: "change" }] }
// ---------- 职级 ----------
// 职级决定职位等级,薪酬方案选定职级后按该职级的标准薪资区间给出参考薪资。
const jobRanks = ref([])
const rankLoading = ref(false)
/** 当前方案可选的职级:员工所在部门的职级 + 全公司通用职级 */
const schemeRankOptions = computed(() => {
const employee = employees.value.find(item => item.id === schemeForm.employee_id)
const departmentId = Number(employee?.department || 0)
return jobRanks.value.filter(item => !item.department_id || !departmentId || Number(item.department_id) === departmentId)
})
/** 职级下拉文案:编码 + 名称,全公司通用的带标识 */
function rankLabel(item) {
const scope = item.department_id === 0 ? "(全公司)" : ""
return `${item.rank_code} ${item.rank_name}${scope}`
}
/** 已选职级的标准薪资提示 */
const schemeRankHint = computed(() => {
const rank = jobRanks.value.find(item => item.id === schemeForm.rank_id)
if (!rank) return ""
const min = Number(rank.salary_min || 0)
const max = Number(rank.salary_max || 0)
if (min <= 0 && max <= 0) return "该职级未配置标准薪资区间"
const range = max > 0 ? `¥${money(min)} ~ ¥${money(max)}` : `¥${money(min)} 起`
return `该职级标准薪资区间:${range}(基本工资留空时自动取下限)`
})
async function loadJobRanks() {
rankLoading.value = true
try {
const res = await getJobRankList({})
if (res?.code === 200) jobRanks.value = res.data || []
} finally { rankLoading.value = false }
}
/** 切换员工:职级可选范围随部门变化,已选职级若不再适用则清空 */
function onSchemeEmployeeChange() {
const options = schemeRankOptions.value
if (schemeForm.rank_id && !options.some(item => item.id === schemeForm.rank_id)) {
schemeForm.rank_id = null
}
}
const matchingSchemes = computed(() => schemes.value.filter(item => item.employee_id === payrollForm.employee_id && item.status === 0))
const grossTotal = computed(() => sum(payrollForm, ["base_salary", "post_allowance", "performance_salary", "transport_allowance", "meal_allowance", "communication_allowance", "overtime_pay", "bonus", "other_addition"]) + customTotal(1))
const deductionTotal = computed(() => sum(payrollForm, ["leave_deduction", "late_deduction", "other_deduction", "social_insurance", "housing_fund", "personal_income_tax"]) + customTotal(2))
@@ -416,7 +469,7 @@ async function removePayroll(row) {
}
function resetSchemeForm() { Object.assign(schemeForm, emptyScheme()) }
function openSchemeCreate() { schemeEditingID.value = 0; resetSchemeForm(); schemeFormVisible.value = true }
function openSchemeEdit(row) { schemeEditingID.value = row.id; Object.assign(schemeForm, emptyScheme(), row); schemeFormVisible.value = true }
function openSchemeEdit(row) { schemeEditingID.value = row.id; Object.assign(schemeForm, emptyScheme(), row); schemeForm.rank_id = row.rank_id ? Number(row.rank_id) : null; schemeFormVisible.value = true }
async function saveScheme() {
await schemeFormRef.value?.validate()
schemeSaving.value = true
@@ -431,7 +484,7 @@ async function removeScheme(row) {
if (res?.code === 200) { ElMessage.success("删除成功"); loadSchemes() } else ElMessage.error(res?.msg || "删除失败")
}
onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadDashboard(), loadPayrolls()]) })
onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadDashboard(), loadPayrolls(), loadJobRanks()]) })
</script>
<style lang="less" scoped>
@@ -449,6 +502,7 @@ onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadD
.table-card { padding: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; }.filter-bar { flex-wrap: wrap; margin-bottom: 14px; }.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
.net-money, .addition-text { color: #e6a23c; }.deduction-text { color: #f56c6c; }.more-icon { margin-left: 3px; vertical-align: -2px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }.main-fields { margin-bottom: 4px; }
.form-tip { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 2px; }
.salary-section { border-top: 1px solid #ebeef5; padding: 14px 0 2px; margin-top: 4px; }.section-title { justify-content: space-between; gap: 8px; margin-bottom: 13px; font-weight: 600; color: #303133; }.dot { width: 8px; height: 8px; border-radius: 50%; }.dot.addition { background: #67c23a; }.dot.deduction { background: #f56c6c; }.section-title:has(.dot) { justify-content: flex-start; }
.salary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 20px; }
.custom-items { padding-bottom: 10px; }.custom-item { gap: 10px; margin-bottom: 10px; }.custom-item :deep(.el-input) { flex: 1; }
@@ -1,5 +1,13 @@
<template>
<el-drawer v-model="visible" :title="isEdit ? `编辑${labels.employeeLabel}` : `新增${labels.employeeLabel}`" size="520px">
<el-drawer v-model="visible" :title="isEdit ? `编辑${labels.employeeLabel}` : isCopy ? `复制${labels.employeeLabel}` : `新增${labels.employeeLabel}`" size="520px">
<el-alert
v-if="isCopy"
:title="`已按原${labels.employeeLabel}预填,账号与密码需重新填写,保存后会生成一条新记录`"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<div class="form-title">账号信息</div>
@@ -123,7 +131,7 @@
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">保存</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">{{ isEdit ? '保存' : '创建' }}</el-button>
</template>
</el-drawer>
</template>
@@ -132,6 +140,7 @@
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, moduleLabels, unwrap, useOrganizationApi } from '../composables';
import { copyName } from '@/utils/copyFields';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
@@ -146,6 +155,11 @@ const labels = computed(() => moduleLabels(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
/**
* 复制模式:预填原记录但走「创建」流程,保存后生成一条新记录。
* 复制时不置 isEdit —— 账号因此保持可编辑,密码也仍然必填。
*/
const isCopy = ref(false);
const editingId = ref(0);
const formRef = ref();
/** 编辑回填期间置 true,级联 watcher 跳过清空,避免回填值被覆盖 */
@@ -293,16 +307,23 @@ const loadOptions = async () => {
}
};
const open = async (row, defaults = {}) => {
/**
* 打开抽屉。
* @param {object|null} row 编辑的记录;传 null 表示新增
* @param {object} options { copy: true } 复制模式;
* { department, affiliate_unit } 新增时预选组织
*/
const open = async (row, options = {}) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row?.id);
isCopy.value = Boolean(options.copy);
isEdit.value = Boolean(row?.id) && !isCopy.value;
editingId.value = row?.id || 0;
visible.value = true;
formRef.value?.clearValidate();
await loadOptions();
if (isEdit.value) {
if (row?.id) {
try {
const detail = unwrap(await api.value.getEmployeeDetail(row.id)) || {};
backfilling.value = true;
@@ -320,6 +341,13 @@ const open = async (row, defaults = {}) => {
home_address: detail.home_address || '',
account_status: Number(detail.account_status ?? detail.status ?? 1),
});
if (isCopy.value) {
// 复制:账号是登录名且租户内唯一,必须换一个新的;密码重新设置
form.account = '';
form.password = '';
form.confirmPassword = '';
form.name = copyName(detail.name);
}
// watcher 为 pre-flush 异步执行,需等其执行完毕再复位标志,
// 否则复位后 watcher 才触发,仍会清空 department / position。
await nextTick();
@@ -331,11 +359,11 @@ const open = async (row, defaults = {}) => {
return;
}
if (defaults.department) {
form.department = String(defaults.department);
if (options.department) {
form.department = String(options.department);
}
if (defaults.affiliate_unit) {
form.affiliate_unit = String(defaults.affiliate_unit);
if (options.affiliate_unit) {
form.affiliate_unit = String(options.affiliate_unit);
}
};
@@ -85,10 +85,11 @@
<el-tag :type="employeeStatusTagType(row)" size="small">{{ employeeStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<el-table-column label="操作" width="330" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewDialogRef?.open(row)">查看</el-button>
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="editDialogRef?.open(row, { copy: true })">复制</el-button>
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
<el-button link type="warning" size="small" @click="handleResetPassword(row)">重置密码</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
@@ -1,11 +1,19 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑组织' : '新建组织'"
:title="isEdit ? '编辑组织' : isCopy ? '复制组织' : '新建组织'"
width="600px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-alert
v-if="isCopy"
title="已按原组织预填,修改后保存会在同级下生成一条新的组织记录"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="上级组织" prop="parent_id">
<el-tree-select
@@ -77,6 +85,7 @@
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, collectSubtreeIds, unwrap, useOrganizationApi } from '../composables';
import { copyCode, copyName } from '@/utils/copyFields';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
@@ -90,6 +99,8 @@ const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
/** 复制模式:预填原记录但走「创建」流程,保存后生成一条新记录 */
const isCopy = ref(false);
const formRef = ref();
const orgTree = ref([]);
const employeeOptions = ref([]);
@@ -159,9 +170,16 @@ const loadOptions = async () => {
}
};
const open = (row, parentId) => {
/**
* 打开弹窗。
* @param {object|null} row 编辑的记录;传 null 表示新建
* @param {object} options { copy: true } 复制模式;{ parentId } 新建时预选上级组织
*/
const open = (row, options = {}) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row);
isCopy.value = Boolean(options.copy);
// 复制时不能置 isEdit,否则提交会走更新、覆盖掉原记录
isEdit.value = Boolean(row) && !isCopy.value;
editingId.value = row?.id || 0;
if (row) {
@@ -175,8 +193,13 @@ const open = (row, parentId) => {
status: Number(row.status ?? 1),
remark: row.remark || '',
});
} else if (parentId) {
form.parent_id = Number(parentId);
if (isCopy.value) {
// 复制:编码在租户内唯一,必须换一个新的;名称加副本后缀便于区分
form.org_code = copyCode(row.org_code, 20);
form.org_name = copyName(row.org_name);
}
} else if (options.parentId) {
form.parent_id = Number(options.parentId);
form.is_company = 0;
} else {
form.is_company = 1;
@@ -11,7 +11,7 @@
导入导出
</el-button>
<el-button :icon="Setting" @click="settingsRef?.open()">组织设置</el-button>
<el-button type="primary" :icon="Plus" @click="orgDialogRef?.open(null, null)">新建组织</el-button>
<el-button type="primary" :icon="Plus" @click="orgDialogRef?.open(null)">新建组织</el-button>
</div>
</div>
@@ -75,10 +75,11 @@
</el-tag>
</span>
<span class="node-actions">
<el-button link type="primary" size="small" @click.stop="orgDialogRef?.open(null, data.id)">
<el-button link type="primary" size="small" @click.stop="orgDialogRef?.open(null, { parentId: data.id })">
加子级
</el-button>
<el-button link type="primary" size="small" @click.stop="orgDialogRef?.open(data)">编辑</el-button>
<el-button link type="warning" size="small" @click.stop="orgDialogRef?.open(data, { copy: true })">复制</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteOrg(data)">删除</el-button>
</span>
</div>
@@ -168,9 +169,10 @@
<el-tag :type="employeeStatusTagType(row)" size="small">{{ employeeStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="employeeDialogRef?.open(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="employeeDialogRef?.open(row, { copy: true })">复制</el-button>
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
<el-button link type="danger" size="small" @click="handleDeleteEmployee(row)">删除</el-button>
</template>
@@ -1,11 +1,19 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑职位' : '新增职位'"
:title="isEdit ? '编辑职位' : isCopy ? '复制职位' : '新增职位'"
width="560px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-alert
v-if="isCopy"
title="已按原职位预填,修改后保存会生成一条新的职位记录"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="所属部门" prop="department_id">
<el-tree-select
@@ -35,6 +43,26 @@
</el-radio-group>
</el-form-item>
<el-form-item label="职级" prop="rank_id">
<el-select
v-model="form.rank_id"
clearable
placeholder="选择职级"
style="width: 100%"
:loading="rankLoading"
>
<el-option
v-for="item in rankOptions"
:key="item.id"
:label="rankLabel(item)"
:value="item.id"
/>
</el-select>
<div class="form-tip">
职级决定该职位的等级,供薪酬定薪与权限分级使用;可选项随所选部门变化(始终包含全公司通用职级)
</div>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
@@ -61,9 +89,11 @@
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { buildOrgTree, unwrap, useOrganizationApi } from '../composables';
import { getJobRankList } from '@/api/jobRank';
import { copyCode, copyName } from '@/utils/copyFields';
const props = defineProps({
/** 所属模块:erp(进销存)或 oa(办公自动化) */
@@ -77,9 +107,13 @@ const api = computed(() => useOrganizationApi(props.module));
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
/** 复制模式:预填原记录但走「创建」流程,保存后生成一条新记录 */
const isCopy = ref(false);
const editingId = ref(0);
const formRef = ref();
const departmentTree = ref([]);
const rankOptions = ref([]);
const rankLoading = ref(false);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
@@ -88,6 +122,7 @@ const emptyForm = () => ({
position_name: '',
position_code: '',
position_type: 0,
rank_id: null,
sort: 0,
status: 1,
remark: '',
@@ -102,6 +137,34 @@ const rules = {
],
};
/** 职级下拉文案:编码 + 名称,全公司通用的带上标识 */
const rankLabel = (item) => {
const scope = item.department_id === 0 ? '(全公司)' : '';
return `${item.rank_code} ${item.rank_name}${scope}`;
};
/** 加载某部门可用的职级(本部门职级 + 全公司通用职级) */
const loadRanks = async (departmentId) => {
rankLoading.value = true;
try {
const params = { scope: 'assign' };
if (departmentId) params.department_id = departmentId;
rankOptions.value = unwrap(await getJobRankList(params)) || [];
} catch {
rankOptions.value = [];
} finally {
rankLoading.value = false;
}
};
// 切换部门后职级可选范围随之变化,重新拉取
watch(
() => form.department_id,
(value) => {
loadRanks(value);
}
);
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await api.value.getOrganizationList()));
@@ -110,9 +173,16 @@ const loadDepartments = async () => {
}
};
const open = (row, departmentId) => {
/**
* 打开弹窗。
* @param {object|null} row 编辑的记录;传 null 表示新增
* @param {object} options { copy: true } 复制模式;{ departmentId } 新增时预选部门
*/
const open = (row, options = {}) => {
Object.assign(form, emptyForm());
isEdit.value = Boolean(row?.id);
isCopy.value = Boolean(options.copy);
// 复制时不能置 isEdit,否则提交会走更新、覆盖掉原记录
isEdit.value = Boolean(row?.id) && !isCopy.value;
editingId.value = row?.id || 0;
if (row) {
@@ -121,16 +191,23 @@ const open = (row, departmentId) => {
position_name: row.position_name || '',
position_code: row.position_code || '',
position_type: Number(row.position_type || 0),
rank_id: row.rank_id ? Number(row.rank_id) : null,
sort: Number(row.sort || 0),
status: Number(row.status ?? 1),
remark: row.remark || '',
});
} else if (departmentId) {
form.department_id = Number(departmentId);
if (isCopy.value) {
// 复制:编码在租户内唯一,必须换一个新的;名称加副本后缀便于区分
form.position_code = copyCode(row.position_code, 50);
form.position_name = copyName(row.position_name);
}
} else if (options.departmentId) {
form.department_id = Number(options.departmentId);
}
visible.value = true;
loadDepartments();
loadRanks(form.department_id);
};
const handleSubmit = async () => {
@@ -146,6 +223,7 @@ const handleSubmit = async () => {
position_name: form.position_name.trim(),
position_code: form.position_code.trim(),
position_type: form.position_type,
rank_id: form.rank_id || 0,
sort: form.sort,
status: form.status,
remark: form.remark || '',
@@ -176,3 +254,12 @@ const handleClosed = () => {
defineExpose({ open });
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 2px;
}
</style>
@@ -7,7 +7,7 @@
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadData">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null, filters.department_id)">
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null, { departmentId: filters.department_id })">
新增职位
</el-button>
</div>
@@ -59,6 +59,12 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="职级" min-width="140">
<template #default="{ row }">
<el-tag v-if="row.rank_id" size="small" type="primary">{{ row.rank_name || '已设置' }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
@@ -70,9 +76,10 @@
<el-table-column label="备注" min-width="140">
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="editDialogRef?.open(row, { copy: true })">复制</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -0,0 +1,235 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑职级' : isCopy ? '复制职级' : '新增职级'"
width="600px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-alert
v-if="isCopy"
title="已按原职级预填,修改后保存会生成一条新的职级记录"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="职级名称" prop="rank_name">
<el-input v-model="form.rank_name" maxlength="100" show-word-limit placeholder="例如:高级工程师" />
</el-form-item>
<el-form-item label="职级编码" prop="rank_code">
<el-input v-model="form.rank_code" maxlength="50" show-word-limit placeholder="例如:P5 / M2" />
</el-form-item>
<el-form-item label="等级序号" prop="level_no">
<el-input-number v-model="form.level_no" :min="0" :max="999" controls-position="right" style="width: 100%" />
<div class="form-tip">数字越大职级越高,用于排序与「取不超过某职级的最高一级」的计算</div>
</el-form-item>
<el-form-item label="适用部门" prop="department_id">
<el-tree-select
v-model="form.department_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="不选表示全公司通用"
style="width: 100%"
/>
<div class="form-tip">留空表示全公司通用职级,任何部门都能选用</div>
</el-form-item>
<el-form-item label="标准薪资下限" prop="salary_min">
<el-input-number v-model="form.salary_min" :min="0" :max="99999999" :precision="2" :step="1000" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="标准薪资上限" prop="salary_max">
<el-input-number v-model="form.salary_max" :min="0" :max="99999999" :precision="2" :step="1000" controls-position="right" style="width: 100%" />
<div class="form-tip">填 0 表示不限;薪酬方案选定该职级时会带出此区间作为参考薪资</div>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
checkJobRankCodeUnique,
createJobRank,
getJobRankDetail,
updateJobRank,
} from '@/api/jobRank';
import { oaOrganizationApi } from '@/api/organization';
import { buildOrgTree, unwrap } from '@/views/apps/organization/composables';
import { copyCode, copyName } from '@/utils/copyFields';
const emit = defineEmits(['success']);
const visible = ref(false);
const loading = ref(false);
const isEdit = ref(false);
/** 复制模式:预填原记录但走「创建」流程,保存后生成一条新记录 */
const isCopy = ref(false);
const editingId = ref(0);
const formRef = ref();
const departmentTree = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const emptyForm = () => ({
rank_name: '',
rank_code: '',
level_no: 0,
department_id: null,
salary_min: 0,
salary_max: 0,
sort: 0,
status: 1,
remark: '',
});
const form = reactive(emptyForm());
const rules = {
rank_name: [{ required: true, message: '请输入职级名称', trigger: 'blur' }],
rank_code: [
{ required: true, message: '请输入职级编码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]*$/, message: '只能包含英文、数字、下划线和横线', trigger: 'blur' },
],
};
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await oaOrganizationApi.getOrganizationList()));
} catch (error) {
ElMessage.error(error?.message || '加载部门失败');
}
};
const open = async (row, options = {}) => {
Object.assign(form, emptyForm());
isCopy.value = Boolean(options.copy);
// 复制时不能置 isEdit,否则提交会走更新、覆盖掉原记录
isEdit.value = Boolean(row?.id) && !isCopy.value;
editingId.value = row?.id || 0;
if (row) {
try {
const detail = unwrap(await getJobRankDetail(row.id)) || row;
Object.assign(form, {
rank_name: detail.rank_name || '',
rank_code: detail.rank_code || '',
level_no: Number(detail.level_no || 0),
department_id: detail.department_id ? Number(detail.department_id) : null,
salary_min: Number(detail.salary_min || 0),
salary_max: Number(detail.salary_max || 0),
sort: Number(detail.sort || 0),
status: Number(detail.status ?? 1),
remark: detail.remark || '',
});
if (isCopy.value) {
// 复制:编码在租户内唯一,必须换一个新的;名称加副本后缀便于区分
form.rank_code = copyCode(detail.rank_code, 50);
form.rank_name = copyName(detail.rank_name);
}
} catch (error) {
ElMessage.error(error?.message || '加载职级详情失败');
}
}
visible.value = true;
loadDepartments();
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
// 编码唯一性先在前端查一次,避免后端报错信息过长。
// 复制模式是新记录,不排除任何已有记录。
try {
const excludeId = isCopy.value ? 0 : editingId.value;
const check = unwrap(await checkJobRankCodeUnique(form.rank_code.trim(), excludeId));
if (check && check.unique === false) {
ElMessage.error(check.message || '职级编码已存在');
return;
}
} catch {
// 校验接口异常不阻断提交,交由后端最终校验
}
const payload = {
rank_name: form.rank_name.trim(),
rank_code: form.rank_code.trim(),
level_no: form.level_no,
department_id: form.department_id || 0,
salary_min: form.salary_min,
salary_max: form.salary_max,
sort: form.sort,
status: form.status,
remark: form.remark || '',
};
loading.value = true;
try {
if (isEdit.value) {
unwrap(await updateJobRank(editingId.value, payload));
ElMessage.success('保存成功');
} else {
unwrap(await createJobRank(payload));
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
loading.value = false;
}
};
const handleClosed = () => {
formRef.value?.clearValidate();
Object.assign(form, emptyForm());
};
defineExpose({ open });
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 2px;
}
</style>
@@ -0,0 +1,243 @@
<template>
<div class="job-rank-page">
<div class="page-header">
<div>
<h2>职级管理</h2>
<p>按部门设置职位等级,供薪酬定薪与权限分级使用;未指定部门的职级为全公司通用</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadData">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null)">新增职级</el-button>
</div>
</div>
<el-card shadow="never">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="职级名称 / 编码"
clearable
class="filter-item"
:prefix-icon="Search"
@change="handleSearch"
/>
<el-tree-select
v-model="filters.department_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="按适用部门筛选"
class="filter-item"
@change="handleSearch"
/>
<el-select
v-model="filters.status"
clearable
placeholder="状态"
class="filter-item status-filter"
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<el-table :data="pagedRows" v-loading="loading" stripe>
<el-table-column prop="rank_code" label="职级编码" width="130" />
<el-table-column prop="rank_name" label="职级名称" min-width="140" />
<el-table-column prop="level_no" label="等级序号" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.level_no" size="small" type="primary">L{{ row.level_no }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="适用范围" min-width="150">
<template #default="{ row }">
<el-tag v-if="row.department_id === 0" size="small" type="info">全公司通用</el-tag>
<span v-else>{{ row.department_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="标准薪资" min-width="160" align="right">
<template #default="{ row }">{{ salaryRange(row) }}</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="140">
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="editDialogRef?.open(row, { copy: true })">复制</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无职级数据" :image-size="90" />
</template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="rows.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</el-card>
<JobRankEditDialog ref="editDialogRef" @success="loadData" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, Search } from '@element-plus/icons-vue';
import JobRankEditDialog from './components/JobRankEditDialog.vue';
import { getJobRankList, deleteJobRank } from '@/api/jobRank';
import { oaOrganizationApi } from '@/api/organization';
import { buildOrgTree, unwrap } from '@/views/apps/organization/composables';
const editDialogRef = ref();
const rows = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const departmentTree = ref([]);
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const filters = reactive({ keyword: '', department_id: null, status: '' });
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return rows.value.slice(start, start + pageSize.value);
});
/** 标准薪资区间展示;上限为 0 表示不限 */
const salaryRange = (row) => {
const min = Number(row.salary_min || 0);
const max = Number(row.salary_max || 0);
if (min <= 0 && max <= 0) return '-';
if (max <= 0) return `¥${min} 起`;
return `¥${min} ~ ¥${max}`;
};
const loadDepartments = async () => {
try {
departmentTree.value = buildOrgTree(unwrap(await oaOrganizationApi.getOrganizationList()));
} catch {
departmentTree.value = [];
}
};
const loadData = async () => {
loading.value = true;
try {
const params = {};
if (filters.keyword) params.keyword = filters.keyword;
if (filters.department_id) params.department_id = filters.department_id;
if (filters.status !== '' && filters.status !== null) params.status = filters.status;
rows.value = unwrap(await getJobRankList(params)) || [];
const maxPage = Math.max(1, Math.ceil(rows.value.length / pageSize.value));
if (page.value > maxPage) {
page.value = maxPage;
}
} catch (error) {
ElMessage.error(error?.message || '获取职级列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
loadData();
};
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除职级「${row.rank_name}」吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
unwrap(await deleteJobRank(row.id));
ElMessage.success('删除成功');
await loadData();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
onMounted(() => {
loadDepartments();
loadData();
});
</script>
<style lang="less" scoped>
.job-rank-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
h2 {
margin: 0 0 6px;
font-size: 20px;
color: #303133;
}
p {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filter-item {
width: 240px;
}
.status-filter {
width: 150px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
+675
View File
@@ -0,0 +1,675 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendJobRankController 职级管理接口。
//
// 职级(Job Rank)定义「职位等级」:每个部门可以各设一套职级阶梯,
// department_id 指向 yz_backend_organization.id,department_id = 0 表示
// 全公司通用职级,任何部门都能选用。
//
// 职级的使用链路:
//
// 员工 → 职位(yz_backend_position.rank_id) → 职级
// 薪酬方案(yz_backend_oa_compensation_schemes.rank_id) → 职级
//
// 所有查询与写入都强制带上 JWT 中的租户ID(tid),实现租户间隔离;
// 前端传入的 tid / tenant_id 一律忽略,避免越权读取其它租户的数据。
type BackendJobRankController struct {
beego.Controller
}
// jobRankDTO 职级返回结构。
type jobRankDTO struct {
ID uint64 `json:"id"`
Tid uint64 `json:"tid"`
TenantID uint64 `json:"tenant_id"`
RankCode string `json:"rank_code"`
RankName string `json:"rank_name"`
LevelNo int `json:"level_no"`
DepartmentID uint64 `json:"department_id"`
DepartmentName string `json:"department_name"`
SalaryMin float64 `json:"salary_min"`
SalaryMax float64 `json:"salary_max"`
DefaultRoleID uint64 `json:"default_role_id"`
Status int8 `json:"status"`
Sort int `json:"sort"`
Remark string `json:"remark"`
CreateTime string `json:"create_time"`
}
// jobRankPayload 职级新增/编辑入参。全部使用指针,区分「没传」与「传了零值」。
type jobRankPayload struct {
RankCode *string `json:"rank_code"`
RankName *string `json:"rank_name"`
LevelNo *int `json:"level_no"`
DepartmentID *uint64 `json:"department_id"`
SalaryMin *float64 `json:"salary_min"`
SalaryMax *float64 `json:"salary_max"`
DefaultRoleID *uint64 `json:"default_role_id"`
Status *int8 `json:"status"`
Sort *int `json:"sort"`
Remark *string `json:"remark"`
}
// ---------------------------------------------------------------------------
// 认证与响应
// ---------------------------------------------------------------------------
// claims 解析 Authorization 头中的 JWT,要求是租户端(backend)用户且带有效租户ID。
func (c *BackendJobRankController) claims() (*jwtutil.Claims, bool) {
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
if err != nil || claims.TenantId <= 0 {
return nil, false
}
return claims, true
}
// pathUint64 读取路径参数中的无符号整数。
func (c *BackendJobRankController) pathUint64(name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
return id, err == nil && id > 0
}
// nowString 当前时间字符串(软删除用)。
func (c *BackendJobRankController) nowString() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// tenantID 取当前登录租户ID,未通过认证时直接输出 401 并返回 false。
func (c *BackendJobRankController) tenantID() (uint64, bool) {
claims, ok := c.claims()
if !ok {
c.jsonError(401, "未登录或登录已过期")
return 0, false
}
return uint64(claims.TenantId), true
}
func (c *BackendJobRankController) jsonOK(data interface{}) {
resp := map[string]interface{}{"code": 200, "msg": "success"}
if data != nil {
resp["data"] = data
}
c.Data["json"] = resp
_ = c.ServeJSON()
}
func (c *BackendJobRankController) jsonError(code int, msg string) {
if code == 401 {
c.Ctx.Output.SetStatus(401)
}
c.Data["json"] = map[string]interface{}{"code": code, "msg": msg}
_ = c.ServeJSON()
}
// ---------------------------------------------------------------------------
// 查询基座
// ---------------------------------------------------------------------------
func (c *BackendJobRankController) rankQuery(tid uint64) orm.QuerySeter {
return models.Orm.QueryTable(new(models.BackendJobRank)).
Filter("tid", tid).
Filter("delete_time__isnull", true)
}
// jobRankPayloadOf 解析请求体(JSON 优先,兼容表单提交)。
func (c *BackendJobRankController) jobRankPayloadOf() (jobRankPayload, bool) {
var payload jobRankPayload
contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type"))
if strings.Contains(contentType, "json") && len(c.Ctx.Input.RequestBody) > 0 {
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
return payload, false
}
return payload, true
}
// 表单提交:逐字段回退
getString := func(key string) *string {
v := strings.TrimSpace(c.GetString(key))
if v == "" {
return nil
}
return &v
}
getFloat := func(key string) *float64 {
v := strings.TrimSpace(c.GetString(key))
if v == "" {
return nil
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil
}
return &f
}
getInt := func(key string) *int {
v := strings.TrimSpace(c.GetString(key))
if v == "" {
return nil
}
i, err := strconv.Atoi(v)
if err != nil {
return nil
}
return &i
}
getUint64 := func(key string) *uint64 {
v := strings.TrimSpace(c.GetString(key))
if v == "" {
return nil
}
i, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return nil
}
return &i
}
payload.RankCode = getString("rank_code")
payload.RankName = getString("rank_name")
payload.LevelNo = getInt("level_no")
payload.DepartmentID = getUint64("department_id")
payload.SalaryMin = getFloat("salary_min")
payload.SalaryMax = getFloat("salary_max")
payload.DefaultRoleID = getUint64("default_role_id")
payload.Sort = getInt("sort")
payload.Remark = getString("remark")
if v := getString("status"); v != nil {
status := int8(0)
if s, err := strconv.Atoi(*v); err == nil {
status = int8(s)
}
payload.Status = &status
}
return payload, true
}
// ---------------------------------------------------------------------------
// 职级 CRUD
// ---------------------------------------------------------------------------
// List 职级列表。
// GET /backend/jobrank/list
//
// 参数:
// keyword 职级名称 / 编码 模糊匹配
// department_id 适用部门筛选
// status 状态筛选(1 启用 / 0 禁用)
// scope=assign 返回「该部门可用」的职级:部门自身 + 全公司通用(department_id=0)。
// 职位、薪酬方案选择职级时用这个口径;不传则返回租户全部职级。
func (c *BackendJobRankController) List() {
tid, ok := c.tenantID()
if !ok {
return
}
cond := orm.NewCondition().And("tid", tid).And("delete_time__isnull", true)
departmentID, _ := c.GetUint64("department_id")
scope := strings.ToLower(strings.TrimSpace(c.GetString("scope")))
if scope == "assign" {
// 可选范围 = 本部门职级 + 全公司通用职级;
// 尚未指定部门时不按部门收窄,先把全部职级摆出来供选择。
if departmentID > 0 {
cond = cond.And("department_id__in", []uint64{0, departmentID})
}
} else if departmentID > 0 {
cond = cond.And("department_id", departmentID)
}
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("rank_name__icontains", keyword).
Or("rank_code__icontains", keyword))
}
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
if status, err := strconv.Atoi(raw); err == nil {
cond = cond.And("status", int8(status))
}
}
var rows []models.BackendJobRank
if _, err := models.Orm.QueryTable(new(models.BackendJobRank)).
SetCond(cond).OrderBy("level_no", "sort", "id").All(&rows); err != nil {
c.jsonError(500, "查询职级失败: "+err.Error())
return
}
deptNames := jobRankDeptNameMap(tid)
list := make([]jobRankDTO, 0, len(rows))
for _, row := range rows {
list = append(list, jobRankDTOOf(row, deptNames))
}
c.jsonOK(list)
}
// Detail 职级详情。
// GET /backend/jobrank/detail/:id
func (c *BackendJobRankController) Detail() {
tid, ok := c.tenantID()
if !ok {
return
}
id, valid := c.pathUint64(":id")
if !valid {
c.jsonError(400, "无效ID")
return
}
var row models.BackendJobRank
if err := c.rankQuery(tid).Filter("id", id).One(&row); err != nil {
c.jsonError(404, "职级不存在")
return
}
c.jsonOK(jobRankDTOOf(row, jobRankDeptNameMap(tid)))
}
// Create 新增职级。
// POST /backend/jobrank/create
func (c *BackendJobRankController) Create() {
tid, ok := c.tenantID()
if !ok {
return
}
payload, ok := c.jobRankPayloadOf()
if !ok {
c.jsonError(400, "参数错误")
return
}
rankName := ""
if payload.RankName != nil {
rankName = strings.TrimSpace(*payload.RankName)
}
if rankName == "" {
c.jsonError(400, "职级名称不能为空")
return
}
rankCode := ""
if payload.RankCode != nil {
rankCode = strings.TrimSpace(*payload.RankCode)
}
if rankCode == "" {
c.jsonError(400, "职级编码不能为空")
return
}
count, err := c.rankQuery(tid).Filter("rank_code", rankCode).Count()
if err != nil {
c.jsonError(500, "校验职级编码失败: "+err.Error())
return
}
if count > 0 {
c.jsonError(400, "职级编码已存在")
return
}
departmentID := uint64(0)
if payload.DepartmentID != nil {
departmentID = *payload.DepartmentID
}
if departmentID > 0 && !jobRankOrgExists(tid, departmentID) {
c.jsonError(400, "适用部门不存在")
return
}
salaryMin, salaryMax := 0.0, 0.0
if payload.SalaryMin != nil {
salaryMin = *payload.SalaryMin
}
if payload.SalaryMax != nil {
salaryMax = *payload.SalaryMax
}
if salaryMin < 0 || salaryMax < 0 {
c.jsonError(400, "标准薪资不能为负数")
return
}
if salaryMax > 0 && salaryMax < salaryMin {
c.jsonError(400, "标准薪资上限不能小于下限")
return
}
row := models.BackendJobRank{
Tid: tid,
RankCode: rankCode,
RankName: rankName,
LevelNo: 0,
DepartmentID: departmentID,
SalaryMin: salaryMin,
SalaryMax: salaryMax,
DefaultRoleID: 0,
Status: 1,
Sort: 0,
}
if payload.LevelNo != nil {
row.LevelNo = *payload.LevelNo
}
if payload.DefaultRoleID != nil {
row.DefaultRoleID = *payload.DefaultRoleID
}
if payload.Status != nil {
row.Status = int8(1)
if *payload.Status == 0 {
row.Status = 0
}
}
if payload.Sort != nil {
row.Sort = *payload.Sort
}
if payload.Remark != nil {
row.Remark = strPtrIfNotEmpty(strings.TrimSpace(*payload.Remark))
}
id, err := models.Orm.Insert(&row)
if err != nil {
c.jsonError(500, "创建职级失败: "+err.Error())
return
}
c.jsonOK(map[string]interface{}{"id": id, "rank_code": rankCode})
}
// Update 更新职级。
// POST /backend/jobrank/update/:id
func (c *BackendJobRankController) Update() {
tid, ok := c.tenantID()
if !ok {
return
}
id, valid := c.pathUint64(":id")
if !valid {
c.jsonError(400, "无效ID")
return
}
if !c.rankQuery(tid).Filter("id", id).Exist() {
c.jsonError(404, "职级不存在")
return
}
payload, ok := c.jobRankPayloadOf()
if !ok {
c.jsonError(400, "参数错误")
return
}
update := orm.Params{}
if payload.RankCode != nil {
code := strings.TrimSpace(*payload.RankCode)
if code == "" {
c.jsonError(400, "职级编码不能为空")
return
}
count, err := c.rankQuery(tid).Filter("rank_code", code).Exclude("id", id).Count()
if err != nil {
c.jsonError(500, "校验职级编码失败: "+err.Error())
return
}
if count > 0 {
c.jsonError(400, "职级编码已存在")
return
}
update["rank_code"] = code
}
if payload.RankName != nil {
name := strings.TrimSpace(*payload.RankName)
if name == "" {
c.jsonError(400, "职级名称不能为空")
return
}
update["rank_name"] = name
}
if payload.LevelNo != nil {
update["level_no"] = *payload.LevelNo
}
if payload.DepartmentID != nil {
departmentID := *payload.DepartmentID
if departmentID > 0 && !jobRankOrgExists(tid, departmentID) {
c.jsonError(400, "适用部门不存在")
return
}
update["department_id"] = departmentID
}
if payload.SalaryMin != nil || payload.SalaryMax != nil {
// 薪资区间必须整体校验:只传一端时用另一端的现值补齐。
var row models.BackendJobRank
if err := c.rankQuery(tid).Filter("id", id).One(&row); err != nil {
c.jsonError(404, "职级不存在")
return
}
salaryMin, salaryMax := row.SalaryMin, row.SalaryMax
if payload.SalaryMin != nil {
salaryMin = *payload.SalaryMin
}
if payload.SalaryMax != nil {
salaryMax = *payload.SalaryMax
}
if salaryMin < 0 || salaryMax < 0 {
c.jsonError(400, "标准薪资不能为负数")
return
}
if salaryMax > 0 && salaryMax < salaryMin {
c.jsonError(400, "标准薪资上限不能小于下限")
return
}
update["salary_min"] = salaryMin
update["salary_max"] = salaryMax
}
if payload.DefaultRoleID != nil {
// 预留字段:本期不启用职级→角色权限绑定,仅落库保存。
update["default_role_id"] = *payload.DefaultRoleID
}
if payload.Status != nil {
status := int8(1)
if *payload.Status == 0 {
status = 0
}
update["status"] = status
}
if payload.Sort != nil {
update["sort"] = *payload.Sort
}
if payload.Remark != nil {
update["remark"] = nullableString(strings.TrimSpace(*payload.Remark))
}
if len(update) == 0 {
c.jsonError(400, "无更新字段")
return
}
if _, err := c.rankQuery(tid).Filter("id", id).Update(update); err != nil {
c.jsonError(500, "更新职级失败: "+err.Error())
return
}
c.jsonOK(nil)
}
// Delete 删除职级(软删除)。
// DELETE /backend/jobrank/delete/:id
func (c *BackendJobRankController) Delete() {
tid, ok := c.tenantID()
if !ok {
return
}
id, valid := c.pathUint64(":id")
if !valid {
c.jsonError(400, "无效ID")
return
}
// 还有职位引用该职级时不允许删除:否则职级消失后职位会指向空职级,
// 薪酬/权限按职级取数时拿不到任何标准值。
positionCount, err := models.Orm.QueryTable(new(models.BackendPosition)).
Filter("tid", tid).
Filter("rank_id", id).
Filter("delete_time__isnull", true).
Count()
if err != nil {
c.jsonError(500, "校验职级引用失败: "+err.Error())
return
}
if positionCount > 0 {
c.jsonError(400, "该职级已被 "+strconv.FormatInt(positionCount, 10)+" 个职位引用,请先调整职位的职级")
return
}
// 薪酬方案同样引用职级:删掉职级会让方案拿不到标准薪资区间,
// 历史方案也会指向空职级,因此一并拦下。
schemeCount, err := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).
Filter("tid", tid).
Filter("rank_id", id).
Filter("is_deleted", 0).
Count()
if err != nil {
c.jsonError(500, "校验职级引用失败: "+err.Error())
return
}
if schemeCount > 0 {
c.jsonError(400, "该职级已被 "+strconv.FormatInt(schemeCount, 10)+" 个薪酬方案引用,请先调整方案的职级")
return
}
num, err := c.rankQuery(tid).Filter("id", id).
Update(orm.Params{"delete_time": c.nowString(), "status": int8(0)})
if err != nil {
c.jsonError(500, "删除职级失败: "+err.Error())
return
}
if num == 0 {
c.jsonError(404, "职级不存在")
return
}
c.jsonOK(nil)
}
// CheckCodeUnique 检查职级编码唯一性。
// GET /backend/jobrank/checkCodeUnique?rank_code=xx&exclude_id=1
func (c *BackendJobRankController) CheckCodeUnique() {
tid, ok := c.tenantID()
if !ok {
return
}
code := strings.TrimSpace(c.GetString("rank_code"))
if code == "" {
c.jsonError(400, "职级编码不能为空")
return
}
excludeID, _ := c.GetUint64("exclude_id")
qs := c.rankQuery(tid).Filter("rank_code", code)
if excludeID > 0 {
qs = qs.Exclude("id", excludeID)
}
count, err := qs.Count()
if err != nil {
c.jsonError(500, "检查编码唯一性失败: "+err.Error())
return
}
c.jsonOK(map[string]interface{}{"unique": count == 0, "message": uniqueMessage(count == 0, "编码")})
}
// ---------------------------------------------------------------------------
// 供其它模块复用的辅助函数
// ---------------------------------------------------------------------------
// jobRankDTOOf 把模型行转成 DTO,并补上部门名称。
func jobRankDTOOf(row models.BackendJobRank, deptNames map[uint64]string) jobRankDTO {
deptName := ""
if row.DepartmentID > 0 {
deptName = deptNames[row.DepartmentID]
}
if row.DepartmentID == 0 {
deptName = "全公司通用"
}
return jobRankDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
RankCode: row.RankCode,
RankName: row.RankName,
LevelNo: row.LevelNo,
DepartmentID: row.DepartmentID,
DepartmentName: deptName,
SalaryMin: row.SalaryMin,
SalaryMax: row.SalaryMax,
DefaultRoleID: row.DefaultRoleID,
Status: row.Status,
Sort: row.Sort,
Remark: derefString(row.Remark),
CreateTime: formatDateTime(&row.CreateTime),
}
}
// jobRankDeptNameMap 组织ID -> 名称,用于展示职级适用部门。
func jobRankDeptNameMap(tid uint64) map[uint64]string {
result := map[uint64]string{}
var rows []models.BackendOrganization
if _, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
All(&rows, "ID", "OrgName"); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row.OrgName
}
return result
}
// jobRankOrgExists 判断部门是否存在(同租户)。
func jobRankOrgExists(tid, id uint64) bool {
if id == 0 {
return false
}
return models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).
Filter("id", id).
Filter("delete_time__isnull", true).
Exist()
}
// JobRankMap 返回租户内的职级映射(ID -> 模型行),供职位、薪酬等模块复用。
func JobRankMap(tid uint64) map[uint64]models.BackendJobRank {
result := map[uint64]models.BackendJobRank{}
if tid == 0 {
return result
}
var rows []models.BackendJobRank
if _, err := models.Orm.QueryTable(new(models.BackendJobRank)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
All(&rows); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row
}
return result
}
// ResolveEmployeeRankID 解析员工对应的职级:
// 员工上记录的职位是「职位名称」,先按 部门+职位名 找到职位,再取职位上的职级。
// 找不到职位或职位未设置职级时返回 0。
func ResolveEmployeeRankID(tid uint64, employee models.BackendEmployee) uint64 {
positionName := strings.TrimSpace(derefString(employee.Position))
if positionName == "" {
return 0
}
qs := models.Orm.QueryTable(new(models.BackendPosition)).
Filter("tid", tid).
Filter("position_name", positionName).
Filter("delete_time__isnull", true)
if departmentID, err := strconv.ParseUint(strings.TrimSpace(derefString(employee.Department)), 10, 64); err == nil && departmentID > 0 {
qs = qs.Filter("department_id", departmentID)
}
var row models.BackendPosition
if err := qs.OrderBy("id").One(&row); err != nil {
return 0
}
return row.RankID
}
+107 -7
View File
@@ -73,7 +73,11 @@ type compensationItemPayload struct {
}
type compensationSchemePayload struct {
EmployeeID uint64 `json:"employee_id"`
EmployeeID uint64 `json:"employee_id"`
// RankID 职级ID(yz_backend_job_rank.id),0=未指定。
// 传 0 时后端会按「员工 → 职位 → 职级」自动带出;带上后按职级的
// 标准薪资区间给出参考薪资(base_salary 未填时自动取下限)。
RankID uint64 `json:"rank_id"`
SchemeName string `json:"scheme_name"`
EffectiveDate string `json:"effective_date"`
ExpiryDate string `json:"expiry_date"`
@@ -299,7 +303,80 @@ func (c *BackendOaCompensationController) SchemeList() {
if rows == nil {
rows = []models.BackendOaCompensationScheme{}
}
c.compensationOK(rows)
c.compensationOK(compensationSchemeViews(uint64(claims.TenantId), rows))
}
// compensationSchemeView 给单个薪酬方案补上职级信息:
// 职级名称与该职级的标准薪资区间,前端据此展示「按职级带出的标准薪资」。
func compensationSchemeView(tid uint64, row models.BackendOaCompensationScheme) map[string]interface{} {
item := map[string]interface{}{}
if b, err := json.Marshal(row); err == nil {
_ = json.Unmarshal(b, &item)
}
item["rank_name"] = ""
item["rank_code"] = ""
item["rank_level_no"] = 0
item["rank_salary_min"] = float64(0)
item["rank_salary_max"] = float64(0)
if row.RankID > 0 {
if rank, ok := JobRankMap(tid)[row.RankID]; ok {
item["rank_name"] = rank.RankName
item["rank_code"] = rank.RankCode
item["rank_level_no"] = rank.LevelNo
item["rank_salary_min"] = rank.SalaryMin
item["rank_salary_max"] = rank.SalaryMax
}
}
return item
}
// compensationSchemeViews 给薪酬方案列表补上职级信息。
func compensationSchemeViews(tid uint64, rows []models.BackendOaCompensationScheme) []map[string]interface{} {
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, compensationSchemeView(tid, row))
}
return list
}
// compensationResolveRank 解析薪酬方案使用的职级:
// - 显式传了 rank_id:必须属于当前租户,且适用部门要与员工部门一致
// (全公司通用职级不受限);
// - 没传:按「员工 → 职位 → 职级」自动带出。
// 第三个返回值为错误提示,空串表示解析成功。
func (c *BackendOaCompensationController) compensationResolveRank(
tid uint64, employee *models.BackendEmployee, rankID uint64,
) (uint64, models.BackendJobRank, string) {
if employee == nil {
return 0, models.BackendJobRank{}, "员工不存在或不属于当前租户"
}
ranks := JobRankMap(tid)
if rankID == 0 {
// 未指定:按员工所在职位带出职级
derived := ResolveEmployeeRankID(tid, *employee)
if derived == 0 {
return 0, models.BackendJobRank{}, ""
}
rank, ok := ranks[derived]
if !ok {
return 0, models.BackendJobRank{}, ""
}
return derived, rank, ""
}
rank, ok := ranks[rankID]
if !ok {
return 0, models.BackendJobRank{}, "职级不存在或不属于当前租户"
}
// 职级适用部门必须覆盖员工所在部门
if rank.DepartmentID != 0 {
departmentID, err := strconv.ParseUint(strings.TrimSpace(compensationString(employee.Department)), 10, 64)
if err == nil && departmentID > 0 && rank.DepartmentID != departmentID {
return 0, models.BackendJobRank{}, "职级「" + rank.RankName + "」不适用于该员工所在部门"
}
}
return rankID, rank, ""
}
// CreateScheme POST /backend/oa/compensation/schemes
@@ -342,9 +419,21 @@ func (c *BackendOaCompensationController) CreateScheme() {
if status != 0 {
status = 1
}
// 职级:未指定时按「员工 → 职位 → 职级」自动带出;
// 带出后 base_salary 留空则取职级标准薪资下限作为参考值。
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
if rankErr != "" {
c.compensationError(400, rankErr)
return
}
baseSalary := compensationMoney(payload.BaseSalary)
if baseSalary <= 0 && rank.SalaryMin > 0 {
baseSalary = compensationMoney(rank.SalaryMin)
}
row := &models.BackendOaCompensationScheme{
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, SchemeName: strings.TrimSpace(payload.SchemeName),
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: compensationMoney(payload.BaseSalary),
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, RankID: rankID,
SchemeName: strings.TrimSpace(payload.SchemeName),
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: baseSalary,
PostAllowance: compensationMoney(payload.PostAllowance), PerformanceSalary: compensationMoney(payload.PerformanceSalary),
TransportAllowance: compensationMoney(payload.TransportAllowance), MealAllowance: compensationMoney(payload.MealAllowance),
CommunicationAllowance: compensationMoney(payload.CommunicationAllowance), SocialInsuranceBase: compensationMoney(payload.SocialInsuranceBase),
@@ -355,7 +444,7 @@ func (c *BackendOaCompensationController) CreateScheme() {
c.compensationError(500, "保存薪酬方案失败")
return
}
c.compensationOK(row)
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), *row))
}
// UpdateScheme POST /backend/oa/compensation/schemes/:id
@@ -385,10 +474,16 @@ func (c *BackendOaCompensationController) UpdateScheme() {
c.compensationError(400, "方案内容无效")
return
}
if _, ok = c.employee(uint64(claims.TenantId), payload.EmployeeID); !ok {
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
if !ok {
c.compensationError(400, "员工不存在或不属于当前租户")
return
}
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
if rankErr != "" {
c.compensationError(400, rankErr)
return
}
var expiry *time.Time
if strings.TrimSpace(payload.ExpiryDate) != "" {
value, dateOK := validCompensationDate(payload.ExpiryDate)
@@ -400,7 +495,12 @@ func (c *BackendOaCompensationController) UpdateScheme() {
}
now := time.Now()
row.EmployeeID, row.SchemeName, row.EffectiveDate, row.ExpiryDate = payload.EmployeeID, strings.TrimSpace(payload.SchemeName), date, expiry
row.RankID = rankID
row.BaseSalary, row.PostAllowance, row.PerformanceSalary = compensationMoney(payload.BaseSalary), compensationMoney(payload.PostAllowance), compensationMoney(payload.PerformanceSalary)
// 职级带出:base_salary 未填且职级有标准薪资时取下限作为参考值
if row.BaseSalary <= 0 && rank.SalaryMin > 0 {
row.BaseSalary = compensationMoney(rank.SalaryMin)
}
row.TransportAllowance, row.MealAllowance, row.CommunicationAllowance = compensationMoney(payload.TransportAllowance), compensationMoney(payload.MealAllowance), compensationMoney(payload.CommunicationAllowance)
row.SocialInsuranceBase, row.HousingFundBase, row.SocialInsuranceRate, row.HousingFundRate = compensationMoney(payload.SocialInsuranceBase), compensationMoney(payload.HousingFundBase), payload.SocialInsuranceRate, payload.HousingFundRate
row.Status, row.Remark, row.UpdateTime = payload.Status, strings.TrimSpace(payload.Remark), &now
@@ -411,7 +511,7 @@ func (c *BackendOaCompensationController) UpdateScheme() {
c.compensationError(500, "更新薪酬方案失败")
return
}
c.compensationOK(row)
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), row))
}
// DeleteScheme DELETE /backend/oa/compensation/schemes/:id
+47
View File
@@ -20,6 +20,11 @@ import (
// 前端传入的 tid / tenant_id 一律忽略,避免越权读取其它租户的数据。
type BackendOrganizationController struct {
beego.Controller
// jobRanks 职级映射缓存:beego 每请求新建一个控制器实例,
// 因此这里的缓存只在单次请求内有效,正好避免职位列表逐条查职级表(N+1)。
jobRanks map[uint64]models.BackendJobRank
jobRanksTid uint64
}
const orgSettingsCodePrefix = "backend_org_settings"
@@ -112,6 +117,9 @@ type positionDTO struct {
PositionCode string `json:"position_code"`
PositionName string `json:"position_name"`
PositionType int8 `json:"position_type"`
RankID uint64 `json:"rank_id"`
RankName string `json:"rank_name"`
RankLevelNo int `json:"rank_level_no"`
Status int8 `json:"status"`
Sort uint `json:"sort"`
Remark string `json:"remark"`
@@ -1298,12 +1306,27 @@ func (c *BackendOrganizationController) CreatePosition() {
sortVal, _ := c.getUintValue(body, "sort")
remark, _ := c.getStringValue(body, "remark")
// 职级:必须是本租户的职级,且适用部门要对得上(0=全公司通用始终可用)。
rankID, _ := c.getUint64Value(body, "rank_id")
if rankID > 0 {
rank, ok := c.jobRankMap(tid)[rankID]
if !ok {
c.jsonError(400, "职级不存在")
return
}
if rank.DepartmentID != 0 && departmentID > 0 && rank.DepartmentID != departmentID {
c.jsonError(400, "职级「"+rank.RankName+"」不属于所选部门")
return
}
}
row := models.BackendPosition{
Tid: tid,
DepartmentID: departmentID,
PositionCode: positionCode,
PositionName: positionName,
PositionType: int8(positionType),
RankID: rankID,
Status: 1,
Sort: sortVal,
Remark: strPtrIfNotEmpty(remark),
@@ -1379,6 +1402,30 @@ func (c *BackendOrganizationController) EditPosition() {
if v, has := c.getIntValue(body, "position_type"); has {
update["position_type"] = int8(v)
}
if v, has := c.getUint64Value(body, "rank_id"); has {
// 职级必须是本租户的职级;适用部门与职位所属部门不一致时拒绝。
if v > 0 {
rank, ok := c.jobRankMap(tid)[v]
if !ok {
c.jsonError(400, "职级不存在")
return
}
departmentID := uint64(0)
if cur, has := c.getUint64Value(body, "department_id"); has {
departmentID = cur
} else {
var cur models.BackendPosition
if err := c.positionQuery(tid).Filter("id", id).One(&cur); err == nil {
departmentID = cur.DepartmentID
}
}
if rank.DepartmentID != 0 && departmentID > 0 && rank.DepartmentID != departmentID {
c.jsonError(400, "职级「"+rank.RankName+"」不属于所选部门")
return
}
}
update["rank_id"] = v
}
if v, has := c.getIntValue(body, "status"); has {
update["status"] = int8(v)
}
+19 -1
View File
@@ -132,7 +132,7 @@ func (c *BackendOrganizationController) assembleEmployeeDTO(
func (c *BackendOrganizationController) positionDTO(tid uint64, row models.BackendPosition) positionDTO {
nameByID := c.orgNameMap(tid)
return positionDTO{
dto := positionDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
@@ -141,11 +141,29 @@ func (c *BackendOrganizationController) positionDTO(tid uint64, row models.Backe
PositionCode: row.PositionCode,
PositionName: row.PositionName,
PositionType: row.PositionType,
RankID: row.RankID,
Status: row.Status,
Sort: row.Sort,
Remark: derefString(row.Remark),
CreateTime: formatDateTime(&row.CreateTime),
}
// 职级名称:职位挂了职级才显示,未设置时留空由前端显示「-」。
if row.RankID > 0 {
if rank, ok := c.jobRankMap(tid)[row.RankID]; ok {
dto.RankName = rank.RankName
dto.RankLevelNo = rank.LevelNo
}
}
return dto
}
// jobRankMap 取租户内的职级映射,单次请求内只查一次。
func (c *BackendOrganizationController) jobRankMap(tid uint64) map[uint64]models.BackendJobRank {
if c.jobRanks == nil || c.jobRanksTid != tid {
c.jobRanks = JobRankMap(tid)
c.jobRanksTid = tid
}
return c.jobRanks
}
// ---------------------------------------------------------------------------
+97
View File
@@ -0,0 +1,97 @@
-- =============================================================
-- OA 职级管理 —— 表结构
--
-- 对应代码:
-- models/backend_job_rank.go 职级模型
-- controllers/backend_job_rank.go 职级 CRUD 接口
--
-- 职级(Job Rank)用于定义「职位等级」:
-- · 每个部门可以各设一套职级阶梯。department_id 指向
-- yz_backend_organization.id;department_id = 0 表示
-- 全公司通用职级,任何部门都能选用。
-- · level_no 是等级序号(数字越大职级越高),用于排序与
-- 「取不超过某职级的最高一级」这类计算。
-- · salary_min / salary_max 是该职级的标准薪资区间,
-- 薪酬方案选定职级后据此带出参考薪资。
-- · default_role_id 是为「职级 → 角色权限绑定」预留的字段,
-- 本期不启用,后端不做任何自动授权,等权限方案确定后单独做。
--
-- 使用链路:
-- 员工(yz_backend_employee) → 职位(yz_backend_position.rank_id)
-- → 职级(yz_backend_job_rank)
-- 薪酬方案(yz_backend_oa_compensation_schemes.rank_id) → 职级
--
-- 执行顺序:
-- 1) 本脚本:建表 + 给职位表 / 薪酬方案表补 rank_id 列
-- 2) oa_job_rank_menus.sql:注册「OA → 基础设置 → 职级管理」菜单并授权角色
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 职级表 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_job_rank` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`rank_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职级编码,如 P5 / M2',
`rank_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '职级名称,如 高级工程师',
`level_no` INT NOT NULL DEFAULT 0 COMMENT '等级序号,数字越大职级越高',
`department_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '适用部门ID(yz_backend_organization.id),0=全公司通用',
`salary_min` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '标准薪资下限',
`salary_max` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '标准薪资上限,0=不限',
`default_role_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '预留:默认角色ID(职级→角色权限绑定,本期不启用)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用 1=启用',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`remark` VARCHAR(512) DEFAULT NULL COMMENT '备注',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` DATETIME DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_tid_deleted` (`tid`, `delete_time`) USING BTREE,
KEY `idx_tid_department` (`tid`, `department_id`) USING BTREE,
KEY `idx_tid_code` (`tid`, `rank_code`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='职级表';
-- =============================================================
-- 给既有表补 rank_id 列
-- MySQL 不支持 ADD COLUMN IF NOT EXISTS,先查 information_schema 再动态执行,
-- 保证脚本可重复执行。
-- =============================================================
-- ---------- yz_backend_position(职位挂职级) ----------
SET @tbl := 'yz_backend_position';
SET @col := 'rank_id';
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
);
SET @sql := IF(@exists = 0,
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置'' AFTER `position_type`'),
CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置''')
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- yz_backend_oa_compensation_schemes(薪酬方案带职级) ----------
SET @tbl := 'yz_backend_oa_compensation_schemes';
SET @col := 'rank_id';
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
);
SET @sql := IF(@exists = 0,
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置'' AFTER `employee_id`'),
CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置''')
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------- 校验 ----------------
SHOW CREATE TABLE `yz_backend_job_rank`;
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND COLUMN_NAME = 'rank_id'
AND TABLE_NAME IN ('yz_backend_position', 'yz_backend_oa_compensation_schemes');
+67
View File
@@ -0,0 +1,67 @@
-- =============================================================
-- 修复:模块「展示端(platforms)」误配置导致菜单整块消失
--
-- 背景:
-- yz_system_modules.platforms 是与 status/is_show、菜单 views、角色 rights
-- 并列的第四个维度,决定模块/菜单出现在 Web 端还是移动端:
-- ["web"] 仅 Web 端(租户管理后台 PC)
-- ["app"] 仅移动端(APP / 微信小程序)
-- ["web","app"] 两端都显示
-- '' / NULL 全端显示(默认)
--
-- 菜单自身未配置 platforms 时,按 path 前缀继承所属模块的 platforms
-- (services.MenuVisibleOn)。所以模块一旦配错,其下整棵菜单都会被
-- 某一端整体剔除——而后台的「模块管理 / 菜单管理 / 角色管理」里
-- 所有开关都是正常的,界面上完全看不出原因。
--
-- 事故原因:
-- add_module_platforms.sql 里的「使用示例」第 1、2 条是演示语句,
-- 却被原样执行到了正式库:
-- 例1 UPDATE yz_system_modules SET platforms = JSON_ARRAY('web') WHERE code = 'crm';
-- 例2 UPDATE yz_system_modules SET platforms = JSON_ARRAY('app') WHERE code = 'bianjiegongju';
--
-- 后果:
-- 例2 → 租户后台(Web)左侧菜单里 /tools(便捷工具)整块消失:
-- 记事本、日程提醒、账号管理全部不显示。
-- 例1 → 移动端工作台没有「客户关系」模块卡片,/apps/crm 下 29 条菜单全部不显示。
--
-- 修复:两个模块都恢复为两端可见。
-- 依据:
-- · 便捷工具:backend/src/views/tools/ 有记事本/日程/密码库完整页面;
-- uniapp/pages/toolbox/ 有 home/functions/mine/todo;
-- 菜单 views 含 2;全部租户套餐都含 bianjiegongju。
-- · 客户关系:backend/src/views/apps/crm/ 与 uniapp/pages/crm/ 均已完整实现;
-- SUBSYSTEMS 已注册 crm;菜单 views 全为 2(租户端两端共用)。
--
-- 回滚:
-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('app')
-- WHERE code = 'bianjiegongju';
-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web')
-- WHERE code = 'crm';
-- =============================================================
-- ---------- 修复前先看一眼现状 ----------
SELECT id, name, code, path, type, status, is_show, platforms
FROM yz_system_modules
WHERE code IN ('bianjiegongju', 'crm');
-- ---------- 恢复为两端可见 ----------
UPDATE yz_system_modules
SET platforms = JSON_ARRAY('web', 'app'),
update_time = NOW()
WHERE code IN ('bianjiegongju', 'crm')
AND delete_time IS NULL;
-- ---------- 校验 ----------
SELECT id, name, code, path, type, status, is_show, platforms
FROM yz_system_modules
WHERE code IN ('bianjiegongju', 'crm');
-- ---------- 顺带体检:其余模块不应残留单端配置 ----------
-- 除本次修复的两个模块外,其余模块 platforms 应为 NULL(全端)。
SELECT id, name, code, path, platforms
FROM yz_system_modules
WHERE delete_time IS NULL
AND platforms IS NOT NULL
AND platforms <> ''
AND code NOT IN ('bianjiegongju', 'crm');
+68
View File
@@ -0,0 +1,68 @@
-- ============================================================
-- OA 职级管理 —— 菜单配置(yz_system_menu)
--
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_job_rank_menus.sql
--
-- 挂载位置:OA → 基础设置(目录,path = '/apps/oa/normalsetting')
-- OA
-- └─ 基础设置
-- ├─ 组织架构 /basic-settings/organization
-- ├─ 人员管理 /basic-settings/employee
-- ├─ 职位管理 /basic-settings/position
-- └─ 职级管理 /basic-settings/jobrank ← 本脚本新增
--
-- 对应页面:backend/src/views/basicSettings/jobRank/index.vue
--
-- 说明:
-- 1. views = [2](租户端),与同级的组织架构/人员管理/职位管理保持一致;
-- platforms 留空(= 全端),移动端可通过 webview 容器打开同一页面。
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
-- 因此本脚本同时把菜单 ID 追加到角色 rights。
-- ============================================================
SET @basic_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/normalsetting' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 职级管理(页面) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @basic_id, '职级管理', '/basic-settings/jobrank', '/basicSettings/jobRank/index.vue',
'Sort', 4, 1, 1, '[2]', NULL, 2, 'system',
'按部门设置职位等级,供薪酬管理与权限分级使用'
FROM DUAL
WHERE @basic_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basic-settings/jobrank' AND delete_time IS NULL) x);
SET @jobrank_id := (
SELECT id FROM yz_system_menu
WHERE path = '/basic-settings/jobrank' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @jobrank_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @jobrank_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@jobrank_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, views, platforms, type, sort
FROM yz_system_menu
WHERE delete_time IS NULL AND path = '/basic-settings/jobrank';
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@jobrank_id AS JSON)) AS has_jobrank
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
+37
View File
@@ -0,0 +1,37 @@
package models
import "time"
// BackendJobRank 职级表 yz_backend_job_rank
//
// 职级用于定义「职位等级」:每个部门可以各设一套职级阶梯,
// DepartmentID = 0 表示全公司通用职级,任何部门都能选用。
//
// 与既有模块的关系:
// - 职位通过 rank_id 挂到职级上(yz_backend_position.rank_id);
// - 薪酬方案通过 rank_id 挂到职级上(yz_backend_oa_compensation_schemes.rank_id),
// 并按职级的标准薪资区间(SalaryMin / SalaryMax)给出参考薪资;
// - DefaultRoleID 是为「职级 → 角色权限绑定」预留的字段,本期不启用,
// 后端不做任何自动授权,等权限方案确定后单独做。
type BackendJobRank struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
RankCode string `orm:"column(rank_code);size(50)" json:"rank_code"`
RankName string `orm:"column(rank_name);size(100)" json:"rank_name"`
LevelNo int `orm:"column(level_no);default(0)" json:"level_no"` // 等级序号,数字越大职级越高
DepartmentID uint64 `orm:"column(department_id);default(0)" json:"department_id"` // 适用部门ID,0=全公司通用
SalaryMin float64 `orm:"column(salary_min);type(decimal);digits(12);decimals(2);default(0)" json:"salary_min"`
SalaryMax float64 `orm:"column(salary_max);type(decimal);digits(12);decimals(2);default(0)" json:"salary_max"`
DefaultRoleID uint64 `orm:"column(default_role_id);default(0)" json:"default_role_id"` // 预留:职级→角色权限绑定,本期不启用
Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:0-禁用 1-启用
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
// TableName 自定义表名
func (m *BackendJobRank) TableName() string {
return "yz_backend_job_rank"
}
+3
View File
@@ -8,6 +8,9 @@ type BackendOaCompensationScheme struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
EmployeeID uint64 `orm:"column(employee_id)" json:"employee_id"`
// RankID 职级ID(yz_backend_job_rank.id),0=未设置职级。
// 方案选定职级后,按职级的标准薪资区间(salary_min/salary_max)给出参考薪资。
RankID uint64 `orm:"column(rank_id);default(0)" json:"rank_id"`
SchemeName string `orm:"column(scheme_name);size(100)" json:"scheme_name"`
EffectiveDate time.Time `orm:"column(effective_date);type(date)" json:"effective_date"`
ExpiryDate *time.Time `orm:"column(expiry_date);null;type(date)" json:"expiry_date"`
+3
View File
@@ -61,6 +61,9 @@ type BackendPosition struct {
PositionCode string `orm:"column(position_code);size(50)" json:"position_code"`
PositionName string `orm:"column(position_name);size(100)" json:"position_name"`
PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"`
// RankID 职级ID(yz_backend_job_rank.id),0=未设置职级。
// 职级定义了「职位等级」,薪酬与权限分级都沿 员工→职位→职级 取用。
RankID uint64 `orm:"column(rank_id);default(0)" json:"rank_id"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort uint `orm:"column(sort);default(0)" json:"sort"`
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
+2
View File
@@ -47,6 +47,8 @@ func Init(_ string) {
new(BackendOrganization),
new(BackendEmployee),
new(BackendPosition),
// 职级(职位等级):yz_backend_job_rank,按部门分设职级阶梯
new(BackendJobRank),
new(BackendErpContact),
new(BackendApprovalFlow),
new(BackendApprovalRecord),
+8
View File
@@ -334,6 +334,14 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/compensation/payrolls/:id", &controllers.BackendOaCompensationController{}, "get:Detail;post:Update;delete:Delete")
beego.Router("/backend/oa/compensation/payrolls/:id/status", &controllers.BackendOaCompensationController{}, "post:UpdateStatus")
// OA 职级管理(职位等级):租户级基础数据,供职位与薪酬模块引用
beego.Router("/backend/jobrank/list", &controllers.BackendJobRankController{}, "get:List")
beego.Router("/backend/jobrank/detail/:id", &controllers.BackendJobRankController{}, "get:Detail")
beego.Router("/backend/jobrank/create", &controllers.BackendJobRankController{}, "post:Create")
beego.Router("/backend/jobrank/update/:id", &controllers.BackendJobRankController{}, "post:Update")
beego.Router("/backend/jobrank/delete/:id", &controllers.BackendJobRankController{}, "delete:Delete")
beego.Router("/backend/jobrank/checkCodeUnique", &controllers.BackendJobRankController{}, "get:CheckCodeUnique")
// OA工作排班(班次 / 排班方案 / 排班看板 / 临时调配)
beego.Router("/backend/oa/scheduling/shifts", &controllers.BackendOaSchedulingController{}, "get:ShiftList;post:CreateShift")
beego.Router("/backend/oa/scheduling/shifts/:id", &controllers.BackendOaSchedulingController{}, "post:UpdateShift;delete:DeleteShift")