更新前端样式
This commit is contained in:
parent
d6f858b363
commit
960d2aa028
@ -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
53
frontend/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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]) => {
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
BIN
frontend/src/assets/login-background.png
Normal file
BIN
frontend/src/assets/login-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) {
|
||||||
|
try {
|
||||||
await auth.loadSession();
|
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";
|
||||||
|
|||||||
54
frontend/src/utils/payrollMonth.ts
Normal file
54
frontend/src/utils/payrollMonth.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@ -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">
|
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
@ -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)">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,13 +351,97 @@ 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>
|
||||||
|
<button v-if="selectedFile" class="link-button" type="button" :disabled="!canCalculate || isLocked || actionLoading === 'excel' || excelMonthMismatch" @click="importSelectedExcel()">
|
||||||
|
重新导入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
|
<input ref="fileInput" accept=".xlsx,.xls" hidden type="file" @change="handleExcelChange" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel payroll-detail-panel">
|
||||||
|
<div class="payroll-detail-header">
|
||||||
|
<div>
|
||||||
|
<h2>工资明细</h2>
|
||||||
|
<p>锁定后本月工资不能重新计算,只能导出。</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<ResultsTable v-if="detail?.results.length" :exceptions="detail.exceptions" :results="detail.results" embedded />
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<FileSpreadsheet :size="38" aria-hidden="true" />
|
||||||
|
<p>当前月份还没有核算结果,请导入 Excel 或同步钉钉并计算。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="detail?.exceptions.length" class="panel">
|
<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 class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>异常清单</h2>
|
<h2>异常清单</h2>
|
||||||
@ -249,29 +465,38 @@ function money(value: number): string {
|
|||||||
<strong>{{ item.employee_name || "-" }}</strong>
|
<strong>{{ item.employee_name || "-" }}</strong>
|
||||||
<span>{{ item.employee_no || "-" }}</span>
|
<span>{{ item.employee_no || "-" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.exception_type }}</td>
|
<td>{{ exceptionTypeText(item.exception_type) }}</td>
|
||||||
<td>{{ item.severity }}</td>
|
<td>{{ severityText(item.severity) }}</td>
|
||||||
<td>{{ item.message }}</td>
|
<td>{{ item.message }}</td>
|
||||||
<td>{{ item.status }}</td>
|
<td>{{ exceptionStatusText(item.status) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<details v-if="activeRun" class="panel monthly-technical-panel">
|
||||||
<div class="panel-header">
|
<summary>技术信息</summary>
|
||||||
|
<p class="technical-note">用于系统排查和审计,日常工资核对不需要使用这些字段。</p>
|
||||||
|
<dl class="detail-grid">
|
||||||
<div>
|
<div>
|
||||||
<h2>工资明细</h2>
|
<dt>系统记录ID</dt>
|
||||||
<p>锁定后本月工资不能重新计算,只能导出。</p>
|
<dd>{{ activeRun.id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<FileSpreadsheet :size="22" aria-hidden="true" />
|
<div>
|
||||||
|
<dt>来源任务编号</dt>
|
||||||
|
<dd class="mono">{{ activeRun.source_job_id || "-" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<ResultsTable v-if="detail?.results.length" :results="detail.results" />
|
<div>
|
||||||
<div v-else class="empty-state">
|
<dt>创建时间</dt>
|
||||||
<FileSpreadsheet :size="38" aria-hidden="true" />
|
<dd>{{ dateTime(activeRun.created_at) }}</dd>
|
||||||
<p>当前月份还没有核算结果,请导入 Excel 或同步钉钉并计算。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div>
|
||||||
|
<dt>更新时间</dt>
|
||||||
|
<dd>{{ dateTime(activeRun.updated_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
<h2>钉钉同步</h2>
|
||||||
|
<p>{{ dingtalkScopeText }}</p>
|
||||||
|
</div>
|
||||||
|
</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" />
|
<Loader2 v-if="loading === 'dingtalk'" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Play v-else :size="18" aria-hidden="true" />
|
<Play v-else :size="18" aria-hidden="true" />
|
||||||
<span>{{ loading === "dingtalk" ? "计算中" : "开始计算钉钉" }}</span>
|
<span>{{ loading === "dingtalk" ? "同步中" : "开始同步" }}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,6 +66,17 @@ 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)];
|
||||||
|
resetForm();
|
||||||
|
showUserModal.value = false;
|
||||||
|
successMessage.value = "用户已创建";
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
username: "",
|
username: "",
|
||||||
display_name: "",
|
display_name: "",
|
||||||
@ -74,12 +87,19 @@ async function submit(): Promise<void> {
|
|||||||
password: "",
|
password: "",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
});
|
});
|
||||||
successMessage.value = "用户已创建";
|
}
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "用户创建失败";
|
function openCreateModal(): void {
|
||||||
} finally {
|
errorMessage.value = "";
|
||||||
saving.value = false;
|
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 {
|
||||||
@ -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">
|
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
36
frontend/tests/authGuardRecovery.test.ts
Normal file
36
frontend/tests/authGuardRecovery.test.ts
Normal 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} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);");
|
||||||
|
|||||||
134
frontend/tests/commissionsLayout.test.ts
Normal file
134
frontend/tests/commissionsLayout.test.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
72
frontend/tests/configCenterFriendlyRules.test.ts
Normal file
72
frontend/tests/configCenterFriendlyRules.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend/tests/datePickerUnification.test.ts
Normal file
88
frontend/tests/datePickerUnification.test.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
103
frontend/tests/employeesLayout.test.ts
Normal file
103
frontend/tests/employeesLayout.test.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
@ -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));");
|
||||||
|
|||||||
34
frontend/tests/loginBackgroundImage.test.ts
Normal file
34
frontend/tests/loginBackgroundImage.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
frontend/tests/loginLayoutRefinement.test.ts
Normal file
59
frontend/tests/loginLayoutRefinement.test.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
88
frontend/tests/pageHeaderActions.test.ts
Normal file
88
frontend/tests/pageHeaderActions.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
frontend/tests/payrollMonthUtils.test.ts
Normal file
24
frontend/tests/payrollMonthUtils.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
|||||||
84
frontend/tests/usersLayout.test.ts
Normal file
84
frontend/tests/usersLayout.test.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user