financial_system/frontend/src/views/ConfigCenterView.vue
2026-06-29 14:59:49 +08:00

440 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup lang="ts">
import { 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>