更新前端样式

This commit is contained in:
焦龙言 2026-06-29 14:59:49 +08:00
parent d6f858b363
commit 960d2aa028
42 changed files with 4223 additions and 638 deletions

View File

@ -1,4 +1,4 @@
FROM node:20-alpine AS build FROM docker.m.daocloud.io/library/node:20-alpine AS build
WORKDIR /app WORKDIR /app
@ -10,7 +10,7 @@ ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM docker.m.daocloud.io/library/nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

53
frontend/nginx.conf Normal file
View File

@ -0,0 +1,53 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /docs {
proxy_pass http://backend:8000/docs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://backend:8000/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "1.18.0", "@lucide/vue": "1.18.0",

View File

@ -1,7 +1,9 @@
import { apiFetch } from "./http"; import { apiFetch } from "./http";
import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types"; import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types";
export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise<EmployeeRecord[]> { export function listEmployees(
params: { keyword?: string; employee_keyword?: string; organization_keyword?: string; employment_status?: string } = {},
): Promise<EmployeeRecord[]> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== "") { if (value !== undefined && value !== null && String(value).trim() !== "") {
@ -13,7 +15,14 @@ export function listEmployees(params: { keyword?: string; employment_status?: st
} }
export function listEmployeePage( export function listEmployeePage(
params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {}, params: {
page?: number;
page_size?: number;
keyword?: string;
employee_keyword?: string;
organization_keyword?: string;
employment_status?: string;
} = {},
): Promise<EmployeeListResponse> { ): Promise<EmployeeListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {

View File

@ -10,24 +10,31 @@ export function getMonthlyRun(runId: number): Promise<MonthlyPayrollDetailRespon
return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`); return apiFetch<MonthlyPayrollDetailResponse>(`/api/payroll/monthly/runs/${runId}`);
} }
export function calculateMonthlyFromExcel(file: File, salaryMonth: string): Promise<MonthlyPayrollDetailResponse> { export function calculateMonthlyFromExcel(
file: File,
salaryMonth: string,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("salary_month", salaryMonth); formData.append("salary_month", salaryMonth);
formData.append("export_excel", "true"); formData.append("export_excel", String(exportExcel));
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", { return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/excel", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
} }
export function calculateMonthlyFromDingTalk(salaryMonth: string): Promise<MonthlyPayrollDetailResponse> { export function calculateMonthlyFromDingTalk(
salaryMonth: string,
exportExcel = true,
): Promise<MonthlyPayrollDetailResponse> {
return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", { return apiFetch<MonthlyPayrollDetailResponse>("/api/payroll/monthly/dingtalk", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
salary_month: salaryMonth, salary_month: salaryMonth,
source_type: "dingtalk", source_type: "dingtalk",
export_excel: true, export_excel: exportExcel,
}), }),
}); });
} }

View File

@ -1,7 +1,6 @@
import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http"; import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http";
import type { import type {
DingTalkPayrollRequest, DingTalkPayrollRequest,
PayrollJobResponse,
PayrollResponse, PayrollResponse,
RecentPayrollJob, RecentPayrollJob,
} from "./types"; } from "./types";
@ -33,10 +32,6 @@ export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise<
}); });
} }
export function getPayrollJob(jobId: string): Promise<PayrollJobResponse> {
return apiFetch<PayrollJobResponse>(`/api/payroll/jobs/${encodeURIComponent(jobId)}`);
}
export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string { export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string {
if (downloadUrl) { if (downloadUrl) {
return buildApiUrl(downloadUrl); return buildApiUrl(downloadUrl);

View File

@ -106,19 +106,6 @@ export interface PayrollResponse {
results: PayrollResult[]; results: PayrollResult[];
} }
export interface PayrollJobResponse {
job_id: string;
source_type: string;
status: string;
input_file: string | null;
output_file: string | null;
employee_count: number;
error_message: string | null;
created_at: string;
updated_at: string;
results: PayrollResult[];
}
export interface DingTalkPayrollRequest { export interface DingTalkPayrollRequest {
user_ids: string[]; user_ids: string[];
start_date: string; start_date: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PayrollResult } from "../api/types"; import { computed, ref, watch } from "vue";
defineProps<{ import type { PayrollException, PayrollResult } from "../api/types";
const props = withDefaults(defineProps<{
results: PayrollResult[]; results: PayrollResult[];
}>(); exceptions?: PayrollException[];
embedded?: boolean;
}>(), {
embedded: false,
exceptions: () => [],
});
const keyword = ref("");
const departmentFilter = ref("all");
const statusFilter = ref("all");
const anomalyFilter = ref("all");
const selectedResult = ref<PayrollResult | null>(null);
const exceptionMap = computed(() => {
const map = new Map<string, PayrollException[]>();
for (const item of props.exceptions) {
const keys = [item.employee_name, item.employee_no].filter(Boolean);
for (const key of keys) {
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
}
}
return map;
});
const departmentOptions = computed(() =>
Array.from(new Set(props.results.map((item) => item.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredResults = computed(() => {
const query = keyword.value.trim().toLowerCase();
return props.results.filter((item) => {
const matchKeyword =
!query ||
[item.name, item.department, item.position, item.attendance_group, item.note]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
const matchDepartment = departmentFilter.value === "all" || item.department === departmentFilter.value;
const hasWarning = hasAnomaly(item);
const matchStatus =
statusFilter.value === "all" ||
(statusFilter.value === "normal" && !hasWarning) ||
(statusFilter.value === "warning" && hasWarning);
const matchAnomaly = anomalyFilter.value === "all" || anomalyTypes(item).includes(anomalyFilter.value);
return matchKeyword && matchDepartment && matchStatus && matchAnomaly;
});
});
watch(
filteredResults,
(items) => {
if (!items.length) {
selectedResult.value = null;
return;
}
if (!selectedResult.value || !items.includes(selectedResult.value)) {
selectedResult.value = items[0];
}
},
{ immediate: true },
);
function money(value: number | null): string { function money(value: number | null): string {
if (value === null || Number.isNaN(value)) { if (value === null || Number.isNaN(value)) {
@ -29,19 +92,235 @@ function modeLabel(value: string): string {
}; };
return labels[value] || value; return labels[value] || value;
} }
function exceptionItems(item: PayrollResult): PayrollException[] {
return [...(exceptionMap.value.get(item.name) || []), ...(exceptionMap.value.get(item.attendance_group) || [])];
}
function anomalyTypes(item: PayrollResult): string[] {
const types = new Set<string>();
for (const exception of exceptionItems(item)) {
if (exception.exception_type) {
types.add(exception.exception_type);
}
}
if (item.note?.includes("未配置薪资档案")) {
types.add("missing_salary_profile");
}
if (item.penalized_late_count > 0 || item.late_deduction > 0) {
types.add("late_penalty");
}
if (item.missing_card_count > 0 || item.missing_card_deduction > 0) {
types.add("missing_card");
}
if (item.remaining_overtime_hours < 0) {
types.add("negative_remaining_overtime");
}
if ((item.net_salary || 0) <= 0) {
types.add("salary_calculation_failed");
}
return Array.from(types);
}
function hasAnomaly(item: PayrollResult): boolean {
return anomalyTypes(item).length > 0;
}
function anomalyLabel(item: PayrollResult): string {
const types = anomalyTypes(item);
if (!types.length) {
return "正常";
}
const labels: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺薪资档案",
negative_remaining_overtime: "加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺考勤",
salary_calculation_failed: "工资异常",
};
return labels[types[0]] || "异常";
}
function anomalyClass(item: PayrollResult): string {
return hasAnomaly(item) ? "warning" : "green";
}
function anomalySummary(item: PayrollResult): string {
const parts = [
item.penalized_late_count > 0 ? `迟到 ${item.penalized_late_count}` : "",
item.missing_card_count > 0 ? `缺卡 ${item.missing_card_count}` : "",
item.remaining_overtime_hours < 0 ? "剩余加班不足" : "",
item.note?.includes("未配置薪资档案") ? "缺薪资档案" : "",
].filter(Boolean);
return parts.join(" / ");
}
function selectResult(item: PayrollResult): void {
selectedResult.value = item;
}
function closeDetail(): void {
selectedResult.value = null;
}
</script> </script>
<template> <template>
<section class="panel table-panel"> <section :class="embedded ? 'salary-detail-table-shell' : 'panel table-panel'">
<div class="panel-header"> <div v-if="!embedded" class="panel-header">
<div> <div>
<h2>员工工资结果</h2> <h2>员工工资结果</h2>
<p> {{ results.length }} </p> <p> {{ results.length }} </p>
</div> </div>
</div> </div>
<div v-if="results.length" class="table-wrap"> <template v-if="embedded">
<table class="data-table"> <div class="salary-summary-toolbar">
<label class="field salary-summary-search">
<span>搜索</span>
<input v-model="keyword" type="search" placeholder="员工、部门、岗位" />
</label>
<label class="field">
<span>部门</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>状态</span>
<select v-model="statusFilter">
<option value="all">全部状态</option>
<option value="normal">正常</option>
<option value="warning">有异常</option>
</select>
</label>
<label class="field">
<span>异常</span>
<select v-model="anomalyFilter">
<option value="all">全部异常</option>
<option value="late_penalty">迟到扣款</option>
<option value="missing_card">缺卡</option>
<option value="negative_remaining_overtime">加班不足</option>
<option value="missing_salary_profile">缺薪资档案</option>
<option value="salary_calculation_failed">工资异常</option>
</select>
</label>
</div>
<div v-if="filteredResults.length" class="salary-summary-layout">
<div class="table-wrap">
<table class="data-table salary-summary-table">
<thead>
<tr>
<th>姓名</th>
<th>部门/岗位</th>
<th>应发工资</th>
<th>实发工资</th>
<th>异常标记</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in filteredResults"
:key="`${item.name}-${item.department}-${item.position}`"
:class="{ 'is-selected': selectedResult === item, 'has-warning': hasAnomaly(item) }"
@click="selectResult(item)"
>
<td>
<button class="link-button salary-name-button" type="button" @click.stop="selectResult(item)">
<strong>{{ item.name }}</strong>
</button>
<span>{{ item.attendance_group || "默认考勤组" }}</span>
</td>
<td>
<strong>{{ item.department || "-" }}</strong>
<span>{{ item.position || "-" }}</span>
</td>
<td class="mono">{{ money(item.gross_salary) }}</td>
<td class="mono strong">{{ money(item.net_salary) }}</td>
<td>
<span class="status-chip" :class="anomalyClass(item)">{{ anomalyLabel(item) }}</span>
<small v-if="anomalySummary(item)" class="salary-anomaly-note">{{ anomalySummary(item) }}</small>
</td>
<td>
<button class="link-button" type="button" @click.stop="selectResult(item)">查看构成</button>
</td>
</tr>
</tbody>
</table>
</div>
<aside v-if="selectedResult" class="salary-detail-drawer" aria-label="员工薪资构成">
<div class="salary-detail-drawer-header">
<div>
<span>薪资构成</span>
<h3>{{ selectedResult.name }}</h3>
<p>{{ selectedResult.department || "-" }} · {{ selectedResult.position || "-" }}</p>
</div>
<button class="link-button" type="button" @click="closeDetail">关闭</button>
</div>
<div>
<span>薪资模式</span>
<strong>{{ modeLabel(selectedResult.salary_mode) }}</strong>
</div>
<div>
<span>出勤/缺勤</span>
<strong>{{ selectedResult.attendance_days }} / {{ selectedResult.absence_days }}</strong>
</div>
<div>
<span>实际工时</span>
<strong>{{ hours(selectedResult.actual_work_hours) }}</strong>
</div>
<div>
<span>剩余加班</span>
<strong>{{ hours(selectedResult.remaining_overtime_hours) }}</strong>
</div>
<div>
<span>请假工时</span>
<strong>{{ hours(selectedResult.leave_hours) }}</strong>
</div>
<div>
<span>迟到扣款</span>
<strong>{{ money(selectedResult.late_deduction) }}</strong>
</div>
<div>
<span>缺卡扣款</span>
<strong>{{ money(selectedResult.missing_card_deduction) }}</strong>
</div>
<div>
<span>提成</span>
<strong>{{ money(selectedResult.commission_amount) }}</strong>
</div>
<div>
<span>基础工资</span>
<strong>{{ money(selectedResult.base_pay) }}</strong>
</div>
<div>
<span>加班费</span>
<strong>{{ money(selectedResult.overtime_pay) }}</strong>
</div>
<div>
<span>应发工资</span>
<strong>{{ money(selectedResult.gross_salary) }}</strong>
</div>
<div>
<span>实发工资</span>
<strong>{{ money(selectedResult.net_salary) }}</strong>
</div>
<p class="salary-detail-note">{{ selectedResult.note || anomalySummary(selectedResult) || "无备注" }}</p>
</aside>
</div>
<div v-else class="empty-state">
<p>没有匹配当前筛选条件的工资结果</p>
</div>
</template>
<div v-else-if="results.length" class="table-wrap">
<table class="data-table salary-detail-table">
<thead> <thead>
<tr> <tr>
<th>姓名</th> <th>姓名</th>

View File

@ -12,6 +12,7 @@ import {
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ApiError, buildApiUrl } from "../api/http"; import { ApiError, buildApiUrl } from "../api/http";
import AppearancePanel from "./AppearancePanel.vue"; import AppearancePanel from "./AppearancePanel.vue";
@ -22,6 +23,7 @@ defineEmits<{
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute();
const showThemePanel = ref(false); const showThemePanel = ref(false);
const showProfileModal = ref(false); const showProfileModal = ref(false);
const savingProfile = ref(false); const savingProfile = ref(false);
@ -50,6 +52,7 @@ const initials = computed(() => {
const source = auth.displayName || auth.user?.username || "U"; const source = auth.displayName || auth.user?.username || "U";
return source.slice(0, 2).toUpperCase(); return source.slice(0, 2).toUpperCase();
}); });
const showGlobalSearch = computed(() => route.path !== "/attendance/realtime");
const avatarSrc = computed(() => { const avatarSrc = computed(() => {
const avatarUrl = auth.user?.avatar_url || ""; const avatarUrl = auth.user?.avatar_url || "";
@ -145,7 +148,7 @@ async function handleAvatarChange(event: Event): Promise<void> {
<button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')"> <button class="icon-button mobile-menu" type="button" title="打开菜单" @click="$emit('toggle-sidebar')">
<Menu :size="22" aria-hidden="true" /> <Menu :size="22" aria-hidden="true" />
</button> </button>
<div class="global-search"> <div v-if="showGlobalSearch" class="global-search">
<Search :size="20" aria-hidden="true" /> <Search :size="20" aria-hidden="true" />
<input type="search" placeholder="搜索员工、任务或部门..." /> <input type="search" placeholder="搜索员工、任务或部门..." />
</div> </div>

View File

@ -18,7 +18,6 @@ const DASHBOARD_MENU: SidebarMenuInput = {
const MENU_LABELS: Record<string, string> = { const MENU_LABELS: Record<string, string> = {
monthly_payroll: "工资核算", monthly_payroll: "工资核算",
jobs: "计算记录",
commissions: "提成录入", commissions: "提成录入",
salary_profiles: "薪资档案", salary_profiles: "薪资档案",
reports: "工资报表", reports: "工资报表",
@ -37,7 +36,7 @@ const SECTION_DEFINITIONS: Array<{
{ {
code: "monthly", code: "monthly",
title: "月度核算", title: "月度核算",
itemCodes: ["monthly_payroll", "jobs", "commissions"], itemCodes: ["monthly_payroll", "commissions"],
}, },
{ {
code: "employee", code: "employee",

View File

@ -6,12 +6,10 @@ import CommissionsView from "../views/CommissionsView.vue";
import ConfigCenterView from "../views/ConfigCenterView.vue"; import ConfigCenterView from "../views/ConfigCenterView.vue";
import DashboardView from "../views/DashboardView.vue"; import DashboardView from "../views/DashboardView.vue";
import EmployeesView from "../views/EmployeesView.vue"; import EmployeesView from "../views/EmployeesView.vue";
import JobsView from "../views/JobsView.vue";
import LoginView from "../views/LoginView.vue"; import LoginView from "../views/LoginView.vue";
import MonthlyPayrollView from "../views/MonthlyPayrollView.vue"; import MonthlyPayrollView from "../views/MonthlyPayrollView.vue";
import OperationLogsView from "../views/OperationLogsView.vue"; import OperationLogsView from "../views/OperationLogsView.vue";
import OrganizationView from "../views/OrganizationView.vue"; import OrganizationView from "../views/OrganizationView.vue";
import PayrollView from "../views/PayrollView.vue";
import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue"; import RealtimeAttendanceView from "../views/RealtimeAttendanceView.vue";
import ReportsView from "../views/ReportsView.vue"; import ReportsView from "../views/ReportsView.vue";
import SalaryProfilesView from "../views/SalaryProfilesView.vue"; import SalaryProfilesView from "../views/SalaryProfilesView.vue";
@ -61,13 +59,12 @@ const router = createRouter({
{ {
path: "payroll", path: "payroll",
name: "payroll", name: "payroll",
component: PayrollView, redirect: "/payroll/monthly",
meta: { permission: "payroll:excel:calculate" },
}, },
{ {
path: "payroll/jobs", path: "payroll/jobs",
name: "jobs", name: "jobs",
component: JobsView, redirect: "/payroll/monthly",
meta: { permission: "payroll:job:view" }, meta: { permission: "payroll:job:view" },
}, },
{ {
@ -131,7 +128,12 @@ router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
if (!to.meta.public && !auth.loaded) { if (!to.meta.public && !auth.loaded) {
await auth.loadSession(); try {
await auth.loadSession();
} catch {
auth.logout();
return { path: "/login", query: { redirect: to.fullPath } };
}
} }
if (to.meta.public) { if (to.meta.public) {
@ -157,11 +159,8 @@ function firstAllowedPath(permissions: string[]): string {
if (permissions.includes("monthly_payroll:view")) { if (permissions.includes("monthly_payroll:view")) {
return "/payroll/monthly"; return "/payroll/monthly";
} }
if (permissions.includes("payroll:excel:calculate")) {
return "/payroll";
}
if (permissions.includes("payroll:job:view")) { if (permissions.includes("payroll:job:view")) {
return "/payroll/jobs"; return "/payroll/monthly";
} }
if (permissions.includes("commission:view")) { if (permissions.includes("commission:view")) {
return "/payroll/commissions"; return "/payroll/commissions";

View File

@ -0,0 +1,54 @@
export interface ExcelMonthInfo {
month: string;
rangeLabel: string;
}
const SALARY_MONTH_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
export function isSalaryMonth(value: string): boolean {
return SALARY_MONTH_RE.test(value);
}
export function formatMonthRange(month: string): string {
if (!isSalaryMonth(month)) {
return "请选择核算月份";
}
const [yearText, monthText] = month.split("-");
const year = Number(yearText);
const monthIndex = Number(monthText) - 1;
const start = new Date(year, monthIndex, 1);
const end = new Date(year, monthIndex + 1, 0);
return `${formatDate(start)}${formatDate(end)}`;
}
export function extractMonthFromExcelFilename(filename: string): ExcelMonthInfo | null {
const compactDates = [...filename.matchAll(/((?:19|20)\d{2})(0[1-9]|1[0-2])([0-3]\d)/g)];
if (compactDates.length >= 2) {
const first = compactDates[0];
const last = compactDates[compactDates.length - 1];
const month = `${first[1]}-${first[2]}`;
return {
month,
rangeLabel: `${first[1]}/${first[2]}/${first[3]}${last[1]}/${last[2]}/${last[3]}`,
};
}
const dashedMonth = filename.match(/((?:19|20)\d{2})[-_/年](0[1-9]|1[0-2])/);
if (dashedMonth) {
const month = `${dashedMonth[1]}-${dashedMonth[2]}`;
return {
month,
rangeLabel: formatMonthRange(month),
};
}
return null;
}
function formatDate(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}/${month}/${day}`;
}

View File

@ -5,11 +5,11 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Save, Save,
Search, Search,
Trash2, Trash2,
Upload, Upload,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -23,6 +23,7 @@ import {
import { listEmployees } from "../api/employees"; import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types"; import type { CommissionRecord, CommissionRecordRequest, EmployeeRecord } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
@ -33,6 +34,7 @@ const importing = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const showCommissionModal = ref(false);
const page = ref(1); const page = ref(1);
const pageSize = ref(10); const pageSize = ref(10);
const total = ref(0); const total = ref(0);
@ -97,8 +99,9 @@ async function submit(): Promise<void> {
await createCommission(normalizedForm()); await createCommission(normalizedForm());
successMessage.value = "提成记录已新增"; successMessage.value = "提成记录已新增";
} }
resetForm();
await loadData(); await loadData();
showCommissionModal.value = false;
resetForm();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败"; errorMessage.value = error instanceof ApiError ? error.message : "提成记录保存失败";
} finally { } finally {
@ -141,6 +144,8 @@ async function handleImport(event: Event): Promise<void> {
} }
function editRecord(record: CommissionRecord): void { function editRecord(record: CommissionRecord): void {
errorMessage.value = "";
successMessage.value = "";
editingId.value = record.id; editingId.value = record.id;
Object.assign(form, { Object.assign(form, {
employee_id: record.employee_id, employee_id: record.employee_id,
@ -151,6 +156,7 @@ function editRecord(record: CommissionRecord): void {
business_ref: record.business_ref, business_ref: record.business_ref,
remark: record.remark, remark: record.remark,
}); });
showCommissionModal.value = true;
} }
function resetForm(): void { function resetForm(): void {
@ -166,6 +172,19 @@ function resetForm(): void {
}); });
} }
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showCommissionModal.value = true;
}
function closeCommissionModal(): void {
showCommissionModal.value = false;
errorMessage.value = "";
resetForm();
}
function searchRecords(): void { function searchRecords(): void {
page.value = 1; page.value = 1;
loadData(); loadData();
@ -199,6 +218,15 @@ function normalizedForm(): CommissionRecordRequest {
}; };
} }
function sourceTypeLabel(sourceType: string): string {
const labels: Record<string, string> = {
manual: "手工录入",
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return labels[sourceType] || sourceType || "-";
}
function money(value: number): string { function money(value: number): string {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat("zh-CN", {
style: "currency", style: "currency",
@ -209,7 +237,7 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack commissions-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>提成管理</h1> <h1>提成管理</h1>
@ -221,88 +249,39 @@ function money(value: number): string {
<span>{{ importing ? "导入中" : "导入Excel" }}</span> <span>{{ importing ? "导入中" : "导入Excel" }}</span>
<input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" /> <input accept=".xlsx,.xls" type="file" :disabled="importing" @change="handleImport" />
</label> </label>
<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>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip commission-summary-strip">
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" /> <StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" /> <StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" /> <StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showCommissionModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="secondary-button" type="button" @click="resetForm">清空</button>
</div>
<form class="form-grid" @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>
<input v-model="form.commission_month" type="month" required />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</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-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</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"> <section class="panel table-panel commission-list-panel">
<div class="panel-header"> <div class="panel-header commission-list-header">
<div> <div>
<h2>提成列表</h2> <h2>提成列表</h2>
<p> {{ page }} / {{ totalPages }} {{ total }} </p> <p> {{ page }} / {{ totalPages }} {{ total }} </p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增提成</span>
</button>
</div> </div>
<form class="table-filter-bar" @submit.prevent="searchRecords"> <form class="table-filter-bar commission-filter-bar" @submit.prevent="searchRecords">
<label class="field"> <label class="field commission-keyword-field">
<span>关键字</span> <span>关键字</span>
<input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" /> <input v-model="filters.keyword" type="search" placeholder="员工、类型或业务单号" />
</label> </label>
<label class="field"> <label class="field commission-month-field">
<span>月份</span> <span>月份</span>
<input v-model="filters.commission_month" type="month" /> <DatePicker v-model="filters.commission_month" mode="month" placeholder="请选择月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button commission-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@ -331,18 +310,18 @@ function money(value: number): string {
<td class="mono">{{ record.commission_month }}</td> <td class="mono">{{ record.commission_month }}</td>
<td>{{ record.commission_type }}</td> <td>{{ record.commission_type }}</td>
<td class="mono strong">{{ money(record.amount) }}</td> <td class="mono strong">{{ money(record.amount) }}</td>
<td><span class="status-chip neutral">{{ record.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceTypeLabel(record.source_type) }}</span></td>
<td>{{ record.business_ref || "-" }}</td> <td>{{ record.business_ref || "-" }}</td>
<td class="note-cell">{{ record.remark || "-" }}</td> <td class="note-cell">{{ record.remark || "-" }}</td>
<td> <td class="commission-action-cell">
<button class="link-button" type="button" @click="editRecord(record)"> <div class="row-action-group commission-row-actions">
<button class="icon-button table-action-icon" type="button" title="编辑提成" aria-label="编辑提成" @click="editRecord(record)">
<Pencil :size="15" aria-hidden="true" /> <Pencil :size="15" aria-hidden="true" />
<span>编辑</span>
</button> </button>
<button class="link-button danger" type="button" @click="removeRecord(record)"> <button class="icon-button table-action-icon danger" type="button" title="删除提成" aria-label="删除提成" @click="removeRecord(record)">
<Trash2 :size="15" aria-hidden="true" /> <Trash2 :size="15" aria-hidden="true" />
<span>删除</span>
</button> </button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -356,5 +335,66 @@ function money(value: number): string {
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button> <button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">下一页</button>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showCommissionModal" class="modal-backdrop commission-modal-backdrop" @click.self="closeCommissionModal">
<section class="profile-modal commission-modal" aria-label="提成维护">
<div class="modal-header">
<div>
<h2>{{ editingId ? "编辑提成" : "新增提成" }}</h2>
<p>按员工和月份维护提成金额</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeCommissionModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="commission-form" @submit.prevent="submit">
<div class="commission-form-grid">
<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>
<DatePicker v-model="form.commission_month" mode="month" placeholder="请选择月份" />
</label>
<label class="field">
<span>提成类型</span>
<input v-model="form.commission_type" type="text" placeholder="销售提成、项目奖金..." />
</label>
<label class="field">
<span>金额</span>
<input v-model.number="form.amount" min="0" step="0.01" type="number" />
</label>
<label class="field commission-business-field">
<span>业务单号</span>
<input v-model="form.business_ref" type="text" />
</label>
<label class="field commission-remark-field">
<span>备注</span>
<textarea v-model="form.remark" rows="2" />
</label>
<p v-if="errorMessage" class="form-error commission-modal-message">{{ errorMessage }}</p>
<div class="form-actions align-right commission-form-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeCommissionModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Save v-else-if="editingId" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "保存中" : editingId ? "保存修改" : "新增提成" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Loader2, RefreshCw, Save, Settings2, SlidersHorizontal } from "@lucide/vue"; import { Loader2, Save, Settings2, SlidersHorizontal } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs"; import { ensureDefaultConfigs, listSalaryConfigs, updateSalaryConfig } from "../api/configs";
@ -14,7 +14,102 @@ const savingKey = ref("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const groups = computed(() => Array.from(new Set(configs.value.map((item) => item.config_group)))); 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(() => { const visibleConfigs = computed(() => {
if (!activeGroup.value) { if (!activeGroup.value) {
return configs.value; return configs.value;
@ -22,6 +117,7 @@ const visibleConfigs = computed(() => {
return configs.value.filter((item) => item.config_group === activeGroup.value); return configs.value.filter((item) => item.config_group === activeGroup.value);
}); });
const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length); const enabledCount = computed(() => configs.value.filter((item) => item.is_enabled).length);
const activeGroupLabel = computed(() => (activeGroup.value ? groupLabel(activeGroup.value) : "全部规则"));
onMounted(loadData); onMounted(loadData);
@ -73,87 +169,253 @@ async function saveConfig(config: SalaryConfigRecord): Promise<void> {
savingKey.value = ""; 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> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>规则配置</h1> <h1>薪资规则设</h1>
<p>维护考勤扣款加班取整加班单价和请假识别规则</p> <p>设置考勤加班迟到缺卡和请假等工资核算规则</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults"> <button class="secondary-button" type="button" :disabled="loading" @click="seedDefaults">
<Settings2 :size="18" aria-hidden="true" /> <Settings2 :size="18" aria-hidden="true" />
<span>补齐默认项</span> <span>恢复默认规则</span>
</button>
<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> </button>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
<StatCard title="配置项" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="全部规则" /> <StatCard title="规则总数" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="可维护规则" />
<StatCard title="启用项" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="参与计算" /> <StatCard title="使用中" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="会参与核算" />
<StatCard title="当前分组" :value="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" /> <StatCard title="当前查看" :value="activeGroupLabel" :icon="Settings2" tone="neutral" meta="规则范围" />
</section> </section>
<section class="panel table-panel"> <section class="panel table-panel rules-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>配置列表</h2> <h2>规则清单</h2>
<p>{{ visibleConfigs.length }} </p> <p> {{ visibleConfigs.length }} 修改后点击保存生效</p>
</div> </div>
<select v-model="activeGroup" class="compact-select"> <select v-model="activeGroup" class="compact-select">
<option value="">全部分组</option> <option value="">全部规则</option>
<option v-for="group in groups" :key="group" :value="group">{{ group }}</option> <option v-for="group in groupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
</select> </select>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table rules-table">
<thead> <thead>
<tr> <tr>
<th>配置</th> <th>规则</th>
<th>分组</th> <th>当前设置</th>
<th>类型</th> <th>是否使用</th>
<th></th> <th>用途说明</th>
<th>启用</th>
<th>说明</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="config in visibleConfigs" :key="config.config_key"> <tr v-for="config in visibleConfigs" :key="config.config_key">
<td> <td class="rule-name-cell">
<strong>{{ config.config_name }}</strong> <strong>{{ configTitle(config) }}</strong>
<span>{{ config.config_key }}</span> <span class="rule-group-chip">{{ groupLabel(config.config_group) }}</span>
</td> </td>
<td> <td class="rule-value-cell">
<input v-model="config.config_group" class="table-input" type="text" /> <span class="rule-value-preview">{{ formatConfigValue(config) }}</span>
</td> <div v-if="isWeekdayRule(config)" class="weekday-toggle-group" :aria-label="`${configTitle(config)}当前设置`">
<td> <label
<select v-model="config.value_type" class="table-input"> v-for="weekday in weekdayOptions"
<option value="string">string</option> :key="weekday.value"
<option value="integer">integer</option> class="weekday-toggle"
<option value="number">number</option> :class="{ 'is-selected': isWeekdaySelected(config, weekday.value) }"
<option value="boolean">boolean</option> >
<option value="json">json</option> <input
</select> type="checkbox"
</td> :checked="isWeekdaySelected(config, weekday.value)"
<td> @change="toggleWeekday(config, weekday.value, $event)"
<textarea v-model="config.config_value" class="table-textarea" /> />
<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>
<td> <td>
<label class="checkbox-line"> <label class="checkbox-line">
<input v-model="config.is_enabled" type="checkbox" /> <input v-model="config.is_enabled" type="checkbox" />
<span>{{ config.is_enabled ? "启用" : "停用" }}</span> <span>{{ config.is_enabled ? "使用" : "不用" }}</span>
</label> </label>
</td> </td>
<td> <td class="rule-purpose">
<textarea v-model="config.remark" class="table-textarea" /> <p>{{ configPurpose(config) }}</p>
</td> </td>
<td> <td>
<button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)"> <button class="link-button" type="button" :disabled="savingKey === config.config_key" @click="saveConfig(config)">

View File

@ -6,22 +6,75 @@ import {
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { downloadPayrollFile, readRecentJobs } from "../api/payroll"; import { exportMonthlyRun, listMonthlyRuns } from "../api/monthlyPayroll";
import type { RecentPayrollJob } from "../api/types"; import type { MonthlyPayrollRun } from "../api/types";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const recentJobs = ref<RecentPayrollJob[]>(readRecentJobs()); const runs = ref<MonthlyPayrollRun[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const latestJob = computed(() => recentJobs.value[0]); const latestRun = computed(() => runs.value[0]);
const totalEmployees = computed(() => recentJobs.value.reduce((total, job) => total + job.employee_count, 0)); const recentRuns = computed(() => runs.value.slice(0, 5));
const totalEmployees = computed(() => runs.value.reduce((total, run) => total + run.employee_count, 0));
const chartBars = [32, 48, 58, 76, 68, 88]; const chartBars = [32, 48, 58, 76, 68, 88];
async function download(job: RecentPayrollJob): Promise<void> { onMounted(loadRuns);
await downloadPayrollFile(job.download_url, job.output_file);
async function loadRuns(): Promise<void> {
if (!auth.hasPermission("monthly_payroll:view")) {
return;
}
loading.value = true;
errorMessage.value = "";
try {
runs.value = await listMonthlyRuns();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "核算历史加载失败";
} finally {
loading.value = false;
}
}
async function download(run: MonthlyPayrollRun): Promise<void> {
await exportMonthlyRun(run.id);
}
function sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "暂无";
}
function statusText(status: string | undefined): string {
const map: Record<string, string> = {
draft: "草稿",
calculated: "已计算",
reviewed: "已复核",
locked: "已锁定",
failed: "失败",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
function money(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 0,
}).format(value || 0);
} }
</script> </script>
@ -33,38 +86,38 @@ async function download(job: RecentPayrollJob): Promise<void> {
<p>实时财务状况与薪资指标</p> <p>实时财务状况与薪资指标</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<RouterLink v-if="auth.hasPermission('payroll:excel:calculate')" class="primary-button" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="primary-button" to="/payroll/monthly">
<Banknote :size="19" aria-hidden="true" /> <Banknote :size="19" aria-hidden="true" />
<span>新建计</span> <span>进入工资核</span>
</RouterLink> </RouterLink>
<RouterLink v-if="auth.hasPermission('payroll:job:view')" class="secondary-button" to="/payroll/jobs"> <RouterLink v-if="auth.hasPermission('monthly_payroll:view')" class="secondary-button" to="/payroll/monthly">
<ClipboardList :size="19" aria-hidden="true" /> <ClipboardList :size="19" aria-hidden="true" />
<span>计算记录</span> <span>核算历史</span>
</RouterLink> </RouterLink>
</div> </div>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
<StatCard <StatCard
title="最近计算员工" title="最近核算人数"
:value="String(latestJob?.employee_count || 0)" :value="String(latestRun?.employee_count || 0)"
:icon="UsersRound" :icon="UsersRound"
tone="blue" tone="blue"
meta="当前浏览器" :meta="latestRun?.salary_month || '暂无月份'"
/> />
<StatCard <StatCard
title="任务记录" title="核算记录"
:value="String(recentJobs.length)" :value="String(runs.length)"
:icon="ClipboardList" :icon="ClipboardList"
tone="neutral" tone="neutral"
meta="最近 12 条" meta="月度历史"
/> />
<StatCard <StatCard
title="累计员工次数" title="累计核算人数"
:value="String(totalEmployees)" :value="String(totalEmployees)"
:icon="Gauge" :icon="Gauge"
tone="green" tone="green"
meta="本地记录" meta="全部历史"
/> />
<StatCard <StatCard
title="当前权限" title="当前权限"
@ -86,15 +139,15 @@ async function download(job: RecentPayrollJob): Promise<void> {
</div> </div>
<dl class="compact-list"> <dl class="compact-list">
<div> <div>
<dt>最近任务</dt> <dt>最近来源</dt>
<dd>{{ latestJob?.job_id || "暂无" }}</dd> <dd>{{ sourceText(latestRun?.source_type) }}</dd>
</div> </div>
<div> <div>
<dt>任务来源</dt> <dt>最近生成</dt>
<dd>{{ latestJob?.source_type || "-" }}</dd> <dd>{{ dateTime(latestRun?.created_at) }}</dd>
</div> </div>
</dl> </dl>
<RouterLink v-if="auth.hasPermission('payroll:dingtalk:calculate')" class="primary-button full" to="/payroll"> <RouterLink v-if="auth.hasPermission('monthly_payroll:calculate')" class="primary-button full" to="/payroll/monthly">
<span>立即同步</span> <span>立即同步</span>
</RouterLink> </RouterLink>
</div> </div>
@ -123,37 +176,42 @@ async function download(job: RecentPayrollJob): Promise<void> {
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>最近算记录</h2> <h2>最近算记录</h2>
<p>{{ recentJobs.length }} </p> <p>{{ runs.length }} </p>
</div> </div>
<RouterLink class="link-button" to="/payroll/jobs">查看全部</RouterLink> <RouterLink class="link-button" to="/payroll/monthly">查看全部</RouterLink>
</div> </div>
<div class="table-wrap"> <div v-if="errorMessage" class="inline-alert">{{ errorMessage }}</div>
<div v-if="recentRuns.length" class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>任务编号</th> <th>工资月份</th>
<th>来源</th> <th>来源</th>
<th>状态</th>
<th>员工数</th> <th>员工数</th>
<th>实发合计</th>
<th>创建时间</th> <th>创建时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="job in recentJobs.slice(0, 5)" :key="job.job_id"> <tr v-for="run in recentRuns" :key="run.id">
<td class="mono strong">{{ job.job_id }}</td> <td><strong>{{ run.salary_month }}</strong></td>
<td><span class="status-chip neutral">{{ job.source_type }}</span></td> <td><span class="status-chip neutral">{{ sourceText(run.source_type) }}</span></td>
<td>{{ job.employee_count }}</td> <td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ new Date(job.created_at).toLocaleString() }}</td> <td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td> <td>
<button class="link-button" type="button" @click="download(job)">下载</button> <button class="link-button" type="button" @click="download(run)">导出</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!recentJobs.length" class="empty-state"> <div v-else class="empty-state">
<p>暂无计算记录</p> <p>{{ loading ? "正在加载核算历史..." : "暂无核算历史" }}</p>
</div> </div>
</section> </section>
</div> </div>

View File

@ -3,7 +3,6 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Search, Search,
UsersRound, UsersRound,
X, X,
@ -31,7 +30,8 @@ const pageSize = ref(10);
const total = ref(0); const total = ref(0);
const activeEmployeeTotal = ref(0); const activeEmployeeTotal = ref(0);
const filters = reactive({ const filters = reactive({
keyword: "", employee_info_keyword: "",
organization_keyword: "",
employment_status: "", employment_status: "",
}); });
@ -133,7 +133,8 @@ async function loadEmployees(): Promise<void> {
const response = await listEmployeePage({ const response = await listEmployeePage({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
keyword: filters.keyword.trim(), employee_keyword: filters.employee_info_keyword.trim(),
organization_keyword: filters.organization_keyword.trim(),
employment_status: filters.employment_status, employment_status: filters.employment_status,
}); });
employees.value = response.items; employees.value = response.items;
@ -284,28 +285,11 @@ function nextPage(): void {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack employees-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>员工维护</h1> <h1>员工维护</h1>
<p>维护员工档案钉钉用户 ID 与在职状态</p> <p>管理员工资料工资核算会优先使用这里的信息</p>
</div>
<div class="employee-header-actions">
<div class="employee-inline-metric" aria-label="在职员工">
<span class="employee-inline-icon">
<UsersRound :size="21" aria-hidden="true" />
</span>
<div>
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
</div>
<em>可参与薪资计算</em>
</div>
<button class="secondary-button" type="button" :disabled="loading" @click="refreshEmployees">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
@ -313,9 +297,15 @@ function nextPage(): void {
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel table-panel"> <section class="panel table-panel">
<div class="panel-header"> <div class="panel-header employee-list-header">
<div> <div class="employee-list-title">
<h2>员工列表</h2> <h2>员工列表</h2>
<span class="employee-inline-metric" aria-label="在职员工">
<UsersRound :size="15" aria-hidden="true" />
<span>在职员工</span>
<strong>{{ activeEmployeeTotal }}</strong>
<em></em>
</span>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal"> <button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" /> <Plus :size="18" aria-hidden="true" />
@ -323,11 +313,15 @@ function nextPage(): void {
</button> </button>
</div> </div>
<form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees"> <form class="table-filter-bar employee-filter-bar" @submit.prevent="searchEmployees">
<label class="field"> <label class="field employee-filter-employee">
<span>关键字</span> <span>找员工</span>
<input v-model="filters.keyword" type="search" placeholder="编号、姓名、钉钉ID、部门或岗位" /> <input v-model="filters.employee_info_keyword" type="search" placeholder="输入姓名、编号或手机号" />
</label> </label>
<label class="field"> <label class="field employee-filter-organization">
<span>找部门/岗位</span>
<input v-model="filters.organization_keyword" type="search" placeholder="输入部门或岗位名称" />
</label>
<label class="field employee-filter-status">
<span>状态</span> <span>状态</span>
<select v-model="filters.employment_status"> <select v-model="filters.employment_status">
<option value="">全部状态</option> <option value="">全部状态</option>
@ -335,7 +329,7 @@ function nextPage(): void {
<option value="inactive">离职</option> <option value="inactive">离职</option>
</select> </select>
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button employee-search-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
@ -346,7 +340,7 @@ function nextPage(): void {
<thead> <thead>
<tr> <tr>
<th>员工</th> <th>员工</th>
<th>钉钉用户ID</th> <th>钉钉账号</th>
<th>部门/岗位</th> <th>部门/岗位</th>
<th>手机号</th> <th>手机号</th>
<th>状态</th> <th>状态</th>
@ -361,14 +355,15 @@ function nextPage(): void {
<strong>{{ employee.name }}</strong> <strong>{{ employee.name }}</strong>
<span>{{ employee.employee_no }}</span> <span>{{ employee.employee_no }}</span>
</td> </td>
<td class="mono">{{ employee.dingtalk_user_id || "-" }}</td> <td class="mono">{{ employee.dingtalk_user_id || "未绑定" }}</td>
<td> <td>
<strong>{{ employee.department || "-" }}</strong> <strong>{{ employee.department || "-" }}</strong>
<span>{{ employee.position || "-" }}</span> <span>{{ employee.position || "-" }}</span>
</td> </td>
<td>{{ employee.phone || "-" }}</td> <td>{{ employee.phone || "-" }}</td>
<td> <td>
<span class="status-chip" :class="employee.employment_status === 'active' ? 'green' : 'red'"> <span class="employee-status-pill" :class="employee.employment_status === 'active' ? 'is-active' : 'is-inactive'">
<i class="employee-status-dot" aria-hidden="true"></i>
{{ statusLabel(employee.employment_status) }} {{ statusLabel(employee.employment_status) }}
</span> </span>
</td> </td>
@ -424,8 +419,8 @@ function nextPage(): void {
<input v-model="form.name" required maxlength="128" type="text" /> <input v-model="form.name" required maxlength="128" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>钉钉用户ID</span> <span>钉钉账号</span>
<input v-model="form.dingtalk_user_id" maxlength="128" type="text" /> <input v-model="form.dingtalk_user_id" maxlength="128" placeholder="用于匹配钉钉考勤,可不填" type="text" />
</label> </label>
<label class="field"> <label class="field">
<span>状态</span> <span>状态</span>

View File

@ -98,6 +98,7 @@ async function submit(): Promise<void> {
</section> </section>
<section class="login-visual"> <section class="login-visual">
<div class="login-background-layer" aria-hidden="true"></div>
<div class="ledger-visual"> <div class="ledger-visual">
<span class="status-chip green">企业版</span> <span class="status-chip green">企业版</span>
<h2>智能 · 精准 · 值得信赖</h2> <h2>智能 · 精准 · 值得信赖</h2>

View File

@ -1,5 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Calculator, Download, FileSpreadsheet, Loader2, LockKeyhole, RefreshCw, Upload } from "@lucide/vue"; import {
Download,
FileSpreadsheet,
Loader2,
LockKeyhole,
Play,
Upload,
} from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
@ -12,11 +19,21 @@ import {
lockMonthlyRun, lockMonthlyRun,
} from "../api/monthlyPayroll"; } from "../api/monthlyPayroll";
import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types"; import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue"; import ResultsTable from "../components/ResultsTable.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore(); const auth = useAuthStore();
const selectedMonth = ref(new Date().toISOString().slice(0, 7)); const selectedMonth = ref(defaultSalaryMonth());
const selectedFile = ref<File | null>(null);
const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const exportExcel = ref(true);
const runs = ref<MonthlyPayrollRun[]>([]); const runs = ref<MonthlyPayrollRun[]>([]);
const detail = ref<MonthlyPayrollDetailResponse | null>(null); const detail = ref<MonthlyPayrollDetailResponse | null>(null);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@ -33,18 +50,47 @@ const canExport = computed(() => auth.hasPermission("monthly_payroll:export"));
const grossTotal = computed(() => money(activeRun.value?.gross_total || 0)); const grossTotal = computed(() => money(activeRun.value?.gross_total || 0));
const netTotal = computed(() => money(activeRun.value?.net_total || 0)); const netTotal = computed(() => money(activeRun.value?.net_total || 0));
const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0)); const deductionTotal = computed(() => money(activeRun.value?.deduction_total || 0));
const overtimeTotal = computed(() => `${(activeRun.value?.overtime_total || 0).toFixed(1)}h`);
const monthRange = computed(() => formatMonthRange(selectedMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(selectedMonth.value));
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== selectedMonth.value),
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "请先选择 Excel 文件。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${selectedMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}${selectedFileMonth.value.rangeLabel}`;
});
onMounted(() => { onMounted(() => {
void loadRuns(); void loadRuns();
}); });
async function loadRuns(): Promise<void> { function defaultSalaryMonth(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
async function loadRuns(preferredRunId?: number): Promise<void> {
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
runs.value = await listMonthlyRuns(selectedMonth.value); runs.value = await listMonthlyRuns(selectedMonth.value);
if (runs.value.length) { if (runs.value.length) {
detail.value = await getMonthlyRun(runs.value[0].id); const selectedRun = runs.value.find((run) => run.id === preferredRunId) || runs.value[0];
detail.value = await getMonthlyRun(selectedRun.id);
} else { } else {
detail.value = null; detail.value = null;
} }
@ -57,6 +103,24 @@ async function loadRuns(): Promise<void> {
} }
} }
async function handleMonthChange(): Promise<void> {
errorMessage.value = "";
successMessage.value = "";
await loadRuns();
}
async function viewRun(run: MonthlyPayrollRun): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
detail.value = await getMonthlyRun(run.id);
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "核算记录加载失败";
} finally {
loading.value = false;
}
}
function chooseExcel(): void { function chooseExcel(): void {
fileInput.value?.click(); fileInput.value?.click();
} }
@ -68,18 +132,52 @@ async function handleExcelChange(event: Event): Promise<void> {
if (!file) { if (!file) {
return; return;
} }
await runAction("excel", async () => {
detail.value = await calculateMonthlyFromExcel(file, selectedMonth.value); selectedFile.value = file;
successMessage.value = "Excel 已导入并完成月度核算"; selectedFileMonth.value = extractMonthFromExcelFilename(file.name);
await loadRuns();
}); let detectedMonthMessage = "";
if (selectedFileMonth.value && selectedMonth.value !== selectedFileMonth.value.month) {
selectedMonth.value = selectedFileMonth.value.month;
detectedMonthMessage = `已根据文件识别核算月份:${selectedFileMonth.value.month}`;
}
await importSelectedExcel(detectedMonthMessage);
} }
async function calculateFromDingTalk(): Promise<void> { async function calculateFromDingTalk(): Promise<void> {
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
await runAction("dingtalk", async () => { await runAction("dingtalk", async () => {
detail.value = await calculateMonthlyFromDingTalk(selectedMonth.value); const calculated = await calculateMonthlyFromDingTalk(selectedMonth.value, exportExcel.value);
successMessage.value = "钉钉考勤已同步并完成月度核算"; detail.value = calculated;
await loadRuns(); successMessage.value = `钉钉考勤已同步并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
});
}
async function importSelectedExcel(prefixMessage = ""): Promise<void> {
if (!selectedFile.value) {
errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return;
}
await runAction("excel", async () => {
const calculated = await calculateMonthlyFromExcel(selectedFile.value!, selectedMonth.value, exportExcel.value);
detail.value = calculated;
successMessage.value = `${prefixMessage}Excel 已导入并完成 ${calculated.run.salary_month} 工资核算`;
await loadRuns(calculated.run.id);
}); });
} }
@ -87,11 +185,12 @@ async function lockRun(): Promise<void> {
if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) { if (!activeRun.value || !window.confirm("锁定后本月工资不能重新计算,只能导出。确认锁定?")) {
return; return;
} }
const runId = activeRun.value.id;
await runAction("lock", async () => { await runAction("lock", async () => {
await lockMonthlyRun(activeRun.value!.id); await lockMonthlyRun(runId);
detail.value = await getMonthlyRun(activeRun.value!.id); detail.value = await getMonthlyRun(runId);
successMessage.value = "本月工资已锁定"; successMessage.value = "本月工资已锁定";
await loadRuns(); await loadRuns(runId);
}); });
} }
@ -132,6 +231,50 @@ function statusText(status: string | undefined): string {
return status ? map[status] || status : "未开始"; return status ? map[status] || status : "未开始";
} }
function sourceText(sourceType: string | undefined): string {
const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
manual: "手动重算",
};
return sourceType ? map[sourceType] || sourceType : "-";
}
function exceptionTypeText(type: string | undefined): string {
const map: Record<string, string> = {
missing_employee: "未匹配员工",
missing_salary_profile: "缺少薪资档案",
negative_remaining_overtime: "剩余加班不足",
late_penalty: "迟到扣款",
missing_card: "缺卡",
missing_attendance: "缺少考勤",
salary_calculation_failed: "工资计算失败",
};
return type ? map[type] || type : "-";
}
function severityText(severity: string | undefined): string {
const map: Record<string, string> = {
info: "提醒",
warning: "预警",
error: "错误",
};
return severity ? map[severity] || severity : "-";
}
function exceptionStatusText(status: string | undefined): string {
const map: Record<string, string> = {
pending: "待处理",
resolved: "已处理",
ignored: "已忽略",
};
return status ? map[status] || status : "-";
}
function dateTime(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "-";
}
function money(value: number): string { function money(value: number): string {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat("zh-CN", {
style: "currency", style: "currency",
@ -145,71 +288,60 @@ function money(value: number): string {
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>月度核算</h1> <h1>工资核算</h1>
<p>按工资月份完成导入计算异常处理锁定和导出</p> <p>一个入口完成考勤取数工资计算异常核对锁定和导出</p>
</div>
<div class="header-actions">
<label class="field compact-field">
<span>工资月份</span>
<input v-model="selectedMonth" type="month" @change="loadRuns" />
</label>
<button class="secondary-button" type="button" :disabled="loading" @click="loadRuns">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</div> </div>
</header> </header>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel payroll-compact-toolbar" aria-label="工资核算工具栏">
<div class="panel-header"> <div class="payroll-toolbar-month">
<span>本月核算</span>
<label class="field">
<span>核算月份</span>
<DatePicker v-model="selectedMonth" mode="month" placeholder="请选择核算月份" @change="handleMonthChange" />
</label>
</div>
<div class="payroll-toolbar-summary">
<div> <div>
<h2>核算流程</h2>
<p>数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定</p>
</div>
<span class="status-chip" :class="isLocked ? 'green' : 'neutral'">{{ statusText(activeRun?.status) }}</span>
</div>
<div class="workflow-steps">
<span>1 数据导入</span>
<span>2 考勤转换</span>
<span>3 异常处理</span>
<span>4 工资计算</span>
<span>5 复核锁定</span>
</div>
<div class="compact-stats-grid">
<div class="compact-stat">
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div class="compact-stat">
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div class="compact-stat">
<span>实发合计</span> <span>实发合计</span>
<strong>{{ netTotal }}</strong> <strong>{{ netTotal }}</strong>
<small>{{ sourceText(activeRun?.source_type) }} · {{ activeRun?.employee_count || 0 }} </small>
</div> </div>
<div class="compact-stat"> <div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
<small>{{ statusText(activeRun?.status) }}</small>
</div>
<div>
<span>扣款合计</span> <span>扣款合计</span>
<strong>{{ deductionTotal }}</strong> <strong>{{ deductionTotal }}</strong>
<small>加班 {{ overtimeTotal }}</small>
</div>
<div class="payroll-toolbar-range">
<span>同步范围</span>
<strong>{{ monthRange }}</strong>
<small>{{ dingtalkScopeText }}</small>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="payroll-toolbar-actions">
<label class="payroll-toolbar-export">
<input v-model="exportExcel" type="checkbox" />
<span>生成 Excel 结果文件</span>
</label>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ actionLoading === "dingtalk" ? "同步中" : "同步钉钉核算" }}</span>
</button>
<button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel"> <button class="secondary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel'" @click="chooseExcel">
<Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Upload v-else :size="18" aria-hidden="true" /> <Upload v-else :size="18" aria-hidden="true" />
<span>导入 Excel</span> <span>{{ selectedFile ? "更换 Excel 核算" : "导入 Excel 核算" }}</span>
</button>
<button class="primary-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'dingtalk'" @click="calculateFromDingTalk">
<Loader2 v-if="actionLoading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Calculator v-else :size="18" aria-hidden="true" />
<span>同步钉钉并计算</span>
</button> </button>
<button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun"> <button class="secondary-button" type="button" :disabled="!activeRun || !canLock || isLocked || actionLoading === 'lock'" @click="lockRun">
<Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'lock'" class="spin" :size="18" aria-hidden="true" />
@ -219,59 +351,152 @@ function money(value: number): string {
<button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun"> <button class="secondary-button" type="button" :disabled="!activeRun || !canExport || actionLoading === 'export'" @click="exportRun">
<Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="actionLoading === 'export'" class="spin" :size="18" aria-hidden="true" />
<Download v-else :size="18" aria-hidden="true" /> <Download v-else :size="18" aria-hidden="true" />
<span>导出</span> <span>导出工资表</span>
</button>
<button v-if="selectedFile" class="link-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel' || excelMonthMismatch" @click="importSelectedExcel()">
重新导入
</button> </button>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</div> </div>
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
</section> </section>
<section v-if="detail?.exceptions.length" class="panel"> <section class="panel payroll-detail-panel">
<div class="panel-header"> <div class="payroll-detail-header">
<div>
<h2>异常清单</h2>
<p>优先处理缺薪资档案缺卡迟到扣款和未匹配员工</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>类型</th>
<th>级别</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in detail.exceptions" :key="item.id">
<td>
<strong>{{ item.employee_name || "-" }}</strong>
<span>{{ item.employee_no || "-" }}</span>
</td>
<td>{{ item.exception_type }}</td>
<td>{{ item.severity }}</td>
<td>{{ item.message }}</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div> <div>
<h2>工资明细</h2> <h2>工资明细</h2>
<p>锁定后本月工资不能重新计算只能导出</p> <p>锁定后本月工资不能重新计算只能导出</p>
</div> </div>
<FileSpreadsheet :size="22" aria-hidden="true" /> <div class="payroll-detail-summary">
<div>
<span>员工数</span>
<strong>{{ activeRun?.employee_count || 0 }}</strong>
</div>
<div>
<span>实发合计</span>
<strong>{{ netTotal }}</strong>
</div>
<div>
<span>应发合计</span>
<strong>{{ grossTotal }}</strong>
</div>
<div>
<span>状态</span>
<strong>{{ statusText(activeRun?.status) }}</strong>
</div>
</div>
</div> </div>
<ResultsTable v-if="detail?.results.length" :results="detail.results" /> <ResultsTable v-if="detail?.results.length" :exceptions="detail.exceptions" :results="detail.results" embedded />
<div v-else class="empty-state"> <div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" /> <FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p> <p>当前月份还没有核算结果请导入 Excel 或同步钉钉并计算</p>
</div> </div>
</section> </section>
<div class="payroll-secondary-grid">
<section class="panel monthly-history-panel">
<div class="panel-header">
<div>
<h2>核算历史</h2>
<p>展示本月最近生成的工资版本可切换查看明细或导出</p>
</div>
<span class="status-chip neutral">{{ runs.length }} 条记录</span>
</div>
<div v-if="runs.length" class="table-wrap">
<table class="data-table monthly-history-table">
<thead>
<tr>
<th>工资月份</th>
<th>来源</th>
<th>状态</th>
<th>员工数</th>
<th>实发合计</th>
<th>计算时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.id" :class="{ 'is-selected': activeRun?.id === run.id }">
<td>
<strong>{{ run.salary_month }}</strong>
<span v-if="activeRun?.id === run.id">当前查看</span>
</td>
<td>{{ sourceText(run.source_type) }}</td>
<td><span class="status-chip" :class="run.status === 'locked' ? 'green' : 'neutral'">{{ statusText(run.status) }}</span></td>
<td>{{ run.employee_count }}</td>
<td class="mono strong">{{ money(run.net_total) }}</td>
<td>{{ dateTime(run.created_at) }}</td>
<td>
<button class="link-button" type="button" :disabled="loading || activeRun?.id === run.id" @click="viewRun(run)">
查看明细
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<FileSpreadsheet :size="38" aria-hidden="true" />
<p>当前月份还没有核算历史请导入 Excel 或同步钉钉并计算</p>
</div>
</section>
<section v-if="detail?.exceptions.length" class="panel payroll-exception-panel">
<div class="panel-header">
<div>
<h2>异常清单</h2>
<p>优先处理缺薪资档案缺卡迟到扣款和未匹配员工</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>员工</th>
<th>类型</th>
<th>级别</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in detail.exceptions" :key="item.id">
<td>
<strong>{{ item.employee_name || "-" }}</strong>
<span>{{ item.employee_no || "-" }}</span>
</td>
<td>{{ exceptionTypeText(item.exception_type) }}</td>
<td>{{ severityText(item.severity) }}</td>
<td>{{ item.message }}</td>
<td>{{ exceptionStatusText(item.status) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<details v-if="activeRun" class="panel monthly-technical-panel">
<summary>技术信息</summary>
<p class="technical-note">用于系统排查和审计日常工资核对不需要使用这些字段</p>
<dl class="detail-grid">
<div>
<dt>系统记录ID</dt>
<dd>{{ activeRun.id }}</dd>
</div>
<div>
<dt>来源任务编号</dt>
<dd class="mono">{{ activeRun.source_job_id || "-" }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ dateTime(activeRun.created_at) }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ dateTime(activeRun.updated_at) }}</dd>
</div>
</dl>
</details>
</div> </div>
</template> </template>

View File

@ -3,7 +3,6 @@ import {
CheckCircle2, CheckCircle2,
Clock3, Clock3,
Loader2, Loader2,
RefreshCw,
ScrollText, ScrollText,
Search, Search,
XCircle, XCircle,
@ -168,11 +167,6 @@ function targetLabel(log: OperationLog): string {
<h1>操作日志</h1> <h1>操作日志</h1>
<p>查看登录工资计算用户管理和下载等关键操作</p> <p>查看登录工资计算用户管理和下载等关键操作</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadLogs">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">

View File

@ -8,7 +8,6 @@ import {
GitBranch, GitBranch,
Loader2, Loader2,
Plus, Plus,
RefreshCw,
Save, Save,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -500,11 +499,6 @@ function sortByOrderThenId<T extends { sort_order: number; id: number }>(left: T
<h1>组织架构</h1> <h1>组织架构</h1>
<p>用树状关系维护部门上下级和部门下岗位员工维护会按这里的关系联动</p> <p>用树状关系维护部门上下级和部门下岗位员工维护会按这里的关系联动</p>
</div> </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> </header>
<section class="organization-summary-strip"> <section class="organization-summary-strip">

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ArrowRight,
Banknote, Banknote,
CalendarDays,
CloudCog, CloudCog,
Download, Download,
FileSpreadsheet, FileSpreadsheet,
@ -9,115 +9,148 @@ import {
Play, Play,
Trash2, Trash2,
Upload, Upload,
UsersRound,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { import {
calculateFromDingTalk, calculateMonthlyFromDingTalk,
calculateFromExcel, calculateMonthlyFromExcel,
downloadPayrollFile, exportMonthlyRun,
saveRecentJob, } from "../api/monthlyPayroll";
} from "../api/payroll"; import type { MonthlyPayrollDetailResponse } from "../api/types";
import type { PayrollResponse } from "../api/types"; import DatePicker from "../components/DatePicker.vue";
import ResultsTable from "../components/ResultsTable.vue"; import ResultsTable from "../components/ResultsTable.vue";
import StatCard from "../components/StatCard.vue"; import StatCard from "../components/StatCard.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
type ExcelMonthInfo,
} from "../utils/payrollMonth";
const auth = useAuthStore(); const auth = useAuthStore();
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const configPath = ref(""); const selectedFileMonth = ref<ExcelMonthInfo | null>(null);
const salaryMonth = ref(defaultSalaryMonth());
const exportExcel = ref(true); const exportExcel = ref(true);
const userIdsText = ref("001\n002"); const activeDetail = ref<MonthlyPayrollDetailResponse | null>(null);
const startDate = ref("2026-05-01");
const endDate = ref("2026-05-31");
const activeResult = ref<PayrollResponse | null>(null);
const loading = ref<"excel" | "dingtalk" | "">(""); const loading = ref<"excel" | "dingtalk" | "">("");
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const canCalculateExcel = computed(() => auth.hasPermission("payroll:excel:calculate")); const canCalculate = computed(() => auth.hasPermission("monthly_payroll:calculate"));
const canCalculateDingTalk = computed(() => auth.hasPermission("payroll:dingtalk:calculate")); const activeRun = computed(() => activeDetail.value?.run);
const monthRange = computed(() => formatMonthRange(salaryMonth.value));
const hasValidMonth = computed(() => isSalaryMonth(salaryMonth.value));
const excelMonthMismatch = computed(
() => Boolean(selectedFile.value && selectedFileMonth.value && selectedFileMonth.value.month !== salaryMonth.value),
);
const dingtalkScopeText = computed(() =>
hasValidMonth.value ? `将同步 ${monthRange.value} 的钉钉考勤` : "请选择核算月份后再同步钉钉",
);
const excelMonthHint = computed(() => {
if (!selectedFile.value) {
return "选择 Excel 后会自动识别文件月份。";
}
if (!selectedFileMonth.value) {
return "未从文件名识别月份,请确认核算月份与 Excel 内容一致。";
}
if (excelMonthMismatch.value) {
return `Excel 文件月份为 ${selectedFileMonth.value.month},当前核算月份为 ${salaryMonth.value || "未选择"},请保持一致后再导入。`;
}
return `Excel 文件月份:${selectedFileMonth.value.month}${selectedFileMonth.value.rangeLabel}`;
});
const totalOvertime = computed(() => (activeRun.value?.overtime_total || 0).toFixed(1));
const totalDeduction = computed(() => money(activeRun.value?.deduction_total || 0));
const totalNetSalary = computed(() => money(activeRun.value?.net_total || 0));
const totalOvertime = computed(() => sumResults("remaining_overtime_hours").toFixed(1)); function defaultSalaryMonth(): string {
const totalDeduction = computed(() => money(sumResults("total_deduction"))); const now = new Date();
const totalNetSalary = computed(() => money(sumResults("net_salary"))); const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const year = previousMonth.getFullYear();
const month = String(previousMonth.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function handleFileChange(event: Event): void { function handleFileChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null; selectedFile.value = input.files?.[0] || null;
selectedFileMonth.value = selectedFile.value ? extractMonthFromExcelFilename(selectedFile.value.name) : null;
errorMessage.value = "";
successMessage.value = "";
if (selectedFileMonth.value && salaryMonth.value !== selectedFileMonth.value.month) {
salaryMonth.value = selectedFileMonth.value.month;
successMessage.value = `已根据文件识别核算月份:${selectedFileMonth.value.month}`;
}
} }
async function submitExcel(): Promise<void> { async function submitExcel(): Promise<void> {
if (!selectedFile.value) { if (!selectedFile.value) {
errorMessage.value = "请选择 Excel 文件"; errorMessage.value = "请选择钉钉月度汇总 Excel 文件";
return;
}
if (!hasValidMonth.value) {
errorMessage.value = "请选择核算月份";
return;
}
if (excelMonthMismatch.value) {
errorMessage.value = excelMonthHint.value;
return; return;
} }
await runCalculation("excel", async () => { await runCalculation("excel", () => calculateMonthlyFromExcel(selectedFile.value!, salaryMonth.value, exportExcel.value));
const response = await calculateFromExcel(selectedFile.value!, exportExcel.value, configPath.value.trim());
saveRecentJob("excel", response);
return response;
});
} }
async function submitDingTalk(): Promise<void> { async function submitDingTalk(): Promise<void> {
const user_ids = userIdsText.value if (!hasValidMonth.value) {
.split(/[\n,\s]+/) errorMessage.value = "请选择核算月份";
.map((item) => item.trim())
.filter(Boolean);
if (!user_ids.length) {
errorMessage.value = "请输入钉钉员工 userId";
return; return;
} }
await runCalculation("dingtalk", async () => { await runCalculation("dingtalk", () => calculateMonthlyFromDingTalk(salaryMonth.value, exportExcel.value));
const response = await calculateFromDingTalk({
user_ids,
start_date: startDate.value,
end_date: endDate.value,
config_path: configPath.value.trim() || null,
export_excel: exportExcel.value,
});
saveRecentJob("dingtalk", response);
return response;
});
} }
async function runCalculation( async function runCalculation(
source: "excel" | "dingtalk", source: "excel" | "dingtalk",
executor: () => Promise<PayrollResponse>, executor: () => Promise<MonthlyPayrollDetailResponse>,
): Promise<void> { ): Promise<void> {
loading.value = source; loading.value = source;
errorMessage.value = ""; errorMessage.value = "";
successMessage.value = ""; successMessage.value = "";
try { try {
activeResult.value = await executor(); activeDetail.value = await executor();
successMessage.value = `计算完成:${activeResult.value.employee_count} 名员工`; successMessage.value = `核算完成:${activeDetail.value.run.salary_month}${activeDetail.value.run.employee_count} 名员工`;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "算失败,请检查数据后重试"; errorMessage.value = error instanceof ApiError ? error.message : "算失败,请检查数据后重试";
} finally { } finally {
loading.value = ""; loading.value = "";
} }
} }
async function downloadCurrent(): Promise<void> { async function downloadCurrent(): Promise<void> {
if (!activeResult.value) { if (!activeRun.value) {
return; return;
} }
await downloadPayrollFile(activeResult.value.download_url, activeResult.value.output_file); await exportMonthlyRun(activeRun.value.id);
} }
function clearResult(): void { function clearResult(): void {
activeResult.value = null; activeDetail.value = null;
successMessage.value = ""; successMessage.value = "";
errorMessage.value = ""; errorMessage.value = "";
} }
function sumResults(field: "remaining_overtime_hours" | "total_deduction" | "net_salary"): number { function sourceText(sourceType: string | undefined): string {
return (activeResult.value?.results || []).reduce((total, item) => total + (item[field] || 0), 0); const map: Record<string, string> = {
excel: "Excel导入",
dingtalk: "钉钉同步",
};
return sourceType ? map[sourceType] || sourceType : "未核算";
} }
function money(value: number): string { function money(value: number): string {
@ -130,109 +163,117 @@ function money(value: number): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack payroll-import-view">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>薪资计算与集成</h1> <h1>同步导入</h1>
<p>Excel 月度汇总与钉钉实时考勤统一计算</p> <p>按月份导入考勤并生成工资核算结果</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="secondary-button" type="button" :disabled="!activeResult" @click="clearResult"> <button class="secondary-button" type="button" :disabled="!activeDetail" @click="clearResult">
<Trash2 :size="18" aria-hidden="true" /> <Trash2 :size="18" aria-hidden="true" />
<span>清空数据</span> <span>清空结果</span>
</button> </button>
<button class="primary-button" type="button" :disabled="!activeResult?.download_url && !activeResult?.output_file" @click="downloadCurrent"> <button class="primary-button" type="button" :disabled="!activeRun" @click="downloadCurrent">
<Download :size="18" aria-hidden="true" /> <Download :size="18" aria-hidden="true" />
<span>下载结果</span> <span>下载结果</span>
</button> </button>
</div> </div>
</header> </header>
<section class="action-grid"> <section class="panel payroll-settings-panel">
<div class="action-card">
<div class="action-icon blue">
<CloudCog :size="29" aria-hidden="true" />
</div>
<h2>实时同步钉钉考勤</h2>
<p>通过后端钉钉接口获取打卡请假及加班数据</p>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<ArrowRight v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "连接并抓取" }}</span>
</button>
</div>
<div class="action-card">
<div class="action-icon green">
<FileSpreadsheet :size="29" aria-hidden="true" />
</div>
<h2>离线 Excel 导入</h2>
<p>上传钉钉月度汇总表按当前工资规则计算</p>
<label class="file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile?.name || "上传文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
</div>
</section>
<section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>计算参数</h2> <h2>核算设置</h2>
<p>{{ exportExcel ? "生成 Excel 结果文件" : "仅返回接口结果" }}</p> <p>核算月份会同时决定钉钉同步范围和 Excel 入库月份</p>
</div> </div>
<span class="status-chip green">API 已连接</span> <span class="status-chip green">API 已连接</span>
</div> </div>
<div class="form-grid"> <div class="settings-grid">
<label class="field"> <label class="field">
<span>配置文件路径</span> <span>核算月份</span>
<input v-model="configPath" type="text" placeholder="默认规则配置" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择核算月份" />
</label>
<label class="field">
<span>开始日期</span>
<input v-model="startDate" type="date" />
</label>
<label class="field">
<span>结束日期</span>
<input v-model="endDate" type="date" />
</label> </label>
<div class="readonly-field">
<span>核算周期</span>
<strong>{{ monthRange }}</strong>
</div>
<div class="readonly-field">
<span>同步范围</span>
<strong>{{ dingtalkScopeText }}</strong>
</div>
<label class="checkbox-card"> <label class="checkbox-card">
<input v-model="exportExcel" type="checkbox" /> <input v-model="exportExcel" type="checkbox" />
<span>导出 Excel</span> <span>生成 Excel 结果文件</span>
</label> </label>
</div> </div>
</section>
<label class="field"> <section class="source-workflow" aria-label="数据来源">
<span>钉钉员工 userId</span> <div class="section-title-row">
<textarea v-model="userIdsText" rows="4" /> <div>
</label> <h2>选择导入方式</h2>
<p>两种方式都会生成所选月份的同一套月度核算结果</p>
</div>
</div>
<div class="form-actions"> <div class="source-strip">
<button class="secondary-button" type="button" :disabled="!canCalculateExcel || loading === 'excel'" @click="submitExcel"> <article class="source-option">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" /> <div class="source-option-main">
<Play v-else :size="18" aria-hidden="true" /> <span class="action-icon blue">
<span>{{ loading === "excel" ? "计算中" : "开始计算 Excel" }}</span> <CloudCog :size="22" aria-hidden="true" />
</button> </span>
<button class="primary-button" type="button" :disabled="!canCalculateDingTalk || loading === 'dingtalk'" @click="submitDingTalk"> <div>
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" /> <h2>钉钉同步</h2>
<Play v-else :size="18" aria-hidden="true" /> <p>{{ dingtalkScopeText }}</p>
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span> </div>
</button> </div>
<button class="primary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'dingtalk'" @click="submitDingTalk">
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "dingtalk" ? "同步中" : "开始同步" }}</span>
</button>
</article>
<article class="source-option">
<div class="source-option-main">
<span class="action-icon green">
<FileSpreadsheet :size="22" aria-hidden="true" />
</span>
<div>
<h2>Excel 导入</h2>
<p>{{ selectedFile?.name || "上传钉钉月度汇总表后开始核算。" }}</p>
<small class="source-note" :class="{ warning: excelMonthMismatch }">{{ excelMonthHint }}</small>
</div>
</div>
<div class="source-actions">
<label class="file-button secondary-file-button">
<Upload :size="18" aria-hidden="true" />
<span>{{ selectedFile ? "更换文件" : "选择文件" }}</span>
<input accept=".xlsx,.xls" type="file" @change="handleFileChange" />
</label>
<button class="secondary-button" type="button" :disabled="!canCalculate || !hasValidMonth || loading === 'excel' || !selectedFile || excelMonthMismatch" @click="submitExcel">
<Loader2 v-if="loading === 'excel'" class="spin" :size="18" aria-hidden="true" />
<Play v-else :size="18" aria-hidden="true" />
<span>{{ loading === "excel" ? "核算中" : "开始导入" }}</span>
</button>
</div>
</article>
</div> </div>
</section> </section>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section v-if="activeResult" class="summary-strip"> <section v-if="activeRun" class="summary-strip">
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" /> <StatCard title="工资月份" :value="activeRun.salary_month" :icon="CalendarDays" tone="blue" :meta="sourceText(activeRun.source_type)" />
<StatCard title="员工数量" :value="String(activeRun.employee_count)" :icon="UsersRound" tone="neutral" meta="参与核算" />
<StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" /> <StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" />
<StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" /> <StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" />
<StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" /> <StatCard title="实发合计" :value="totalNetSalary" :icon="Banknote" tone="neutral" meta="已计算" />
</section> </section>
<ResultsTable :results="activeResult?.results || []" /> <ResultsTable :results="activeDetail?.results || []" />
</div> </div>
</template> </template>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { CalendarClock, CloudCog, Loader2, RefreshCw } from "@lucide/vue"; import { CalendarClock, CloudCog, Loader2 } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance"; import { getTodayAttendance, syncRealtimeAttendance } from "../api/realtimeAttendance";
import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types"; import type { RealtimeAttendanceResponse, TodayAttendanceItem } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
@ -14,10 +15,37 @@ const loading = ref(false);
const syncing = ref(false); const syncing = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const employeeQuery = ref("");
const departmentFilter = ref("all");
const summary = computed(() => response.value?.summary); const summary = computed(() => response.value?.summary);
const rows = computed(() => response.value?.items || []); const rows = computed(() => response.value?.items || []);
const canSync = computed(() => auth.hasPermission("attendance:realtime:sync")); const canSync = computed(() => auth.hasPermission("attendance:realtime:sync"));
const departmentOptions = computed(() =>
Array.from(new Set(rows.value.map((row) => row.department).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
);
const filteredRows = computed(() => {
const query = employeeQuery.value.trim().toLowerCase();
return rows.value.filter((row) => {
const matchDepartment = departmentFilter.value === "all" || row.department === departmentFilter.value;
const matchKeyword =
!query ||
[row.employee_name, row.employee_no, row.department, row.position]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
return matchDepartment && matchKeyword;
});
});
const hasRealtimeFilters = computed(() => departmentFilter.value !== "all" || employeeQuery.value.trim() !== "");
const syncStatusText = computed(() => {
const syncedAt = response.value?.synced_at;
if (syncedAt) {
const time = formatSyncTime(syncedAt);
return time ? `${time} 同步` : "已同步";
}
return response.value?.sync_job_id ? "已同步" : "未同步";
});
const syncStatusClass = computed(() => (response.value?.synced_at || response.value?.sync_job_id ? "green" : "neutral"));
onMounted(() => { onMounted(() => {
void loadTodayAttendance(); void loadTodayAttendance();
@ -78,6 +106,29 @@ function numberText(value: number, unit = ""): string {
return `${Number(value || 0).toFixed(1)}${unit}`; return `${Number(value || 0).toFixed(1)}${unit}`;
} }
function formatSyncTime(value: string): string {
const text = String(value || "").trim();
const match = text.match(/(?:T|\s)(\d{2}):(\d{2})/);
if (match) {
return `${match[1]}:${match[2]}`;
}
const parsed = new Date(text.includes(" ") ? text.replace(" ", "T") : text);
if (Number.isNaN(parsed.getTime())) {
return "";
}
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
}
function clearRealtimeFilters(): void {
departmentFilter.value = "all";
employeeQuery.value = "";
}
function rowKey(row: TodayAttendanceItem): string { function rowKey(row: TodayAttendanceItem): string {
return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`; return row.employee_id ? String(row.employee_id) : `${row.employee_no}-${row.employee_name}`;
} }
@ -85,21 +136,16 @@ function rowKey(row: TodayAttendanceItem): string {
<template> <template>
<div class="view-stack"> <div class="view-stack">
<header class="page-header"> <header class="page-header realtime-page-header">
<div> <div>
<h1>实时考勤</h1> <h1>实时考勤</h1>
<p>查看今日打卡状态并预估本月薪资进度</p> <p>查看今日打卡状态并预估本月薪资进度</p>
</div> </div>
<div class="header-actions"> <div class="header-actions realtime-header-actions">
<label class="field compact-field"> <label class="field compact-field realtime-date-field">
<span>考勤日期</span> <span>考勤日期</span>
<input v-model="selectedDate" type="date" @change="loadTodayAttendance" /> <DatePicker v-model="selectedDate" mode="date" placeholder="请选择考勤日期" @change="loadTodayAttendance" />
</label> </label>
<button class="secondary-button" type="button" :disabled="loading || syncing" @click="loadTodayAttendance">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<RefreshCw v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
<button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk"> <button class="primary-button" type="button" :disabled="!canSync || syncing" @click="syncDingTalk">
<Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="syncing" class="spin" :size="18" aria-hidden="true" />
<CloudCog v-else :size="18" aria-hidden="true" /> <CloudCog v-else :size="18" aria-hidden="true" />
@ -111,18 +157,18 @@ function rowKey(row: TodayAttendanceItem): string {
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p> <p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<p v-if="successMessage" class="form-success">{{ successMessage }}</p> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<section class="panel"> <section class="panel realtime-overview-panel">
<div class="panel-header"> <div class="panel-header realtime-overview-header">
<div> <div class="realtime-overview-title">
<h2>今日考勤与薪资预估</h2> <h2>今日考勤与薪资预估</h2>
<p>预估金额最终以月度核算锁定结果为准</p> <p>预估金额最终以月度核算锁定结果为准</p>
</div> </div>
<span class="status-chip" :class="response?.sync_job_id ? 'green' : 'neutral'"> <span class="status-chip realtime-sync-chip" :class="syncStatusClass">
{{ response?.sync_job_id ? "已同步" : "未同步" }} {{ syncStatusText }}
</span> </span>
</div> </div>
<div class="compact-stats-grid"> <div class="compact-stats-grid realtime-overview-stats">
<div class="compact-stat"> <div class="compact-stat">
<span>在职员工</span> <span>在职员工</span>
<strong>{{ summary?.employee_total || 0 }}</strong> <strong>{{ summary?.employee_total || 0 }}</strong>
@ -158,11 +204,39 @@ function rowKey(row: TodayAttendanceItem): string {
</div> </div>
</div> </div>
<div v-if="rows.length" class="realtime-table-filter-bar">
<label class="field">
<span>部门筛选</span>
<select v-model="departmentFilter">
<option value="all">全部部门</option>
<option v-for="department in departmentOptions" :key="department" :value="department">{{ department }}</option>
</select>
</label>
<label class="field">
<span>员工搜索</span>
<input v-model="employeeQuery" type="search" placeholder="姓名 / 编号 / 岗位" />
</label>
<button
class="secondary-button realtime-clear-filter"
type="button"
:disabled="!hasRealtimeFilters"
@click="clearRealtimeFilters"
>
清空
</button>
<span class="realtime-filter-result">显示 {{ filteredRows.length }} / {{ rows.length }} </span>
</div>
<div v-if="!rows.length && !loading" class="empty-state"> <div v-if="!rows.length && !loading" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" /> <CalendarClock :size="38" aria-hidden="true" />
<p>当前日期暂无考勤数据可点击同步钉钉拉取在职员工打卡记录</p> <p>当前日期暂无考勤数据可点击同步钉钉拉取在职员工打卡记录</p>
</div> </div>
<div v-else-if="!filteredRows.length" class="empty-state">
<CalendarClock :size="38" aria-hidden="true" />
<p>没有匹配当前部门或员工搜索条件的考勤记录</p>
</div>
<div v-else class="table-wrap"> <div v-else class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@ -181,7 +255,7 @@ function rowKey(row: TodayAttendanceItem): string {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in rows" :key="rowKey(row)"> <tr v-for="row in filteredRows" :key="rowKey(row)">
<td> <td>
<strong>{{ row.employee_name }}</strong> <strong>{{ row.employee_name }}</strong>
<span>{{ row.employee_no || "-" }}</span> <span>{{ row.employee_no || "-" }}</span>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Banknote, Building2, Clock3, Loader2, RefreshCw, Search, WalletCards } from "@lucide/vue"; import { Banknote, Building2, Clock3, Loader2, Search, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { getPayrollReport } from "../api/reports"; import { getPayrollReport } from "../api/reports";
import type { PayrollReportResponse } from "../api/types"; import type { PayrollReportResponse } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
const salaryMonth = ref(new Date().toISOString().slice(0, 7)); const salaryMonth = ref(new Date().toISOString().slice(0, 7));
const report = ref<PayrollReportResponse | null>(null); const report = ref<PayrollReportResponse | null>(null);
@ -57,17 +58,13 @@ function modeLabel(value: string): string {
<form class="header-actions" @submit.prevent="loadReport"> <form class="header-actions" @submit.prevent="loadReport">
<label class="field inline-field"> <label class="field inline-field">
<span>工资月份</span> <span>工资月份</span>
<input v-model="salaryMonth" type="month" /> <DatePicker v-model="salaryMonth" mode="month" placeholder="请选择工资月份" />
</label> </label>
<button class="primary-button" type="submit" :disabled="loading"> <button class="primary-button" type="submit" :disabled="loading">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" /> <Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<Search v-else :size="18" aria-hidden="true" /> <Search v-else :size="18" aria-hidden="true" />
<span>{{ loading ? "查询中" : "查询" }}</span> <span>{{ loading ? "查询中" : "查询" }}</span>
</button> </button>
<button class="secondary-button" type="button" :disabled="loading" @click="loadReport">
<RefreshCw :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</form> </form>
</header> </header>

View File

@ -5,7 +5,6 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Save, Save,
Search, Search,
TimerReset, TimerReset,
@ -18,6 +17,7 @@ import { listEmployees } from "../api/employees";
import { ApiError } from "../api/http"; import { ApiError } from "../api/http";
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles"; import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types"; import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
import DatePicker from "../components/DatePicker.vue";
const employees = ref<EmployeeRecord[]>([]); const employees = ref<EmployeeRecord[]>([]);
const profiles = ref<SalaryProfileRecord[]>([]); const profiles = ref<SalaryProfileRecord[]>([]);
@ -210,11 +210,6 @@ function money(value: number): string {
<h1>薪资维护</h1> <h1>薪资维护</h1>
<p>关联员工维护包月计时计件试用期规则和独立加班费单价</p> <p>关联员工维护包月计时计件试用期规则和独立加班费单价</p>
</div> </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> </header>
<section class="salary-profile-summary-strip" aria-label="薪资档案指标"> <section class="salary-profile-summary-strip" aria-label="薪资档案指标">
@ -364,7 +359,7 @@ function money(value: number): string {
</label> </label>
<label class="field"> <label class="field">
<span>生效月份</span> <span>生效月份</span>
<input v-model="form.effective_month" type="month" /> <DatePicker v-model="form.effective_month" mode="month" placeholder="长期" />
</label> </label>
</div> </div>
</section> </section>

View File

@ -4,6 +4,7 @@ import {
Plus, Plus,
ShieldCheck, ShieldCheck,
UsersRound, UsersRound,
X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@ -17,6 +18,7 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const showUserModal = ref(false);
const form = reactive({ const form = reactive({
username: "", username: "",
@ -64,16 +66,8 @@ async function submit(): Promise<void> {
role: form.role, role: form.role,
}); });
users.value = [user, ...users.value.filter((item) => item.id !== user.id)]; users.value = [user, ...users.value.filter((item) => item.id !== user.id)];
Object.assign(form, { resetForm();
username: "", showUserModal.value = false;
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer",
});
successMessage.value = "用户已创建"; successMessage.value = "用户已创建";
} catch (error) { } catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败"; errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
@ -82,6 +76,32 @@ async function submit(): Promise<void> {
} }
} }
function resetForm(): void {
Object.assign(form, {
username: "",
display_name: "",
email: "",
phone: "",
department: "",
position_title: "",
password: "",
role: "viewer",
});
}
function openCreateModal(): void {
errorMessage.value = "";
successMessage.value = "";
resetForm();
showUserModal.value = true;
}
function closeUserModal(): void {
showUserModal.value = false;
errorMessage.value = "";
resetForm();
}
function roleLabel(role: string): string { function roleLabel(role: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
superuser: "超级用户", superuser: "超级用户",
@ -93,17 +113,12 @@ function roleLabel(role: string): string {
</script> </script>
<template> <template>
<div class="view-stack"> <div class="view-stack users-page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>用户管理</h1> <h1>用户管理</h1>
<p>账号角色与菜单权限</p> <p>账号角色与菜单权限</p>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading" @click="loadUsers">
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
<UsersRound v-else :size="18" aria-hidden="true" />
<span>刷新</span>
</button>
</header> </header>
<section class="summary-strip"> <section class="summary-strip">
@ -112,68 +127,19 @@ function roleLabel(role: string): string {
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" /> <StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
</section> </section>
<section class="panel"> <p v-if="errorMessage && !showUserModal" class="form-error">{{ errorMessage }}</p>
<div class="panel-header"> <p v-if="successMessage" class="form-success">{{ successMessage }}</p>
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
</div>
<form class="form-grid user-form" @submit.prevent="submit">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</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" />
<Plus 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"> <section class="panel table-panel user-list-panel">
<div class="panel-header"> <div class="panel-header user-list-header">
<div> <div>
<h2>用户列表</h2> <h2>用户列表</h2>
<p>{{ users.length }} 个账号</p> <p>{{ users.length }} 个账号</p>
</div> </div>
<button class="primary-button" type="button" @click="openCreateModal">
<Plus :size="18" aria-hidden="true" />
<span>新增用户</span>
</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
@ -220,5 +186,72 @@ function roleLabel(role: string): string {
<p>暂无用户</p> <p>暂无用户</p>
</div> </div>
</section> </section>
<Teleport to="body">
<div v-if="showUserModal" class="modal-backdrop user-modal-backdrop" @click.self="closeUserModal">
<section class="profile-modal user-modal" aria-label="用户维护">
<div class="modal-header">
<div>
<h2>新增用户</h2>
<p>角色决定可见菜单和操作权限</p>
</div>
<button class="icon-button" type="button" title="关闭" @click="closeUserModal">
<X :size="21" aria-hidden="true" />
</button>
</div>
<form class="user-modal-form" @submit.prevent="submit">
<div class="user-modal-fields">
<label class="field">
<span>用户名</span>
<input v-model="form.username" required minlength="2" type="text" />
</label>
<label class="field">
<span>显示名称</span>
<input v-model="form.display_name" type="text" />
</label>
<label class="field">
<span>初始密码</span>
<input v-model="form.password" required minlength="6" type="password" />
</label>
<label class="field">
<span>角色</span>
<select v-model="form.role">
<option value="viewer">查看权限</option>
<option value="manager">管理者</option>
<option value="superuser">超级用户</option>
</select>
</label>
<label class="field">
<span>邮箱</span>
<input v-model="form.email" type="email" />
</label>
<label class="field">
<span>手机号</span>
<input v-model="form.phone" type="tel" />
</label>
<label class="field">
<span>部门</span>
<input v-model="form.department" type="text" />
</label>
<label class="field">
<span>职位</span>
<input v-model="form.position_title" type="text" />
</label>
<p v-if="errorMessage" class="form-error user-modal-message">{{ errorMessage }}</p>
<div class="form-actions user-modal-actions">
<button class="secondary-button" type="button" @click="resetForm">清空</button>
<button class="secondary-button" type="button" @click="closeUserModal">取消</button>
<button class="primary-button" type="submit" :disabled="saving">
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
<Plus v-else :size="18" aria-hidden="true" />
<span>{{ saving ? "创建中" : "创建用户" }}</span>
</button>
</div>
</div>
</form>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@ -0,0 +1,36 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const routerPath = join(process.cwd(), "src/router/index.ts");
const routerSource = readFileSync(routerPath, "utf-8");
assertIncludes(routerSource, "await auth.loadSession();");
assertIncludes(routerSource, "catch");
assertIncludes(routerSource, "auth.logout();");
assertIncludes(routerSource, 'return { path: "/login", query: { redirect: to.fullPath } };');
assertBefore(routerSource, "try", "await auth.loadSession();");
assertBefore(routerSource, "await auth.loadSession();", "auth.logout();");
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}

View File

@ -30,7 +30,7 @@ assertIncludes(summaryStripBlock, "border: var(--card-border);");
assertIncludes(summaryStripBlock, "background: var(--card);"); assertIncludes(summaryStripBlock, "background: var(--card);");
assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);"); assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);");
const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.employee-inline-metric,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card"); const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card");
assertIncludes(innerCardBlock, "border: var(--card-inner-border);"); assertIncludes(innerCardBlock, "border: var(--card-inner-border);");
assertIncludes(innerCardBlock, "background: var(--card-inner-bg);"); assertIncludes(innerCardBlock, "background: var(--card-inner-bg);");
assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);"); assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);");

View File

@ -0,0 +1,134 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/CommissionsView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack commissions-page");
assertIncludes(view, "summary-strip commission-summary-strip");
assertIncludes(view, "commission-list-panel");
assertIncludes(view, "commission-filter-bar");
assertIncludes(view, "commission-keyword-field");
assertIncludes(view, "commission-month-field");
assertIncludes(view, "commission-search-button");
assertIncludes(view, "row-action-group commission-row-actions");
assertIncludes(view, "table-action-icon");
assertIncludes(view, "title=\"编辑提成\"");
assertIncludes(view, "title=\"删除提成\"");
assertIncludes(view, "sourceTypeLabel");
assertIncludes(view, "manual: \"手工录入\"");
assertIncludes(view, "excel: \"Excel导入\"");
assertIncludes(view, "dingtalk: \"钉钉同步\"");
assertIncludes(view, "{{ sourceTypeLabel(record.source_type) }}");
assertNotIncludes(view, "{{ record.source_type }}");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "showCommissionModal");
assertIncludes(view, "closeCommissionModal");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop commission-modal-backdrop");
assertIncludes(view, "profile-modal commission-modal");
assertIncludes(view, "aria-label=\"提成维护\"");
assertIncludes(view, "commission-form");
assertIncludes(view, "commission-form-grid");
assertIncludes(view, "commission-business-field");
assertIncludes(view, "commission-remark-field");
assertIncludes(view, "rows=\"2\"");
assertIncludes(view, "commission-form-actions");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "@click=\"closeCommissionModal\"");
assertNotIncludes(view, "field span-row");
assertNotIncludes(view, "commission-entry-panel");
const pageBlock = cssBlock(".commissions-page");
assertIncludes(pageBlock, "gap: 14px;");
const summaryBlock = cssBlock(".commission-summary-strip");
assertIncludes(summaryBlock, "display: grid;");
assertIncludes(summaryBlock, "grid-template-columns: repeat(3, minmax(0, 1fr));");
assertIncludes(summaryBlock, "padding: 8px;");
const summaryMetricBlock = cssBlock(".commission-summary-strip .summary-metric");
assertIncludes(summaryMetricBlock, "min-height: 58px;");
assertIncludes(summaryMetricBlock, "padding: 8px 12px;");
const summaryStrongBlock = cssBlock(".commission-summary-strip .summary-copy strong");
assertIncludes(summaryStrongBlock, "font-size: 20px;");
const listPanelBlock = cssBlock(".commission-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".commission-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const filterBlock = cssBlock(".commission-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(220px, 420px) 180px 104px minmax(0, 1fr);");
assertIncludes(filterBlock, "align-items: end;");
const searchButtonBlock = cssBlock(".commission-search-button");
assertIncludes(searchButtonBlock, "width: 104px;");
const rowActionsBlock = cssBlock(".commission-row-actions");
assertIncludes(rowActionsBlock, "display: inline-flex;");
assertIncludes(rowActionsBlock, "justify-content: flex-end;");
const actionIconBlock = cssBlock(".table-action-icon");
assertIncludes(actionIconBlock, "width: 34px;");
assertIncludes(actionIconBlock, "height: 34px;");
const modalBlock = cssBlock(".commission-modal");
assertIncludes(modalBlock, "width: min(900px, 100%);");
assertIncludes(modalBlock, "max-height: min(720px, calc(100vh - 48px));");
const formBlock = cssBlock(".commission-form");
assertIncludes(formBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const formGridBlock = cssBlock(".commission-form-grid");
assertIncludes(formGridBlock, "grid-template-columns: minmax(220px, 1.2fr) minmax(160px, 0.75fr) minmax(190px, 1fr) minmax(150px, 0.7fr);");
assertIncludes(formGridBlock, "gap: 10px 14px;");
const remarkFieldBlock = cssBlock(".commission-remark-field textarea");
assertIncludes(remarkFieldBlock, "min-height: 44px;");
assertIncludes(remarkFieldBlock, "max-height: 76px;");
const formActionsBlock = cssBlock(".commission-form-actions");
assertIncludes(formActionsBlock, "align-self: end;");
assertIncludes(formActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".commission-business-field,\n .commission-remark-field,\n .commission-form-actions {\n grid-column: auto;");
assertIncludes(css, ".commission-summary-strip,\n .commission-form-grid,\n .commission-filter-bar {\n grid-template-columns: 1fr;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,72 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/ConfigCenterView.vue"), "utf-8");
const template = extractTemplate(view);
assertIncludes(template, "薪资规则设置");
assertIncludes(template, "设置考勤、加班、迟到、缺卡和请假等工资核算规则。");
assertIncludes(template, "恢复默认规则");
assertIncludes(template, "规则清单");
assertIncludes(template, "全部规则");
assertIncludes(view, "attendance: \"考勤规则\"");
assertIncludes(view, "payroll: \"薪资规则\"");
for (const label of ["规则", "当前设置", "是否使用", "用途说明", "操作"]) {
assertIncludes(template, `<th>${label}</th>`, `规则表格应展示业务化表头:${label}`);
}
for (const technicalText of ["配置列表", "<th>配置</th>", "<th>分组</th>", "<th>类型</th>", "全部分组"]) {
assertNotIncludes(template, technicalText, `规则页不应展示技术化文案:${technicalText}`);
}
for (const technicalBinding of [
"{{ config.config_key }}",
"v-model=\"config.config_group\"",
"v-model=\"config.value_type\"",
"<option value=\"string\">string</option>",
"<option value=\"integer\">integer</option>",
"<option value=\"number\">number</option>",
"<option value=\"boolean\">boolean</option>",
"<option value=\"json\">json</option>",
]) {
assertNotIncludes(template, technicalBinding, `规则页不应暴露系统字段:${technicalBinding}`);
}
assertIncludes(view, "groupLabel(config.config_group)", "规则分组应转换成业务名称展示");
assertIncludes(view, "formatConfigValue(config)", "规则值应转换成用户能理解的展示方式");
assertIncludes(template, "weekday-toggle-group", "周末规则应使用星期选择,不应展示原始数字数组");
assertIncludes(view, "displayEditValue(config)", "复杂规则编辑值应转换成业务可读格式");
assertIncludes(view, "updateDisplayValue(config", "用户输入应再转换回系统保存值");
assertNotIncludes(template, "v-model=\"config.config_value\"", "不应直接把系统保存值暴露给用户编辑");
function extractTemplate(content: string): string {
const match = content.match(/<template>([\s\S]*)<\/template>/);
if (!match) {
throw new Error("缺少 template");
}
return match[1];
}
function assertIncludes(content: string, expected: string, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}

View File

@ -0,0 +1,88 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const component = readFileSync(join(root, "src/components/DatePicker.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const datePages = [
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"PayrollView.vue",
"RealtimeAttendanceView.vue",
"ReportsView.vue",
"SalaryProfilesView.vue",
];
for (const page of datePages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertIncludes(content, "DatePicker", `${page} 应使用统一 DatePicker 组件`);
assertNotIncludes(content, "type=\"month\"", `${page} 不应再使用浏览器原生月份选择器`);
assertNotIncludes(content, "type=\"date\"", `${page} 不应再使用浏览器原生日期选择器`);
}
assertIncludes(component, 'mode: "month" | "date"');
assertIncludes(component, "date-picker-panel");
assertIncludes(component, "<Teleport to=\"body\">", "日期面板应挂到 body避免被父级面板裁剪");
assertIncludes(component, "panelRef", "日期面板应独立追踪自身节点,保证弹层内部点击不会被当成外部点击");
assertIncludes(component, "panelStyle", "日期面板应使用运行时坐标定位到触发按钮附近");
assertIncludes(component, "updatePanelPosition", "日期面板打开、滚动或窗口变化时应重新定位");
assertIncludes(component, "month-grid");
assertIncludes(component, "calendar-grid");
assertIncludes(component, "emit(\"change\"");
assertIncludes(component, "本月");
assertIncludes(component, "今天");
const pickerBlock = cssBlock(".date-picker");
assertIncludes(pickerBlock, "position: relative;");
const triggerBlock = cssBlock(".date-picker-trigger");
assertIncludes(triggerBlock, "min-height: var(--control-height);");
assertIncludes(triggerBlock, "border: 1px solid var(--line);");
const panelBlock = cssBlock(".date-picker-panel");
assertIncludes(panelBlock, "position: fixed;");
assertIncludes(panelBlock, "max-height: min(420px, calc(100vh - 24px));");
assertIncludes(panelBlock, "overflow: auto;");
assertIncludes(panelBlock, "box-shadow: var(--shadow);");
const monthGridBlock = cssBlock(".month-grid");
assertIncludes(monthGridBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const calendarGridBlock = cssBlock(".calendar-grid");
assertIncludes(calendarGridBlock, "grid-template-columns: repeat(7, minmax(0, 1fr));");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string, message?: string): void {
if (!content.includes(expected)) {
throw new Error(message || `期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,103 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/EmployeesView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack employees-page");
assertIncludes(view, "employee-list-header");
assertIncludes(view, "employee-list-title");
assertIncludes(view, "employee-inline-metric");
assertNotIncludes(view, "employee-header-actions");
assertIncludes(view, "employee_info_keyword");
assertIncludes(view, "organization_keyword");
assertIncludes(view, "employee-filter-employee");
assertIncludes(view, "employee-filter-organization");
assertIncludes(view, "employee-filter-status");
assertIncludes(view, "employee-search-button");
assertIncludes(view, "employee-status-pill");
assertIncludes(view, "employee-status-dot");
assertNotIncludes(view, "placeholder=\"编号、姓名、钉钉ID、部门或岗位\"");
assertNotIncludes(view, "class=\"status-chip\"");
assertIncludes(view, "管理员工资料,工资核算会优先使用这里的信息。");
assertIncludes(view, "在职员工");
assertIncludes(view, "<em>人</em>");
assertIncludes(view, "<span>找员工</span>");
assertIncludes(view, "placeholder=\"输入姓名、编号或手机号\"");
assertIncludes(view, "<span>找部门/岗位</span>");
assertIncludes(view, "placeholder=\"输入部门或岗位名称\"");
assertIncludes(view, "<th>钉钉账号</th>");
assertIncludes(view, "employee.dingtalk_user_id || \"未绑定\"");
assertIncludes(view, "<span>钉钉账号</span>");
assertIncludes(view, "placeholder=\"用于匹配钉钉考勤,可不填\"");
assertNotIncludes(view, "钉钉用户ID");
assertNotIncludes(view, "<span>组织</span>");
assertNotIncludes(view, "参与薪资计算");
const pageBlock = cssBlock(".employees-page");
assertIncludes(pageBlock, "gap: 14px;");
const headerBlock = cssBlock(".employee-list-header");
assertIncludes(headerBlock, "display: grid;");
assertIncludes(headerBlock, "grid-template-columns: minmax(0, 1fr) auto;");
const titleBlock = cssBlock(".employee-list-title");
assertIncludes(titleBlock, "display: flex;");
assertIncludes(titleBlock, "align-items: center;");
const metricBlock = cssBlock(".employee-inline-metric");
assertIncludes(metricBlock, "min-height: 30px;");
assertIncludes(metricBlock, "box-shadow: none;");
const filterBlock = cssBlock(".employee-filter-bar");
assertIncludes(filterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const scopedFilterBlock = cssBlock(".table-filter-bar.employee-filter-bar");
assertIncludes(scopedFilterBlock, "grid-template-columns: minmax(180px, 260px) minmax(180px, 260px) minmax(130px, 160px) 104px minmax(0, 1fr);");
const statusBlock = cssBlock(".employee-status-pill");
assertIncludes(statusBlock, "display: inline-flex;");
assertIncludes(statusBlock, "min-width: 0;");
assertIncludes(statusBlock, "background: transparent;");
const dotBlock = cssBlock(".employee-status-dot");
assertIncludes(dotBlock, "width: 7px;");
assertIncludes(dotBlock, "height: 7px;");
assertIncludes(css, ".table-filter-bar.employee-filter-bar {\n grid-template-columns: 1fr;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -15,6 +15,10 @@ const root = process.cwd();
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8"); const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8"); const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8");
const appRootBlock = cssBlock("html,\nbody,\n#app");
assertIncludes(appRootBlock, "max-width: 100%;");
assertIncludes(appRootBlock, "overflow-x: hidden;");
const rootBlock = cssBlock(":root"); const rootBlock = cssBlock(":root");
assertIncludes(rootBlock, "--base-font-size: 14px;"); assertIncludes(rootBlock, "--base-font-size: 14px;");
assertIncludes(rootBlock, "--page-padding: 20px;"); assertIncludes(rootBlock, "--page-padding: 20px;");
@ -25,6 +29,9 @@ const pageHeaderBlock = cssBlock(".page-header");
assertIncludes(pageHeaderBlock, "min-height: 52px;"); assertIncludes(pageHeaderBlock, "min-height: 52px;");
assertIncludes(pageHeaderBlock, "padding-bottom: 2px;"); assertIncludes(pageHeaderBlock, "padding-bottom: 2px;");
const bodyBlock = cssBlock("body");
assertIncludes(bodyBlock, "overflow-x: hidden;");
const pageTitleBlock = cssBlock(".page-header h1"); const pageTitleBlock = cssBlock(".page-header h1");
assertIncludes(pageTitleBlock, "font-size: 26px;"); assertIncludes(pageTitleBlock, "font-size: 26px;");
@ -47,6 +54,12 @@ const tableFilterBlock = cssBlock(".table-filter-bar");
assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);"); assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);");
assertIncludes(tableFilterBlock, "background: var(--surface);"); assertIncludes(tableFilterBlock, "background: var(--surface);");
const tableWrapBlock = cssBlock(".table-wrap");
assertIncludes(tableWrapBlock, "max-width: 100%;");
assertIncludes(tableWrapBlock, "min-width: 0;");
assertIncludes(tableWrapBlock, "contain: inline-size;");
assertIncludes(tableWrapBlock, "overflow-x: auto;");
const tableHeaderBlock = cssBlock(".data-table th"); const tableHeaderBlock = cssBlock(".data-table th");
assertIncludes(tableHeaderBlock, "position: sticky;"); assertIncludes(tableHeaderBlock, "position: sticky;");
assertIncludes(tableHeaderBlock, "top: 0;"); assertIncludes(tableHeaderBlock, "top: 0;");
@ -55,8 +68,8 @@ const modalBackdropBlock = cssBlock(".modal-backdrop");
assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);"); assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);");
const employeeMetricBlock = cssBlock(".employee-inline-metric"); const employeeMetricBlock = cssBlock(".employee-inline-metric");
assertIncludes(employeeMetricBlock, "min-height: 42px;"); assertIncludes(employeeMetricBlock, "min-height: 30px;");
assertIncludes(employeeMetricBlock, "padding: 6px 10px;"); assertIncludes(employeeMetricBlock, "box-shadow: none;");
const userFormBlock = cssBlock(".user-form"); const userFormBlock = cssBlock(".user-form");
assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));"); assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");

View File

@ -0,0 +1,34 @@
declare const process: { cwd(): string };
declare function require(name: string): {
existsSync?: (path: string) => boolean;
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { existsSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!existsSync || !readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const loginView = readFileSync(join(root, "src/views/LoginView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
const backgroundPath = join(root, "src/assets/login-background.png");
assertIncludes(loginView, "login-background-layer");
assertIncludes(css, "url(\"./login-background.png\")");
assertIncludes(css, ".login-background-layer");
assertIncludes(css, ".login-visual::before");
assertIncludes(css, ".login-visual::after");
if (!existsSync(backgroundPath)) {
throw new Error("登录页背景图资产不存在");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}

View File

@ -0,0 +1,59 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
const loginScreen = cssBlock(".login-screen");
assertIncludes(loginScreen, "grid-template-columns: minmax(340px, 34vw) minmax(0, 1fr);");
const loginFormSide = cssBlock(".login-form-side");
assertIncludes(loginFormSide, "background: linear-gradient(180deg, #f8fafc 0%, #f3f6fa 100%);");
assertIncludes(loginFormSide, "box-shadow: inset -1px 0 0 rgba(var(--primary-rgb), 0.08);");
const loginCard = cssBlock(".login-card");
assertIncludes(loginCard, "width: min(360px, 100%);");
const loginVisual = cssBlock(".login-visual");
assertIncludes(loginVisual, "justify-items: start;");
assertNotIncludes(loginVisual, "border-left: 1px solid var(--line);");
const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "width: min(360px, 100%);");
assertIncludes(ledgerVisual, "background: rgba(255, 255, 255, 0.68);");
assertIncludes(ledgerVisual, "backdrop-filter: blur(10px);");
assertIncludes(ledgerVisual, "padding: 22px;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -0,0 +1,88 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const pages = [
"RealtimeAttendanceView.vue",
"ConfigCenterView.vue",
"ReportsView.vue",
"OperationLogsView.vue",
"UsersView.vue",
"CommissionsView.vue",
"MonthlyPayrollView.vue",
"EmployeesView.vue",
"SalaryProfilesView.vue",
"OrganizationView.vue",
];
for (const page of pages) {
const content = readFileSync(join(root, "src/views", page), "utf-8");
assertNotIncludes(content, "<span>刷新</span>", `${page} 不应展示无业务含义的刷新按钮`);
}
const realtimeView = readView("RealtimeAttendanceView.vue");
assertIncludes(realtimeView, "同步钉钉");
assertIncludes(realtimeView, "@change=\"loadTodayAttendance\"");
assertNotIncludes(realtimeView, "RefreshCw");
const reportsView = readView("ReportsView.vue");
assertIncludes(reportsView, "查询");
assertIncludes(reportsView, "@submit.prevent=\"loadReport\"");
assertNotIncludes(reportsView, "RefreshCw");
const configView = readView("ConfigCenterView.vue");
assertIncludes(configView, "恢复默认规则");
assertIncludes(configView, "保存");
assertNotIncludes(configView, "RefreshCw");
const commissionView = readView("CommissionsView.vue");
assertIncludes(commissionView, "导入Excel");
assertIncludes(commissionView, "新增提成");
assertNotIncludes(commissionView, "RefreshCw");
const monthlyView = readView("MonthlyPayrollView.vue");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "RefreshCw");
const employeesView = readView("EmployeesView.vue");
assertIncludes(employeesView, "新增员工");
assertIncludes(employeesView, "在职员工");
assertNotIncludes(employeesView, "RefreshCw");
const salaryProfilesView = readView("SalaryProfilesView.vue");
assertIncludes(salaryProfilesView, "薪资档案");
assertIncludes(salaryProfilesView, "编辑");
assertNotIncludes(salaryProfilesView, "RefreshCw");
const organizationView = readView("OrganizationView.vue");
assertIncludes(organizationView, "新增部门");
assertIncludes(organizationView, "新增岗位");
assertNotIncludes(organizationView, "RefreshCw");
function readView(filename: string): string {
return readFileSync(join(root, "src/views", filename), "utf-8");
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
if (content.includes(unexpected)) {
throw new Error(message || `不应包含 ${unexpected}`);
}
}

View File

@ -17,9 +17,15 @@ const realtimeApiPath = join(root, "src/api/realtimeAttendance.ts");
const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts"); const monthlyApiPath = join(root, "src/api/monthlyPayroll.ts");
const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue"); const realtimeViewPath = join(root, "src/views/RealtimeAttendanceView.vue");
const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue"); const monthlyViewPath = join(root, "src/views/MonthlyPayrollView.vue");
const resultsTablePath = join(root, "src/components/ResultsTable.vue");
const topBarPath = join(root, "src/components/TopBar.vue");
const dashboardViewPath = join(root, "src/views/DashboardView.vue");
const routerPath = join(root, "src/router/index.ts");
assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端"); assertTruthy(existsSync(realtimeApiPath), "缺少实时考勤 API 客户端");
assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端"); assertTruthy(existsSync(monthlyApiPath), "缺少月度核算 API 客户端");
assertTruthy(existsSync(resultsTablePath), "缺少工资明细表组件");
assertTruthy(existsSync(topBarPath), "缺少顶部导航组件");
const realtimeApi = readFileSync(realtimeApiPath, "utf-8"); const realtimeApi = readFileSync(realtimeApiPath, "utf-8");
assertIncludes(realtimeApi, "/api/attendance/realtime/today"); assertIncludes(realtimeApi, "/api/attendance/realtime/today");
@ -27,15 +33,146 @@ assertIncludes(realtimeApi, "/api/attendance/realtime/sync");
const monthlyApi = readFileSync(monthlyApiPath, "utf-8"); const monthlyApi = readFileSync(monthlyApiPath, "utf-8");
assertIncludes(monthlyApi, "/api/payroll/monthly/runs"); assertIncludes(monthlyApi, "/api/payroll/monthly/runs");
assertIncludes(monthlyApi, "/api/payroll/monthly/dingtalk");
assertIncludes(monthlyApi, "/api/payroll/monthly/excel");
const realtimeView = readFileSync(realtimeViewPath, "utf-8"); const realtimeView = readFileSync(realtimeViewPath, "utf-8");
assertNotIncludes(realtimeView, "待接入菜单"); assertNotIncludes(realtimeView, "待接入菜单");
assertNotIncludes(realtimeView, "待接入钉钉同步"); assertNotIncludes(realtimeView, "待接入钉钉同步");
assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准"); assertIncludes(realtimeView, "预估金额,最终以月度核算锁定结果为准");
assertIncludes(realtimeView, "realtime-page-header");
assertIncludes(realtimeView, "realtime-header-actions");
assertIncludes(realtimeView, "realtime-date-field");
assertIncludes(realtimeView, "realtime-overview-panel");
assertIncludes(realtimeView, "realtime-overview-header");
assertIncludes(realtimeView, "realtime-overview-title");
assertIncludes(realtimeView, "realtime-overview-stats");
assertIncludes(realtimeView, "syncStatusText");
assertIncludes(realtimeView, "formatSyncTime");
assertIncludes(realtimeView, "realtime-sync-chip");
assertNotIncludes(realtimeView, 'response?.sync_job_id ? "已同步" : "未同步"');
assertIncludes(realtimeView, "employeeQuery");
assertIncludes(realtimeView, "departmentFilter");
assertIncludes(realtimeView, "departmentOptions");
assertIncludes(realtimeView, "filteredRows");
assertIncludes(realtimeView, "hasRealtimeFilters");
assertIncludes(realtimeView, "clearRealtimeFilters");
assertIncludes(realtimeView, "realtime-table-filter-bar");
assertIncludes(realtimeView, "部门筛选");
assertIncludes(realtimeView, "员工搜索");
assertIncludes(realtimeView, "realtime-clear-filter");
assertIncludes(realtimeView, ":disabled=\"!hasRealtimeFilters\"");
assertIncludes(realtimeView, "@click=\"clearRealtimeFilters\"");
assertIncludes(realtimeView, "清空");
assertIncludes(realtimeView, "显示 {{ filteredRows.length }} / {{ rows.length }} 人");
assertIncludes(realtimeView, "v-for=\"row in filteredRows\"");
const topBar = readFileSync(topBarPath, "utf-8");
assertIncludes(topBar, "useRoute");
assertIncludes(topBar, "showGlobalSearch");
assertIncludes(topBar, 'route.path !== "/attendance/realtime"');
assertIncludes(topBar, 'v-if="showGlobalSearch"');
const monthlyView = readFileSync(monthlyViewPath, "utf-8"); const monthlyView = readFileSync(monthlyViewPath, "utf-8");
assertNotIncludes(monthlyView, "下一阶段接入"); assertNotIncludes(monthlyView, "下一阶段接入");
assertNotIncludes(monthlyView, "核算流程");
assertNotIncludes(monthlyView, "计算编号仅用于系统排查");
assertIncludes(monthlyView, "本月核算");
assertNotIncludes(monthlyView, "工资核算工作台");
assertIncludes(monthlyView, "同步钉钉核算");
assertIncludes(monthlyView, "导入 Excel 核算");
assertNotIncludes(monthlyView, "payroll-workbench");
assertNotIncludes(monthlyView, "payroll-flow-strip");
assertIncludes(monthlyView, "payroll-compact-toolbar");
assertIncludes(monthlyView, "payroll-toolbar-month");
assertIncludes(monthlyView, "payroll-toolbar-summary");
assertIncludes(monthlyView, "payroll-toolbar-actions");
assertIncludes(monthlyView, "payroll-toolbar-export");
assertIncludes(monthlyView, "payroll-toolbar-range");
assertIncludes(monthlyView, "payroll-detail-panel");
assertIncludes(monthlyView, "payroll-detail-summary");
assertIncludes(monthlyView, "payroll-secondary-grid");
assertIncludes(monthlyView, ":exceptions=\"detail.exceptions\"");
assertNotIncludes(monthlyView, "payroll-command-copy");
assertNotIncludes(monthlyView, "payroll-command-card");
assertNotIncludes(monthlyView, "payroll-command-step");
assertNotIncludes(monthlyView, "payroll-step-index");
assertNotIncludes(monthlyView, "payroll-period-row");
assertNotIncludes(monthlyView, "payroll-source-row");
assertNotIncludes(monthlyView, "payroll-source-copy");
assertNotIncludes(monthlyView, "payroll-summary-band");
assertNotIncludes(monthlyView, "核算操作");
assertNotIncludes(monthlyView, "1 选择月份");
assertNotIncludes(monthlyView, "2 取数核算");
assertNotIncludes(monthlyView, "3 核对工资");
assertNotIncludes(monthlyView, "4 锁定导出");
assertNotIncludes(monthlyView, "payroll-action-card");
assertNotIncludes(monthlyView, "payroll-result-panel");
assertIncludes(monthlyView, "锁定后本月工资不能重新计算"); assertIncludes(monthlyView, "锁定后本月工资不能重新计算");
assertIncludes(monthlyView, "核算历史");
assertIncludes(monthlyView, "查看明细");
assertIncludes(monthlyView, "技术信息");
assertIncludes(monthlyView, "<summary>技术信息</summary>");
assertNotIncludes(monthlyView, "请输入任务编号");
assertIncludes(monthlyView, "calculateMonthlyFromDingTalk");
assertIncludes(monthlyView, "calculateMonthlyFromExcel");
assertIncludes(monthlyView, "monthly_payroll:calculate");
assertNotIncludes(monthlyView, "monthly-source-strip");
assertIncludes(monthlyView, "核算月份");
assertIncludes(monthlyView, "同步范围");
assertIncludes(monthlyView, "Excel 文件月份");
assertIncludes(monthlyView, "请选择核算月份后再同步钉钉");
assertIncludes(monthlyView, "已根据文件识别核算月份");
assertIncludes(monthlyView, "生成 Excel 结果文件");
assertNotIncludes(monthlyView, "优先同步钉钉,也可导入钉钉月度汇总 Excel。");
assertNotIncludes(monthlyView, "选择 Excel 后会自动识别文件月份。");
assertNotIncludes(monthlyView, ">01<");
assertNotIncludes(monthlyView, ">02<");
assertBefore(monthlyView, "核算月份", "同步钉钉核算");
assertBefore(monthlyView, "工资明细", "核算历史");
assertBefore(monthlyView, "工资明细", "异常清单");
assertBefore(monthlyView, "payroll-compact-toolbar", "payroll-detail-panel");
assertBefore(monthlyView, "payroll-detail-panel", "payroll-secondary-grid");
const resultsTable = readFileSync(resultsTablePath, "utf-8");
assertIncludes(resultsTable, "embedded");
assertIncludes(resultsTable, "salary-detail-table");
assertIncludes(resultsTable, "salary-summary-toolbar");
assertIncludes(resultsTable, "salary-summary-table");
assertIncludes(resultsTable, "salary-detail-drawer");
assertIncludes(resultsTable, "filteredResults");
assertIncludes(resultsTable, "anomalyLabel");
assertIncludes(resultsTable, "查看构成");
assertIncludes(resultsTable, "部门");
assertIncludes(resultsTable, "异常");
assertIncludes(resultsTable, "实发工资");
assertBefore(resultsTable, "salary-summary-table", "salary-detail-drawer");
const dashboardView = readFileSync(dashboardViewPath, "utf-8");
assertNotIncludes(dashboardView, "/payroll/jobs");
assertNotIncludes(dashboardView, 'to="/payroll"');
assertNotIncludes(dashboardView, "readRecentJobs");
assertNotIncludes(dashboardView, "RecentPayrollJob");
assertIncludes(dashboardView, "listMonthlyRuns");
assertIncludes(dashboardView, "exportMonthlyRun");
assertNotIncludes(dashboardView, "payroll:dingtalk:calculate");
assertIncludes(dashboardView, "monthly_payroll:calculate");
assertNotIncludes(dashboardView, "任务编号");
assertNotIncludes(dashboardView, "任务记录");
assertNotIncludes(dashboardView, "最近任务");
assertNotIncludes(dashboardView, "任务来源");
assertNotIncludes(dashboardView, "暂无计算记录");
assertIncludes(dashboardView, "核算历史");
assertIncludes(dashboardView, "最近核算人数");
assertIncludes(dashboardView, "工资月份");
assertIncludes(dashboardView, "实发合计");
const router = readFileSync(routerPath, "utf-8");
assertIncludes(router, 'path: "payroll"');
assertIncludes(router, 'redirect: "/payroll/monthly"');
assertNotIncludes(router, "../views/PayrollView.vue");
assertNotIncludes(router, "component: PayrollView");
assertNotIncludes(router, 'meta: { permission: "payroll:excel:calculate" }');
function assertIncludes(content: string, expected: string): void { function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) { if (!content.includes(expected)) {
@ -54,3 +191,11 @@ function assertTruthy(value: boolean, message: string): void {
throw new Error(message); throw new Error(message);
} }
} }
function assertBefore(content: string, first: string, second: string): void {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(`期望 ${first} 出现在 ${second} 之前`);
}
}

View File

@ -0,0 +1,24 @@
import {
extractMonthFromExcelFilename,
formatMonthRange,
isSalaryMonth,
} from "../src/utils/payrollMonth";
const detected = extractMonthFromExcelFilename("宁波兆安流体科技有限公司_月度汇总_20260501-20260531.xlsx");
assertEqual(detected?.month || "", "2026-05");
assertEqual(detected?.rangeLabel || "", "2026/05/01 至 2026/05/31");
const dashed = extractMonthFromExcelFilename("考勤汇总_2026-06.xlsx");
assertEqual(dashed?.month || "", "2026-06");
assertEqual(formatMonthRange("2026-05"), "2026/05/01 至 2026/05/31");
assertEqual(formatMonthRange(""), "请选择核算月份");
assertEqual(String(isSalaryMonth("2026-05")), "true");
assertEqual(String(isSalaryMonth("")), "false");
assertEqual(String(isSalaryMonth("2026-13")), "false");
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}

View File

@ -26,10 +26,13 @@ assertEqual(
); );
assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime"); assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime");
assertEqual(sectionCodes("月度核算"), "monthly_payroll,jobs,commissions"); assertEqual(sectionCodes("月度核算"), "monthly_payroll,commissions");
assertEqual(sectionNames("月度核算"), "工资核算,提成录入");
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization"); assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
assertEqual(sectionCodes("报表分析"), "reports"); assertEqual(sectionCodes("报表分析"), "reports");
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs"); assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
assertEqual(hasMenu("payroll"), "false");
assertEqual(hasMenu("jobs"), "false");
function sectionCodes(title: string): string { function sectionCodes(title: string): string {
const section = sections.find((item) => item.title === title); const section = sections.find((item) => item.title === title);
@ -39,8 +42,20 @@ function sectionCodes(title: string): string {
return section.items.map((item) => item.code).join(","); return section.items.map((item) => item.code).join(",");
} }
function sectionNames(title: string): string {
const section = sections.find((item) => item.title === title);
if (!section) {
throw new Error(`找不到菜单分组:${title}`);
}
return section.items.map((item) => item.name).join(",");
}
function assertEqual(actual: string, expected: string): void { function assertEqual(actual: string, expected: string): void {
if (actual !== expected) { if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`); throw new Error(`期望 ${expected},实际 ${actual}`);
} }
} }
function hasMenu(code: string): string {
return String(sections.some((section) => section.items.some((item) => item.code === code)));
}

View File

@ -0,0 +1,84 @@
declare const process: { cwd(): string };
declare function require(name: string): {
readFileSync?: (path: string, encoding: string) => string;
join?: (...parts: string[]) => string;
};
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
if (!readFileSync || !join) {
throw new Error("测试运行环境缺少文件读取能力");
}
const root = process.cwd();
const view = readFileSync(join(root, "src/views/UsersView.vue"), "utf-8");
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
assertIncludes(view, "view-stack users-page");
assertIncludes(view, "showUserModal");
assertIncludes(view, "openCreateModal");
assertIncludes(view, "closeUserModal");
assertIncludes(view, "user-list-panel");
assertIncludes(view, "user-list-header");
assertIncludes(view, "@click=\"openCreateModal\"");
assertIncludes(view, "Teleport to=\"body\"");
assertIncludes(view, "modal-backdrop user-modal-backdrop");
assertIncludes(view, "profile-modal user-modal");
assertIncludes(view, "aria-label=\"用户维护\"");
assertIncludes(view, "user-modal-form");
assertIncludes(view, "user-modal-fields");
assertIncludes(view, "user-modal-actions");
assertIncludes(view, "@click=\"closeUserModal\"");
assertNotIncludes(view, "<section class=\"panel\">\n <div class=\"panel-header\">\n <div>\n <h2>新增用户</h2>");
const pageBlock = cssBlock(".users-page");
assertIncludes(pageBlock, "gap: 14px;");
const listPanelBlock = cssBlock(".user-list-panel");
assertIncludes(listPanelBlock, "overflow: hidden;");
const listHeaderBlock = cssBlock(".user-list-header");
assertIncludes(listHeaderBlock, "align-items: center;");
const modalBlock = cssBlock(".user-modal");
assertIncludes(modalBlock, "width: min(920px, 100%);");
assertIncludes(modalBlock, "max-height: min(760px, calc(100vh - 48px));");
const modalFormBlock = cssBlock(".user-modal-form");
assertIncludes(modalFormBlock, "padding: 14px var(--panel-padding) var(--panel-padding);");
const modalFieldsBlock = cssBlock(".user-modal-fields");
assertIncludes(modalFieldsBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const modalActionsBlock = cssBlock(".user-modal-actions");
assertIncludes(modalActionsBlock, "grid-column: 1 / -1;");
assertIncludes(modalActionsBlock, "justify-content: flex-end;");
assertIncludes(css, ".user-modal-fields {\n grid-template-columns: 1fr;");
assertIncludes(css, ".user-modal-actions {\n justify-content: stretch;");
function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern);
if (!match) {
throw new Error(`缺少样式块 ${selector}`);
}
return match[1];
}
function assertIncludes(content: string, expected: string): void {
if (!content.includes(expected)) {
throw new Error(`期望包含 ${expected}`);
}
}
function assertNotIncludes(content: string, unexpected: string): void {
if (content.includes(unexpected)) {
throw new Error(`不应包含 ${unexpected}`);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -20,7 +20,7 @@ assertNotIncludes(loginVisual, "rgba(var(--primary-rgb), 0.94)");
const ledgerVisual = cssBlock(".ledger-visual"); const ledgerVisual = cssBlock(".ledger-visual");
assertIncludes(ledgerVisual, "background: var(--card);"); assertIncludes(ledgerVisual, "background: var(--card);");
assertIncludes(ledgerVisual, "padding: 32px;"); assertIncludes(ledgerVisual, "padding: 22px;");
const appearancePanel = cssBlock(".appearance-panel"); const appearancePanel = cssBlock(".appearance-panel");
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));"); assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
@ -32,6 +32,131 @@ assertIncludes(primaryFocus, "box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16
const tableRows = cssBlock(".data-table tbody tr"); const tableRows = cssBlock(".data-table tbody tr");
assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;"); assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;");
const realtimeHeaderActions = cssBlock(".realtime-header-actions");
assertIncludes(realtimeHeaderActions, "display: grid;");
assertIncludes(realtimeHeaderActions, "grid-template-columns: minmax(210px, 242px) 140px;");
assertIncludes(realtimeHeaderActions, "align-items: end;");
const realtimeDateField = cssBlock(".realtime-date-field");
assertIncludes(realtimeDateField, "min-width: 0;");
const realtimeOverviewPanel = cssBlock(".realtime-overview-panel");
assertIncludes(realtimeOverviewPanel, "overflow: hidden;");
const realtimeOverviewHeader = cssBlock(".realtime-overview-header");
assertIncludes(realtimeOverviewHeader, "align-items: center;");
assertIncludes(realtimeOverviewHeader, "padding: 12px var(--panel-padding) 10px;");
const realtimeOverviewTitle = cssBlock(".realtime-overview-title");
assertIncludes(realtimeOverviewTitle, "display: flex;");
assertIncludes(realtimeOverviewTitle, "align-items: baseline;");
assertIncludes(realtimeOverviewTitle, "gap: 10px;");
const realtimeOverviewTitleText = cssBlock(".realtime-overview-title h2,\n.realtime-overview-title p");
assertIncludes(realtimeOverviewTitleText, "margin: 0;");
const realtimeOverviewStats = cssBlock(".realtime-overview-stats");
assertIncludes(realtimeOverviewStats, "padding-top: 0;");
const realtimeOverviewNumbers = cssBlock(".realtime-overview-stats .compact-stat strong");
assertIncludes(realtimeOverviewNumbers, "font-size: 24px;");
const realtimeSyncChip = cssBlock(".realtime-sync-chip");
assertIncludes(realtimeSyncChip, "min-width: 96px;");
assertIncludes(realtimeSyncChip, "justify-content: center;");
const realtimeTableFilterBar = cssBlock(".realtime-table-filter-bar");
assertIncludes(realtimeTableFilterBar, "display: grid;");
assertIncludes(realtimeTableFilterBar, "grid-template-columns: minmax(180px, 220px) minmax(240px, 1fr) 78px auto;");
assertIncludes(realtimeTableFilterBar, "align-items: end;");
assertIncludes(realtimeTableFilterBar, "padding: 10px var(--panel-padding);");
const realtimeClearFilter = cssBlock(".realtime-clear-filter");
assertIncludes(realtimeClearFilter, "min-height: var(--control-height);");
assertIncludes(realtimeClearFilter, "width: 78px;");
assertIncludes(realtimeClearFilter, "justify-self: start;");
assertIncludes(realtimeClearFilter, "padding: 0 12px;");
assertIncludes(realtimeClearFilter, "white-space: nowrap;");
const realtimeFilterResult = cssBlock(".realtime-filter-result");
assertIncludes(realtimeFilterResult, "justify-self: end;");
const payrollCompactToolbar = cssBlock(".payroll-compact-toolbar");
assertIncludes(payrollCompactToolbar, "grid-template-columns: minmax(190px, 240px) minmax(0, 1fr) auto;");
assertIncludes(payrollCompactToolbar, "padding: 12px var(--panel-padding);");
const payrollToolbarSummary = cssBlock(".payroll-toolbar-summary");
assertIncludes(payrollToolbarSummary, "grid-template-columns: minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(110px, 0.7fr) minmax(270px, 1.45fr);");
assertIncludes(payrollToolbarSummary, "border-inline: 1px solid var(--line-soft);");
const payrollToolbarRangeText = cssBlock(".payroll-toolbar-range strong,\n.payroll-toolbar-range small");
assertIncludes(payrollToolbarRangeText, "overflow: visible;");
assertIncludes(payrollToolbarRangeText, "white-space: normal;");
assertIncludes(payrollToolbarRangeText, "text-overflow: clip;");
const payrollToolbarActions = cssBlock(".payroll-toolbar-actions");
assertIncludes(payrollToolbarActions, "grid-template-columns: repeat(2, 138px);");
assertIncludes(payrollToolbarActions, "justify-content: end;");
const payrollToolbarActionButtons = cssBlock(".payroll-toolbar-actions .primary-button,\n.payroll-toolbar-actions .secondary-button");
assertIncludes(payrollToolbarActionButtons, "min-height: 34px;");
assertIncludes(payrollToolbarActionButtons, "padding: 0 10px;");
assertIncludes(payrollToolbarActionButtons, "font-size: 13px;");
const payrollDetailPanel = cssBlock(".payroll-detail-panel");
assertIncludes(payrollDetailPanel, "border-color: rgba(var(--accent-rgb), 0.2);");
assertIncludes(payrollDetailPanel, "overflow: hidden;");
const payrollDetailHeader = cssBlock(".payroll-detail-header");
assertIncludes(payrollDetailHeader, "grid-template-columns: minmax(0, 1fr) auto;");
const payrollDetailSummary = cssBlock(".payroll-detail-summary");
assertIncludes(payrollDetailSummary, "grid-template-columns: repeat(4, minmax(110px, auto));");
const salaryDetailTable = cssBlock(".salary-detail-table");
assertIncludes(salaryDetailTable, "font-size: 13px;");
assertIncludes(salaryDetailTable, "min-width: 1560px;");
const salarySummaryToolbar = cssBlock(".salary-summary-toolbar");
assertIncludes(salarySummaryToolbar, "grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(140px, 180px));");
const salarySummaryTable = cssBlock(".salary-summary-table");
assertIncludes(salarySummaryTable, "min-width: 860px;");
const salarySummaryTableCells = cssBlock(".salary-summary-table th,\n.salary-summary-table td");
assertIncludes(salarySummaryTableCells, "padding: 12px 14px;");
const salaryDetailDrawer = cssBlock(".salary-detail-drawer");
assertIncludes(salaryDetailDrawer, "grid-template-columns: repeat(4, minmax(0, 1fr));");
const salaryDetailDrawerHeader = cssBlock(".salary-detail-drawer-header");
assertIncludes(salaryDetailDrawerHeader, "grid-column: 1 / -1;");
const salaryDetailCells = cssBlock(".salary-detail-table th,\n.salary-detail-table td");
assertIncludes(salaryDetailCells, "padding: 10px 12px;");
const payrollSecondaryGrid = cssBlock(".payroll-secondary-grid");
assertIncludes(payrollSecondaryGrid, "grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);");
assertNotIncludes(css, ".payroll-workbench {");
assertNotIncludes(css, ".payroll-flow-strip");
assertNotIncludes(css, ".payroll-summary-band");
assertIncludes(css, "@media (max-width: 1320px)");
assertIncludes(css, ".payroll-compact-toolbar {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));");
assertIncludes(css, ".payroll-secondary-grid {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".payroll-toolbar-summary,\n .payroll-toolbar-actions,\n .payroll-detail-summary {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }");
assertIncludes(css, ".realtime-header-actions {\n grid-template-columns: 1fr;\n }");
assertIncludes(css, ".realtime-overview-title {\n align-items: flex-start;\n flex-direction: column;");
assertIncludes(css, ".realtime-table-filter-bar {\n grid-template-columns: 1fr;");
assertNotIncludes(css, ".payroll-command-step");
assertNotIncludes(css, ".payroll-step-index");
assertNotIncludes(css, ".payroll-command-copy");
assertNotIncludes(css, ".payroll-period-row");
assertNotIncludes(css, ".payroll-source-row");
assertNotIncludes(css, ".payroll-source-copy");
function cssBlock(selector: string): string { function cssBlock(selector: string): string {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m"); const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
const match = css.match(pattern); const match = css.match(pattern);