440 lines
16 KiB
Vue
440 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { Loader2, Save, Settings2, SlidersHorizontal } from "@lucide/vue";
|
||
import { computed, onMounted, ref } from "vue";
|
||
|
||
import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs";
|
||
import { ApiError } from "../api/http";
|
||
import type { SalaryConfigRecord } from "../api/types";
|
||
import StatCard from "../components/StatCard.vue";
|
||
|
||
const configs = ref<SalaryConfigRecord[]>([]);
|
||
const activeGroup = ref("");
|
||
const loading = ref(false);
|
||
const savingKey = ref("");
|
||
const errorMessage = ref("");
|
||
const successMessage = ref("");
|
||
|
||
type RulePresentation = {
|
||
name: string;
|
||
purpose: string;
|
||
};
|
||
|
||
const groupLabels: Record<string, string> = {
|
||
attendance: "考勤规则",
|
||
payroll: "薪资规则",
|
||
overtime: "加班规则",
|
||
leave: "请假规则",
|
||
penalty: "扣款规则",
|
||
};
|
||
|
||
const groupOrder = ["attendance", "payroll", "overtime", "leave", "penalty"];
|
||
const weekdayOptions = [
|
||
{ value: 0, label: "周一" },
|
||
{ value: 1, label: "周二" },
|
||
{ value: 2, label: "周三" },
|
||
{ value: 3, label: "周四" },
|
||
{ value: 4, label: "周五" },
|
||
{ value: 5, label: "周六" },
|
||
{ value: 6, label: "周日" },
|
||
];
|
||
|
||
const rulePresentation: Record<string, RulePresentation> = {
|
||
"attendance.on_work_time": {
|
||
name: "上班时间",
|
||
purpose: "员工晚于这个时间打卡,系统会开始判断迟到。",
|
||
},
|
||
"attendance.off_work_time": {
|
||
name: "下班时间",
|
||
purpose: "用于判断下班后是否产生加班工时。",
|
||
},
|
||
"attendance.overtime_round_minutes": {
|
||
name: "加班取整方式",
|
||
purpose: "下班打卡超过下班时间后,加班时长按这个分钟数向下取整。",
|
||
},
|
||
"attendance.standard_daily_hours": {
|
||
name: "每天标准工时",
|
||
purpose: "计时工资和请假折算会按这个工时作为一天。",
|
||
},
|
||
"attendance.minor_late_minutes": {
|
||
name: "轻微迟到范围",
|
||
purpose: "迟到不超过这个分钟数,先按轻微迟到统计。",
|
||
},
|
||
"attendance.minor_late_free_times": {
|
||
name: "轻微迟到免扣次数",
|
||
purpose: "轻微迟到在这个次数以内不扣款,超过后按次扣款。",
|
||
},
|
||
"attendance.late_penalty": {
|
||
name: "迟到扣款金额",
|
||
purpose: "超过轻微迟到免扣次数,或迟到超过轻微范围时,每次扣这个金额。",
|
||
},
|
||
"attendance.missing_card_penalty": {
|
||
name: "缺卡扣款金额",
|
||
purpose: "员工上班或下班缺少打卡记录时,每次扣这个金额。",
|
||
},
|
||
"attendance.weekend_days": {
|
||
name: "周末加班日",
|
||
purpose: "用于识别哪些日期按周末加班规则计算。",
|
||
},
|
||
"attendance.legal_holidays": {
|
||
name: "法定节假日",
|
||
purpose: "用于识别哪些日期按节假日加班规则计算。",
|
||
},
|
||
"attendance.leave_keywords": {
|
||
name: "请假识别词",
|
||
purpose: "钉钉或 Excel 中出现这些文字时,系统会按请假或调休处理。",
|
||
},
|
||
"payroll.default_overtime_rate": {
|
||
name: "工作日加班单价",
|
||
purpose: "员工薪资档案没有维护加班单价时,默认按这个单价计算。",
|
||
},
|
||
"payroll.default_weekend_overtime_rate": {
|
||
name: "周末加班单价",
|
||
purpose: "员工薪资档案没有维护周末加班单价时,默认按这个单价计算。",
|
||
},
|
||
"payroll.default_holiday_overtime_rate": {
|
||
name: "节假日加班单价",
|
||
purpose: "员工薪资档案没有维护节假日加班单价时,默认按这个单价计算。",
|
||
},
|
||
};
|
||
|
||
const groupOptions = computed(() => {
|
||
const values = Array.from(new Set(configs.value.map((item) => item.config_group))).filter(Boolean);
|
||
return values
|
||
.sort((left, right) => {
|
||
const leftIndex = groupOrder.indexOf(left);
|
||
const rightIndex = groupOrder.indexOf(right);
|
||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||
return (leftIndex === -1 ? 99 : leftIndex) - (rightIndex === -1 ? 99 : rightIndex);
|
||
}
|
||
return groupLabel(left).localeCompare(groupLabel(right), "zh-Hans-CN");
|
||
})
|
||
.map((value) => ({ value, label: groupLabel(value) }));
|
||
});
|
||
const visibleConfigs = computed(() => {
|
||
if (!activeGroup.value) {
|
||
return configs.value;
|
||
}
|
||
return configs.value.filter((item) => item.config_group === activeGroup.value);
|
||
});
|
||
const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length);
|
||
const activeGroupLabel = computed(() => (activeGroup.value ? groupLabel(activeGroup.value) : "全部规则"));
|
||
|
||
onMounted(loadData);
|
||
|
||
async function loadData(): Promise<void> {
|
||
loading.value = true;
|
||
errorMessage.value = "";
|
||
try {
|
||
configs.value = await listSalaryConfigs();
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof ApiError ? error.message : "配置加载失败";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function seedDefaults(): Promise<void> {
|
||
loading.value = true;
|
||
errorMessage.value = "";
|
||
successMessage.value = "";
|
||
try {
|
||
const response = await ensureDefaultConfigs();
|
||
successMessage.value = `${response.message},新增 ${response.created_count} 项`;
|
||
await loadData();
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof ApiError ? error.message : "默认配置初始化失败";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveConfig(config: SalaryConfigRecord): Promise<void> {
|
||
savingKey.value = config.config_key;
|
||
errorMessage.value = "";
|
||
successMessage.value = "";
|
||
try {
|
||
const updated = await updateSalaryConfig(config.config_key, {
|
||
config_name: config.config_name,
|
||
config_group: config.config_group,
|
||
config_value: config.config_value,
|
||
value_type: config.value_type,
|
||
is_enabled: config.is_enabled,
|
||
remark: config.remark,
|
||
});
|
||
configs.value = configs.value.map((item) => (item.id === updated.id ? updated : item));
|
||
successMessage.value = "配置已保存";
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof ApiError ? error.message : "配置保存失败";
|
||
} finally {
|
||
savingKey.value = "";
|
||
}
|
||
}
|
||
|
||
function groupLabel(group: string): string {
|
||
return groupLabels[group] || "其他规则";
|
||
}
|
||
|
||
function configTitle(config: SalaryConfigRecord): string {
|
||
return rulePresentation[config.config_key]?.name || config.config_name || "未命名规则";
|
||
}
|
||
|
||
function configPurpose(config: SalaryConfigRecord): string {
|
||
return rulePresentation[config.config_key]?.purpose || config.remark || "影响工资核算时的自动判断规则。";
|
||
}
|
||
|
||
function isLongValue(config: SalaryConfigRecord): boolean {
|
||
return isTextListRule(config) || config.config_value.length > 18;
|
||
}
|
||
|
||
function formatConfigValue(config: SalaryConfigRecord): string {
|
||
const value = config.config_value.trim();
|
||
switch (config.config_key) {
|
||
case "attendance.on_work_time":
|
||
return value ? `${value} 后开始判断迟到` : "未设置上班时间";
|
||
case "attendance.off_work_time":
|
||
return value ? `${value} 后判断加班` : "未设置下班时间";
|
||
case "attendance.overtime_round_minutes":
|
||
return value ? `按 ${value} 分钟向下取整` : "未设置取整方式";
|
||
case "attendance.standard_daily_hours":
|
||
return value ? `每天 ${value} 小时` : "未设置标准工时";
|
||
case "attendance.minor_late_minutes":
|
||
return value ? `${value} 分钟内算轻微迟到` : "未设置轻微迟到范围";
|
||
case "attendance.minor_late_free_times":
|
||
return value ? `每月前 ${value} 次不扣款` : "未设置免扣次数";
|
||
case "attendance.late_penalty":
|
||
return value ? `每次扣 ${formatMoney(value)}` : "未设置迟到扣款";
|
||
case "attendance.missing_card_penalty":
|
||
return value ? `每次扣 ${formatMoney(value)}` : "未设置缺卡扣款";
|
||
case "attendance.weekend_days":
|
||
return `按 ${formatWeekdays(value)} 识别周末`;
|
||
case "attendance.legal_holidays":
|
||
return formatHolidaySummary(value);
|
||
case "attendance.leave_keywords":
|
||
return formatKeywordSummary(value);
|
||
case "payroll.default_overtime_rate":
|
||
case "payroll.default_weekend_overtime_rate":
|
||
case "payroll.default_holiday_overtime_rate":
|
||
return value ? `${formatMoney(value)} / 小时` : "未设置默认单价";
|
||
default:
|
||
return value || "未设置";
|
||
}
|
||
}
|
||
|
||
function formatMoney(value: string): string {
|
||
const amount = Number(value);
|
||
if (!Number.isFinite(amount)) {
|
||
return value;
|
||
}
|
||
return `¥${amount.toFixed(2).replace(/\.00$/, "")}`;
|
||
}
|
||
|
||
function formatWeekdays(value: string): string {
|
||
const weekdayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
|
||
const items = parseArray(value);
|
||
const labels = items
|
||
.map((item) => Number(item))
|
||
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)
|
||
.map((item) => weekdayNames[item]);
|
||
return labels.length ? labels.join("、") : "未设置";
|
||
}
|
||
|
||
function formatHolidaySummary(value: string): string {
|
||
const items = parseArray(value);
|
||
if (!items.length) {
|
||
return "暂未设置节假日";
|
||
}
|
||
return `已设置 ${items.length} 天节假日`;
|
||
}
|
||
|
||
function formatKeywordSummary(value: string): string {
|
||
const items = parseArray(value).map((item) => String(item)).filter(Boolean);
|
||
if (!items.length) {
|
||
return "暂未设置请假识别词";
|
||
}
|
||
const visibleItems = items.slice(0, 5).join("、");
|
||
return items.length > 5 ? `${visibleItems} 等 ${items.length} 个词` : visibleItems;
|
||
}
|
||
|
||
function parseArray(value: string): unknown[] {
|
||
try {
|
||
const parsed = JSON.parse(value || "[]");
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function isWeekdayRule(config: SalaryConfigRecord): boolean {
|
||
return config.config_key === "attendance.weekend_days";
|
||
}
|
||
|
||
function isTextListRule(config: SalaryConfigRecord): boolean {
|
||
return ["attendance.legal_holidays", "attendance.leave_keywords"].includes(config.config_key);
|
||
}
|
||
|
||
function displayEditValue(config: SalaryConfigRecord): string {
|
||
if (config.config_key === "attendance.legal_holidays") {
|
||
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("\n");
|
||
}
|
||
if (config.config_key === "attendance.leave_keywords") {
|
||
return parseArray(config.config_value).map((item) => String(item)).filter(Boolean).join("、");
|
||
}
|
||
return config.config_value;
|
||
}
|
||
|
||
function updateDisplayValue(config: SalaryConfigRecord, event: Event): void {
|
||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||
const value = target.value;
|
||
if (config.config_key === "attendance.legal_holidays" || config.config_key === "attendance.leave_keywords") {
|
||
config.config_value = JSON.stringify(splitListValue(value), null, 0);
|
||
return;
|
||
}
|
||
config.config_value = value;
|
||
}
|
||
|
||
function splitListValue(value: string): string[] {
|
||
return value
|
||
.split(/[\n,,、;;\s]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function textListPlaceholder(config: SalaryConfigRecord): string {
|
||
if (config.config_key === "attendance.legal_holidays") {
|
||
return "一行一个日期,例如 2026-10-01";
|
||
}
|
||
if (config.config_key === "attendance.leave_keywords") {
|
||
return "例如:调休、请假、事假、病假";
|
||
}
|
||
return "请输入当前设置";
|
||
}
|
||
|
||
function isWeekdaySelected(config: SalaryConfigRecord, weekday: number): boolean {
|
||
return parseArray(config.config_value).map((item) => Number(item)).includes(weekday);
|
||
}
|
||
|
||
function toggleWeekday(config: SalaryConfigRecord, weekday: number, event: Event): void {
|
||
const target = event.target as HTMLInputElement;
|
||
const selected = new Set(parseArray(config.config_value).map((item) => Number(item)).filter((item) => Number.isInteger(item)));
|
||
if (target.checked) {
|
||
selected.add(weekday);
|
||
} else {
|
||
selected.delete(weekday);
|
||
}
|
||
config.config_value = JSON.stringify(Array.from(selected).sort((left, right) => left - right));
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="view-stack">
|
||
<header class="page-header">
|
||
<div>
|
||
<h1>薪资规则设置</h1>
|
||
<p>设置考勤、加班、迟到、缺卡和请假等工资核算规则。</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults">
|
||
<Settings2 :size="18" aria-hidden="true" />
|
||
<span>恢复默认规则</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="summary-strip">
|
||
<StatCard title="规则总数" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="可维护规则" />
|
||
<StatCard title="使用中" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="会参与核算" />
|
||
<StatCard title="当前查看" :value="activeGroupLabel" :icon="Settings2" tone="neutral" meta="规则范围" />
|
||
</section>
|
||
|
||
<section class="panel table-panel rules-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h2>规则清单</h2>
|
||
<p>共 {{ visibleConfigs.length }} 项,修改后点击保存生效</p>
|
||
</div>
|
||
<select v-model="activeGroup" class="compact-select">
|
||
<option value="">全部规则</option>
|
||
<option v-for="group in groupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="data-table rules-table">
|
||
<thead>
|
||
<tr>
|
||
<th>规则</th>
|
||
<th>当前设置</th>
|
||
<th>是否使用</th>
|
||
<th>用途说明</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="config in visibleConfigs" :key="config.config_key">
|
||
<td class="rule-name-cell">
|
||
<strong>{{ configTitle(config) }}</strong>
|
||
<span class="rule-group-chip">{{ groupLabel(config.config_group) }}</span>
|
||
</td>
|
||
<td class="rule-value-cell">
|
||
<span class="rule-value-preview">{{ formatConfigValue(config) }}</span>
|
||
<div v-if="isWeekdayRule(config)" class="weekday-toggle-group" :aria-label="`${configTitle(config)}当前设置`">
|
||
<label
|
||
v-for="weekday in weekdayOptions"
|
||
:key="weekday.value"
|
||
class="weekday-toggle"
|
||
:class="{ 'is-selected': isWeekdaySelected(config, weekday.value) }"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:checked="isWeekdaySelected(config, weekday.value)"
|
||
@change="toggleWeekday(config, weekday.value, $event)"
|
||
/>
|
||
<span>{{ weekday.label }}</span>
|
||
</label>
|
||
</div>
|
||
<textarea
|
||
v-else-if="isLongValue(config)"
|
||
:value="displayEditValue(config)"
|
||
class="table-textarea rule-value-input"
|
||
:placeholder="textListPlaceholder(config)"
|
||
:aria-label="`${configTitle(config)}当前设置`"
|
||
@input="updateDisplayValue(config, $event)"
|
||
/>
|
||
<input
|
||
v-else
|
||
:value="displayEditValue(config)"
|
||
class="table-input rule-value-input"
|
||
type="text"
|
||
:aria-label="`${configTitle(config)}当前设置`"
|
||
@input="updateDisplayValue(config, $event)"
|
||
/>
|
||
</td>
|
||
<td>
|
||
<label class="checkbox-line">
|
||
<input v-model="config.is_enabled" type="checkbox" />
|
||
<span>{{ config.is_enabled ? "使用" : "不用" }}</span>
|
||
</label>
|
||
</td>
|
||
<td class="rule-purpose">
|
||
<p>{{ configPurpose(config) }}</p>
|
||
</td>
|
||
<td>
|
||
<button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)">
|
||
<Loader2 v-if="savingKey === config.config_key" class="spin" :size="15" aria-hidden="true" />
|
||
<Save v-else :size="15" aria-hidden="true" />
|
||
<span>保存</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-if="!visibleConfigs.length && !loading" class="empty-state">
|
||
<p>暂无配置项</p>
|
||
</div>
|
||
</section>
|
||
|
||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||
</div>
|
||
</template>
|