初步提交

This commit is contained in:
焦龙言
2026-06-18 13:21:20 +08:00
commit 99c7ba776f
133 changed files with 20680 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
<script setup lang="ts">
import {
BadgeDollarSign,
HandCoins,
Loader2,
Pencil,
RefreshCw,
Save,
Search,
TimerReset,
WalletCards,
} from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue";
import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const keyword = ref("");
const form = reactive<SalaryProfileRequest>({
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
const salaryModeOptions = [
{ value: "monthly", label: "包月" },
{ value: "hourly", label: "计时" },
{ value: "piecework", label: "计件" },
{ value: "probation", label: "试用期" },
];
const totalBaseSalary = computed(() => profiles.value.reduce((total, item) => total + Number(item.base_salary || 0), 0));
const totalCommission = computed(() => profiles.value.reduce((total, item) => total + Number(item.commission_amount || 0), 0));
const avgOvertimeRate = computed(() => {
if (!profiles.value.length) {
return 0;
}
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
});
onMounted(loadData);
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [employeeRows, profileRows] = await Promise.all([
listEmployees({ employment_status: "active" }),
listSalaryProfiles(keyword.value.trim()),
]);
employees.value = employeeRows;
profiles.value = profileRows;
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案加载失败";
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
if (!form.employee_id) {
errorMessage.value = "请选择关联员工";
return;
}
saving.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const profile = await saveSalaryProfile({
employee_id: Number(form.employee_id),
salary_mode: form.salary_mode,
base_salary: Number(form.base_salary || 0),
hourly_rate: Number(form.hourly_rate || 0),
piece_quantity: Number(form.piece_quantity || 0),
piece_unit_price: Number(form.piece_unit_price || 0),
probation_type: form.probation_type,
probation_ratio: Number(form.probation_ratio || 1),
probation_salary: Number(form.probation_salary || 0),
commission_amount: Number(form.commission_amount || 0),
overtime_rate: Number(form.overtime_rate || 0),
weekend_overtime_rate: Number(form.weekend_overtime_rate || 0),
holiday_overtime_rate: Number(form.holiday_overtime_rate || 0),
effective_month: form.effective_month || "",
remark: form.remark.trim(),
});
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
successMessage.value = "薪资档案已保存";
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
} finally {
saving.value = false;
}
}
function editProfile(profile: SalaryProfileRecord): void {
Object.assign(form, {
employee_id: profile.employee_id,
salary_mode: profile.salary_mode,
base_salary: profile.base_salary,
hourly_rate: profile.hourly_rate,
piece_quantity: profile.piece_quantity,
piece_unit_price: profile.piece_unit_price,
probation_type: profile.probation_type,
probation_ratio: profile.probation_ratio,
probation_salary: profile.probation_salary,
commission_amount: profile.commission_amount,
overtime_rate: profile.overtime_rate,
weekend_overtime_rate: profile.weekend_overtime_rate,
holiday_overtime_rate: profile.holiday_overtime_rate,
effective_month: profile.effective_month,
remark: profile.remark,
});
successMessage.value = "";
errorMessage.value = "";
}
function resetForm(): void {
Object.assign(form, {
employee_id: 0,
salary_mode: "monthly",
base_salary: 0,
hourly_rate: 0,
piece_quantity: 0,
piece_unit_price: 0,
probation_type: "ratio",
probation_ratio: 1,
probation_salary: 0,
commission_amount: 0,
overtime_rate: 0,
weekend_overtime_rate: 0,
holiday_overtime_rate: 0,
effective_month: "",
remark: "",
});
}
function salaryModeLabel(value: string): string {
return salaryModeOptions.find((item) => item.value === value)?.label || value;
}
function profileRuleText(profile: SalaryProfileRecord): string {
if (profile.salary_mode === "hourly") {
return `${money(profile.hourly_rate)}/h`;
}
if (profile.salary_mode === "piecework") {
return `${profile.piece_quantity} × ${money(profile.piece_unit_price)}`;
}
if (profile.salary_mode === "probation") {
return profile.probation_type === "fixed"
? `固定 ${money(profile.probation_salary)}`
: `${Math.round(profile.probation_ratio * 100)}%`;
}
return money(profile.base_salary);
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
</script>
<template>
<div class="view-stack">
<header class="page-header">
<div>
<h1>薪资维护</h1>
<p>关联员工维护包月、计时、计件、试用期规则和独立加班费单价。</p>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadData">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header>
<section class="stats-grid">
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" />
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" />
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" />
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>查询薪资档案</h2>
<p>按员工编号、姓名、部门或岗位查询</p>
</div>
</div>
<form class="search-form" @submit.prevent="loadData">
<label class="field">
<span>关键字</span>
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
</label>
<button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span>
</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>维护薪资档案</h2>
<p>一名员工维护一条当前薪资档案</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid salary-profile-form" @submit.prevent="submit">
<label class="field">
<span>关联员工</span>
<select v-model.number="form.employee_id" required>
<option :value="0">请选择员工</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }} / {{ employee.employee_no }}
</option>
</select>
</label>
<label class="field">
<span>薪资模式</span>
<select v-model="form.salary_mode">
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field">
<span>基础工资/底薪</span>
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计时小时单价</span>
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件数量</span>
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>计件单价</span>
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期方式</span>
<select v-model="form.probation_type">
<option value="ratio">固定比例</option>
<option value="fixed">固定金额</option>
</select>
</label>
<label class="field">
<span>试用期比例</span>
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>试用期固定金额</span>
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>默认提成</span>
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>工作日加班单价</span>
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>周末加班单价</span>
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>节假日加班单价</span>
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>生效月份</span>
<input v-model="form.effective_month" type="month" />
</label>
<label class="field span-row">
<span>备注</span>
<textarea v-model="form.remark" />
</label>
<div class="form-actions align-right span-row">
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
</button>
</div>
</form>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
</section>
<section class="panel table-panel">
<div class="panel-header">
<div>
<h2>薪资档案列表</h2>
<p>{{ profiles.length }} 条薪资档案</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>部门/岗位</th>
<th>薪资模式</th>
<th>计算规则</th>
<th>默认提成</th>
<th>加班单价</th>
<th>生效月份</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td>
<strong>{{ profile.employee.name }}</strong>
<span>{{ profile.employee.employee_no }}</span>
</td>
<td>
<strong>{{ profile.employee.department || "-" }}</strong>
<span>{{ profile.employee.position || "-" }}</span>
</td>
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
<td class="mono strong">{{ profileRuleText(profile) }}</td>
<td class="mono">{{ money(profile.commission_amount) }}</td>
<td class="mono">
工作日 {{ money(profile.overtime_rate) }}/h
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
</td>
<td>{{ profile.effective_month || "长期" }}</td>
<td class="note-cell">{{ profile.remark || "-" }}</td>
<td>
<button class="link-button" type="button" @click="editProfile(profile)">
<Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!profiles.length && !loading" class="empty-state">
<p>暂无薪资档案</p>
</div>
</section>
</div>
</template>