forked from jiaoly/financial_system
更新UI相关部分
This commit is contained in:
parent
faa09101a1
commit
f213848719
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
outputs/
|
||||||
|
uploads/
|
||||||
|
static/uploads/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
9
.env
Normal file
9
.env
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copy this file to .env and edit DATABASE_URL before running docker compose.
|
||||||
|
#
|
||||||
|
# If MySQL runs on the same host as Docker, use host.docker.internal.
|
||||||
|
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
|
||||||
|
# If MySQL runs on another server, use that server's reachable IP address or domain name.
|
||||||
|
DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||||
|
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
FRONTEND_PORT=5173
|
||||||
9
.env.docker.example
Normal file
9
.env.docker.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copy this file to .env and edit DATABASE_URL before running docker compose.
|
||||||
|
#
|
||||||
|
# If MySQL runs on the same host as Docker, use host.docker.internal.
|
||||||
|
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
|
||||||
|
# If MySQL runs on another server, use that server's reachable IP address or domain name.
|
||||||
|
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||||
|
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
FRONTEND_PORT=5173
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV APP_CONFIG_FILE=/app/config/app_settings.json
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY main.py .
|
||||||
|
COPY config ./config
|
||||||
|
COPY financial_system ./financial_system
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
RUN mkdir -p logs uploads outputs static/uploads/avatars
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
38
README.md
38
README.md
@ -85,6 +85,44 @@ cd frontend
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker 运行(外部 MySQL)
|
||||||
|
|
||||||
|
项目已提供后端镜像、前端 Nginx 镜像和 `docker-compose.yml`。数据库使用外部 MySQL,不会在 compose 里启动 MySQL 容器。
|
||||||
|
|
||||||
|
1. 准备环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.docker.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 修改 `.env` 里的 `DATABASE_URL`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=mysql+pymysql://用户名:密码@MySQL地址:3306/financial_system?charset=utf8mb4
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 MySQL 就运行在当前电脑,并且你使用 Docker Desktop,MySQL 地址通常写:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 MySQL 在另一台服务器,写那台服务器的内网 IP、外网 IP 或域名。不要在容器里使用 `localhost` 连接外部 MySQL,因为 `localhost` 指向的是后端容器本身。
|
||||||
|
|
||||||
|
3. 启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问:
|
||||||
|
|
||||||
|
- 前端系统:http://127.0.0.1:5173
|
||||||
|
- 后端接口:http://127.0.0.1:8000
|
||||||
|
- 接口文档:http://127.0.0.1:5173/docs
|
||||||
|
|
||||||
|
后端容器启动时会按外部 MySQL 自动建库、建表并初始化默认超级管理员。请确认外部 MySQL 已允许该账号远程连接,并且账号有创建数据库和建表权限。
|
||||||
|
|
||||||
## 统一配置
|
## 统一配置
|
||||||
|
|
||||||
后端配置统一放在:
|
后端配置统一放在:
|
||||||
|
|||||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: financial-system-backend
|
||||||
|
environment:
|
||||||
|
APP_CONFIG_FILE: /app/config/app_settings.json
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?Please set DATABASE_URL in .env}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./config/app_settings.json:/app/config/app_settings.json:ro
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./outputs:/app/outputs
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./static/uploads:/app/static/uploads
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)",
|
||||||
|
]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ""
|
||||||
|
container_name: financial-system-frontend
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
|
restart: unless-stopped
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
ARG VITE_API_BASE_URL=
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
File diff suppressed because it is too large
Load Diff
@ -11,14 +11,14 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="stat-card" :class="`tone-${tone || 'neutral'}`">
|
<section class="summary-metric" :class="`tone-${tone || 'neutral'}`">
|
||||||
<div class="stat-head">
|
<div class="summary-icon">
|
||||||
<div class="stat-icon">
|
<component :is="icon" :size="16" aria-hidden="true" />
|
||||||
<component :is="icon" :size="23" aria-hidden="true" />
|
|
||||||
</div>
|
</div>
|
||||||
<span v-if="meta" class="stat-meta">{{ meta }}</span>
|
<div class="summary-copy">
|
||||||
</div>
|
<span>{{ title }}</span>
|
||||||
<p>{{ title }}</p>
|
|
||||||
<strong>{{ value }}</strong>
|
<strong>{{ value }}</strong>
|
||||||
|
</div>
|
||||||
|
<em v-if="meta" class="summary-meta">{{ meta }}</em>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -744,48 +744,48 @@ function radiusVariables(radius: RadiusChoice): Record<string, string> {
|
|||||||
function densityVariables(density: DensityChoice): Record<string, string> {
|
function densityVariables(density: DensityChoice): Record<string, string> {
|
||||||
const densityMap: Record<DensityChoice, Record<string, string>> = {
|
const densityMap: Record<DensityChoice, Record<string, string>> = {
|
||||||
compact: {
|
compact: {
|
||||||
"--topbar-height": "64px",
|
"--topbar-height": "60px",
|
||||||
"--nav-height": "42px",
|
"--nav-height": "38px",
|
||||||
"--control-height": "40px",
|
"--control-height": "38px",
|
||||||
"--base-font-size": "15px",
|
"--base-font-size": "14px",
|
||||||
"--page-padding": "24px",
|
"--page-padding": "20px",
|
||||||
"--content-gap": "18px",
|
"--content-gap": "14px",
|
||||||
"--panel-padding": "20px",
|
"--panel-padding": "16px",
|
||||||
"--table-cell-y": "12px",
|
"--table-cell-y": "10px",
|
||||||
"--table-cell-x": "18px",
|
"--table-cell-x": "14px",
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
|
"--topbar-height": "66px",
|
||||||
|
"--nav-height": "44px",
|
||||||
|
"--control-height": "42px",
|
||||||
|
"--base-font-size": "15px",
|
||||||
|
"--page-padding": "26px",
|
||||||
|
"--content-gap": "18px",
|
||||||
|
"--panel-padding": "20px",
|
||||||
|
"--table-cell-y": "14px",
|
||||||
|
"--table-cell-x": "18px",
|
||||||
|
},
|
||||||
|
comfortable: {
|
||||||
"--topbar-height": "72px",
|
"--topbar-height": "72px",
|
||||||
"--nav-height": "48px",
|
"--nav-height": "50px",
|
||||||
"--control-height": "44px",
|
"--control-height": "46px",
|
||||||
"--base-font-size": "16px",
|
"--base-font-size": "16px",
|
||||||
"--page-padding": "32px",
|
"--page-padding": "32px",
|
||||||
"--content-gap": "24px",
|
"--content-gap": "24px",
|
||||||
"--panel-padding": "24px",
|
"--panel-padding": "24px",
|
||||||
"--table-cell-y": "18px",
|
"--table-cell-y": "18px",
|
||||||
"--table-cell-x": "24px",
|
"--table-cell-x": "22px",
|
||||||
},
|
|
||||||
comfortable: {
|
|
||||||
"--topbar-height": "78px",
|
|
||||||
"--nav-height": "54px",
|
|
||||||
"--control-height": "48px",
|
|
||||||
"--base-font-size": "16px",
|
|
||||||
"--page-padding": "36px",
|
|
||||||
"--content-gap": "28px",
|
|
||||||
"--panel-padding": "28px",
|
|
||||||
"--table-cell-y": "22px",
|
|
||||||
"--table-cell-x": "28px",
|
|
||||||
},
|
},
|
||||||
large: {
|
large: {
|
||||||
"--topbar-height": "88px",
|
"--topbar-height": "82px",
|
||||||
"--nav-height": "62px",
|
"--nav-height": "58px",
|
||||||
"--control-height": "56px",
|
"--control-height": "52px",
|
||||||
"--base-font-size": "18px",
|
"--base-font-size": "17px",
|
||||||
"--page-padding": "44px",
|
"--page-padding": "38px",
|
||||||
"--content-gap": "34px",
|
"--content-gap": "30px",
|
||||||
"--panel-padding": "34px",
|
"--panel-padding": "30px",
|
||||||
"--table-cell-y": "28px",
|
"--table-cell-y": "24px",
|
||||||
"--table-cell-x": "32px",
|
"--table-cell-x": "28px",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return densityMap[density];
|
return densityMap[density];
|
||||||
|
|||||||
@ -229,7 +229,7 @@ function money(value: number): string {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="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="当前页合计" />
|
||||||
|
|||||||
@ -95,7 +95,7 @@ async function saveConfig(config: SalaryConfigRecord): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<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="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" />
|
||||||
|
|||||||
@ -44,7 +44,7 @@ async function download(job: RecentPayrollJob): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="summary-strip">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="最近计算员工"
|
title="最近计算员工"
|
||||||
:value="String(latestJob?.employee_count || 0)"
|
:value="String(latestJob?.employee_count || 0)"
|
||||||
|
|||||||
@ -175,7 +175,7 @@ function targetLabel(log: OperationLog): string {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="summary-strip">
|
||||||
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
|
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
|
||||||
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
|
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
|
||||||
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
|
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
|
||||||
|
|||||||
@ -226,7 +226,7 @@ function money(value: number): 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 v-if="activeResult" class="stats-grid">
|
<section v-if="activeResult" class="summary-strip">
|
||||||
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" />
|
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" />
|
||||||
<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="迟到+缺卡" />
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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 StatCard from "../components/StatCard.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);
|
||||||
@ -49,7 +48,7 @@ function modeLabel(value: string): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="view-stack">
|
<div class="view-stack report-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>统计报表</h1>
|
<h1>统计报表</h1>
|
||||||
@ -72,26 +71,117 @@ function modeLabel(value: string): string {
|
|||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
<StatCard title="员工数" :value="String(totals.employee_count || 0)" :icon="WalletCards" tone="blue" :meta="salaryMonth" />
|
|
||||||
<StatCard title="应发工资" :value="money(totals.gross_salary)" :icon="Banknote" tone="neutral" meta="基础+提成+加班" />
|
<section class="panel table-panel report-ledger-workspace report-record-panel" aria-label="工资明细台账">
|
||||||
<StatCard title="实发工资" :value="money(totals.net_salary)" :icon="Banknote" tone="green" meta="扣款后" />
|
<div class="report-ledger-toolbar">
|
||||||
<StatCard title="扣款合计" :value="money(totals.deduction_amount)" :icon="Clock3" tone="red" meta="迟到+缺卡" />
|
<div class="report-ledger-title">
|
||||||
|
<span class="status-chip neutral">{{ report?.salary_month || salaryMonth }}</span>
|
||||||
|
<div>
|
||||||
|
<span class="report-ledger-eyebrow">工资汇总</span>
|
||||||
|
<h2>工资明细台账</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-ledger-totals" aria-label="工资汇总">
|
||||||
|
<div class="report-total-item">
|
||||||
|
<span>应发工资</span>
|
||||||
|
<strong>{{ money(totals.gross_salary) }}</strong>
|
||||||
|
<small>基础 + 提成 + 加班</small>
|
||||||
|
</div>
|
||||||
|
<div class="report-total-item">
|
||||||
|
<span>扣款合计</span>
|
||||||
|
<strong>{{ money(totals.deduction_amount) }}</strong>
|
||||||
|
<small>迟到 + 缺卡</small>
|
||||||
|
</div>
|
||||||
|
<div class="report-total-item is-primary">
|
||||||
|
<span>实发工资</span>
|
||||||
|
<strong>{{ money(totals.net_salary) }}</strong>
|
||||||
|
<small>财务最终金额</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-inline-metrics" aria-label="报表核心指标">
|
||||||
|
<span>
|
||||||
|
<WalletCards :size="15" aria-hidden="true" />
|
||||||
|
员工数
|
||||||
|
<strong>{{ totals.employee_count || 0 }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Banknote :size="15" aria-hidden="true" />
|
||||||
|
提成
|
||||||
|
<strong>{{ money(totals.commission_amount) }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock3 :size="15" aria-hidden="true" />
|
||||||
|
加班费
|
||||||
|
<strong>{{ money(totals.overtime_pay) }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock3 :size="15" aria-hidden="true" />
|
||||||
|
加班工时
|
||||||
|
<strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock3 :size="15" aria-hidden="true" />
|
||||||
|
请假工时
|
||||||
|
<strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-table-heading">
|
||||||
|
<div>
|
||||||
|
<h3>最近工资明细</h3>
|
||||||
|
<p>{{ report?.recent_records.length || 0 }} 条记录,按员工展示工资构成。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table report-record-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>员工</th>
|
||||||
|
<th>部门 / 月份</th>
|
||||||
|
<th>模式</th>
|
||||||
|
<th>基础工资</th>
|
||||||
|
<th>提成</th>
|
||||||
|
<th>加班费</th>
|
||||||
|
<th>扣款</th>
|
||||||
|
<th>应发</th>
|
||||||
|
<th>实发</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in report?.recent_records || []" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<strong>{{ item.employee_name }}</strong>
|
||||||
|
<span>{{ item.employee_no || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ item.department || "-" }}</strong>
|
||||||
|
<span>{{ item.salary_month }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
|
||||||
|
<td class="mono">{{ money(item.base_pay) }}</td>
|
||||||
|
<td class="mono">{{ money(item.commission_amount) }}</td>
|
||||||
|
<td class="mono">{{ money(item.overtime_pay) }}</td>
|
||||||
|
<td class="mono">{{ money(item.deduction_amount) }}</td>
|
||||||
|
<td class="mono">{{ money(item.gross_salary) }}</td>
|
||||||
|
<td class="mono strong report-net-cell">{{ money(item.net_salary) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
|
||||||
|
<p>暂无工资记录</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="report-detail-grid">
|
||||||
<StatCard title="提成合计" :value="money(totals.commission_amount)" :icon="Banknote" tone="green" meta="当月维护" />
|
|
||||||
<StatCard title="加班费合计" :value="money(totals.overtime_pay)" :icon="Clock3" tone="blue" meta="三类加班" />
|
|
||||||
<StatCard title="加班工时" :value="`${Number(attendance.overtime_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="neutral" meta="打卡推算" />
|
|
||||||
<StatCard title="请假工时" :value="`${Number(attendance.leave_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="red" meta="统一折算" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="dashboard-grid">
|
|
||||||
<div class="panel table-panel">
|
<div class="panel table-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>部门工资汇总</h2>
|
<h2>部门工资汇总</h2>
|
||||||
<p>{{ report?.departments.length || 0 }} 个部门</p>
|
<p>按部门查看人数、应发和实发成本。</p>
|
||||||
</div>
|
</div>
|
||||||
<Building2 :size="22" aria-hidden="true" />
|
<Building2 :size="22" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
@ -120,44 +210,30 @@ function modeLabel(value: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel table-panel">
|
<div class="panel report-attendance-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>最近工资记录</h2>
|
<h2>考勤工时拆分</h2>
|
||||||
<p>{{ report?.recent_records.length || 0 }} 条</p>
|
<p>加班和请假会影响剩余加班及工资结果。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Clock3 :size="22" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="report-attendance-list">
|
||||||
<table class="data-table">
|
<div class="report-attendance-card">
|
||||||
<thead>
|
<span>加班工时</span>
|
||||||
<tr>
|
<strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
|
||||||
<th>员工</th>
|
</div>
|
||||||
<th>模式</th>
|
<div class="report-attendance-card">
|
||||||
<th>基础</th>
|
<span>请假工时</span>
|
||||||
<th>提成</th>
|
<strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
|
||||||
<th>实发</th>
|
</div>
|
||||||
</tr>
|
<div class="report-attendance-card">
|
||||||
</thead>
|
<span>缺勤天数</span>
|
||||||
<tbody>
|
<strong>{{ Number(attendance.absence_days || 0).toFixed(1) }}</strong>
|
||||||
<tr v-for="item in report?.recent_records || []" :key="item.id">
|
|
||||||
<td>
|
|
||||||
<strong>{{ item.employee_name }}</strong>
|
|
||||||
<span>{{ item.employee_no || item.department }}</span>
|
|
||||||
</td>
|
|
||||||
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
|
|
||||||
<td class="mono">{{ money(item.base_pay) }}</td>
|
|
||||||
<td class="mono">{{ money(item.commission_amount) }}</td>
|
|
||||||
<td class="mono strong">{{ money(item.net_salary) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
|
|
||||||
<p>暂无工资记录</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import {
|
|||||||
HandCoins,
|
HandCoins,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
TimerReset,
|
TimerReset,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
|
X,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
@ -16,12 +18,12 @@ 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 StatCard from "../components/StatCard.vue";
|
|
||||||
|
|
||||||
const employees = ref<EmployeeRecord[]>([]);
|
const employees = ref<EmployeeRecord[]>([]);
|
||||||
const profiles = ref<SalaryProfileRecord[]>([]);
|
const profiles = ref<SalaryProfileRecord[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const showEditor = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const successMessage = ref("");
|
const successMessage = ref("");
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
@ -59,6 +61,9 @@ const avgOvertimeRate = computed(() => {
|
|||||||
}
|
}
|
||||||
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
|
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
|
||||||
});
|
});
|
||||||
|
const editorTitle = computed(() => {
|
||||||
|
return profiles.value.some((item) => item.employee_id === form.employee_id) ? "编辑薪资" : "新增薪资";
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
|
|
||||||
@ -108,6 +113,7 @@ async function submit(): Promise<void> {
|
|||||||
});
|
});
|
||||||
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
|
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
|
||||||
successMessage.value = "薪资档案已保存";
|
successMessage.value = "薪资档案已保存";
|
||||||
|
showEditor.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
|
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
|
||||||
} finally {
|
} finally {
|
||||||
@ -135,6 +141,18 @@ function editProfile(profile: SalaryProfileRecord): void {
|
|||||||
});
|
});
|
||||||
successMessage.value = "";
|
successMessage.value = "";
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
showEditor.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateProfile(): void {
|
||||||
|
resetForm();
|
||||||
|
successMessage.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
showEditor.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor(): void {
|
||||||
|
showEditor.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
@ -186,7 +204,7 @@ function money(value: number): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="view-stack">
|
<div class="view-stack salary-profile-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>薪资维护</h1>
|
<h1>薪资维护</h1>
|
||||||
@ -199,42 +217,130 @@ function money(value: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="salary-profile-summary-strip" aria-label="薪资档案指标">
|
||||||
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" />
|
<div class="salary-profile-summary-item">
|
||||||
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" />
|
<WalletCards :size="18" aria-hidden="true" />
|
||||||
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" />
|
<span>薪资档案</span>
|
||||||
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" />
|
<strong>{{ profiles.length }}</strong>
|
||||||
|
<small>已维护员工</small>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-summary-item">
|
||||||
|
<BadgeDollarSign :size="18" aria-hidden="true" />
|
||||||
|
<span>基础工资合计</span>
|
||||||
|
<strong>{{ money(totalBaseSalary) }}</strong>
|
||||||
|
<small>当前筛选</small>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-summary-item">
|
||||||
|
<HandCoins :size="18" aria-hidden="true" />
|
||||||
|
<span>提成合计</span>
|
||||||
|
<strong>{{ money(totalCommission) }}</strong>
|
||||||
|
<small>手工维护</small>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-summary-item">
|
||||||
|
<TimerReset :size="18" aria-hidden="true" />
|
||||||
|
<span>平均加班单价</span>
|
||||||
|
<strong>{{ money(avgOvertimeRate) }}</strong>
|
||||||
|
<small>独立维护</small>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<p v-if="errorMessage && !showEditor" class="form-error">{{ errorMessage }}</p>
|
||||||
<div class="panel-header">
|
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||||
|
|
||||||
|
<section class="panel salary-profile-list-panel">
|
||||||
|
<div class="panel-header salary-profile-list-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>查询薪资档案</h2>
|
<h2>薪资结构一览</h2>
|
||||||
<p>按员工编号、姓名、部门或岗位查询</p>
|
<p>{{ profiles.length }} 条薪资档案,按员工编号、姓名、部门或岗位查询</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form class="salary-profile-list-tools salary-profile-toolbar" @submit.prevent="loadData">
|
||||||
<form class="search-form" @submit.prevent="loadData">
|
|
||||||
<label class="field">
|
|
||||||
<span>关键字</span>
|
|
||||||
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
|
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
|
||||||
</label>
|
<button class="secondary-button compact-button" type="submit" :disabled="loading">
|
||||||
<button class="primary-button" type="submit" :disabled="loading">
|
<Loader2 v-if="loading" class="spin" :size="16" aria-hidden="true" />
|
||||||
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
<Search v-else :size="16" aria-hidden="true" />
|
||||||
<Search v-else :size="18" aria-hidden="true" />
|
|
||||||
<span>{{ loading ? "查询中" : "查询" }}</span>
|
<span>{{ loading ? "查询中" : "查询" }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="primary-button compact-button" type="button" @click="openCreateProfile">
|
||||||
|
<Plus :size="16" aria-hidden="true" />
|
||||||
|
<span>新增档案</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="empty-state">
|
||||||
|
<Loader2 class="spin" :size="18" aria-hidden="true" />
|
||||||
|
<p>正在加载薪资档案</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="profiles.length" class="table-wrap">
|
||||||
|
<table class="data-table salary-profile-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>员工</th>
|
||||||
|
<th>部门 / 岗位</th>
|
||||||
|
<th>薪资模式</th>
|
||||||
|
<th>计算规则</th>
|
||||||
|
<th>底薪 / 提成</th>
|
||||||
|
<th>加班单价</th>
|
||||||
|
<th>生效月份</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="profile in profiles" :key="profile.id">
|
||||||
|
<td>
|
||||||
|
<strong>{{ profile.employee.name }}</strong>
|
||||||
|
<span>{{ profile.employee.employee_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ profile.employee.department || "-" }}</strong>
|
||||||
|
<span>{{ profile.employee.position || "-" }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
|
||||||
|
<td class="mono strong">{{ profileRuleText(profile) }}</td>
|
||||||
|
<td class="salary-profile-pay-cell">
|
||||||
|
<strong>{{ money(profile.base_salary) }}</strong>
|
||||||
|
<span>提成 {{ money(profile.commission_amount) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="salary-profile-rate-stack">
|
||||||
|
<span>工作日 {{ money(profile.overtime_rate) }}/h</span>
|
||||||
|
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
|
||||||
|
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ profile.effective_month || "长期" }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="link-button" type="button" @click="editProfile(profile)">
|
||||||
|
<Pencil :size="15" aria-hidden="true" />
|
||||||
|
<span>编辑薪资</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>暂无薪资档案</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<div v-if="showEditor" class="modal-backdrop salary-profile-edit-modal" @click.self="closeEditor">
|
||||||
<div class="panel-header">
|
<section class="profile-modal salary-profile-edit-card" aria-label="薪资档案维护">
|
||||||
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>维护薪资档案</h2>
|
<h2>{{ editorTitle }}</h2>
|
||||||
<p>一名员工维护一条当前薪资档案</p>
|
<p>在弹出卡片内维护员工当前薪资档案和独立加班单价。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-button" type="button" @click="resetForm">清空</button>
|
<button class="icon-button" type="button" title="关闭" @click="closeEditor">
|
||||||
|
<X :size="18" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="form-grid salary-profile-form" @submit.prevent="submit">
|
<form class="salary-profile-form salary-profile-modal-form" @submit.prevent="submit">
|
||||||
|
<section class="salary-profile-modal-section">
|
||||||
|
<div class="salary-profile-modal-section-title">
|
||||||
|
<strong>基础档案</strong>
|
||||||
|
<span>员工、薪资模式和生效月份</span>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-modal-fields">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>关联员工</span>
|
<span>关联员工</span>
|
||||||
<select v-model.number="form.employee_id" required>
|
<select v-model.number="form.employee_id" required>
|
||||||
@ -256,6 +362,19 @@ function money(value: number): string {
|
|||||||
<span>基础工资/底薪</span>
|
<span>基础工资/底薪</span>
|
||||||
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>生效月份</span>
|
||||||
|
<input v-model="form.effective_month" type="month" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="salary-profile-modal-section">
|
||||||
|
<div class="salary-profile-modal-section-title">
|
||||||
|
<strong>计时 / 计件 / 试用期</strong>
|
||||||
|
<span>按员工类型维护对应口径</span>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-modal-fields">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>计时小时单价</span>
|
<span>计时小时单价</span>
|
||||||
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
||||||
@ -283,6 +402,15 @@ function money(value: number): string {
|
|||||||
<span>试用期固定金额</span>
|
<span>试用期固定金额</span>
|
||||||
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="salary-profile-modal-section">
|
||||||
|
<div class="salary-profile-modal-section-title">
|
||||||
|
<strong>提成与加班费</strong>
|
||||||
|
<span>提成和不同加班单价单独维护</span>
|
||||||
|
</div>
|
||||||
|
<div class="salary-profile-modal-fields">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>默认提成</span>
|
<span>默认提成</span>
|
||||||
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
||||||
@ -299,15 +427,24 @@ function money(value: number): string {
|
|||||||
<span>节假日加班单价</span>
|
<span>节假日加班单价</span>
|
||||||
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="salary-profile-modal-section salary-profile-modal-section-muted">
|
||||||
|
<div class="salary-profile-modal-section-title">
|
||||||
|
<strong>备注</strong>
|
||||||
|
<span>记录特殊薪资说明</span>
|
||||||
|
</div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>生效月份</span>
|
|
||||||
<input v-model="form.effective_month" type="month" />
|
|
||||||
</label>
|
|
||||||
<label class="field span-row">
|
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea v-model="form.remark" />
|
<textarea v-model="form.remark" />
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions align-right span-row">
|
</section>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="form-error salary-profile-modal-message">{{ errorMessage }}</p>
|
||||||
|
<div class="form-actions align-right salary-profile-modal-actions">
|
||||||
|
<button class="secondary-button" type="button" @click="resetForm">清空</button>
|
||||||
|
<button class="secondary-button" type="button" @click="closeEditor">取消</button>
|
||||||
<button class="primary-button" type="submit" :disabled="saving">
|
<button class="primary-button" type="submit" :disabled="saving">
|
||||||
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
||||||
<Save v-else :size="18" aria-hidden="true" />
|
<Save v-else :size="18" aria-hidden="true" />
|
||||||
@ -315,65 +452,7 @@ function money(value: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
|
||||||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel table-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>薪资档案列表</h2>
|
|
||||||
<p>{{ profiles.length }} 条薪资档案</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>员工</th>
|
|
||||||
<th>部门/岗位</th>
|
|
||||||
<th>薪资模式</th>
|
|
||||||
<th>计算规则</th>
|
|
||||||
<th>默认提成</th>
|
|
||||||
<th>加班单价</th>
|
|
||||||
<th>生效月份</th>
|
|
||||||
<th>备注</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="profile in profiles" :key="profile.id">
|
|
||||||
<td>
|
|
||||||
<strong>{{ profile.employee.name }}</strong>
|
|
||||||
<span>{{ profile.employee.employee_no }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ profile.employee.department || "-" }}</strong>
|
|
||||||
<span>{{ profile.employee.position || "-" }}</span>
|
|
||||||
</td>
|
|
||||||
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
|
|
||||||
<td class="mono strong">{{ profileRuleText(profile) }}</td>
|
|
||||||
<td class="mono">{{ money(profile.commission_amount) }}</td>
|
|
||||||
<td class="mono">
|
|
||||||
工作日 {{ money(profile.overtime_rate) }}/h
|
|
||||||
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
|
|
||||||
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ profile.effective_month || "长期" }}</td>
|
|
||||||
<td class="note-cell">{{ profile.remark || "-" }}</td>
|
|
||||||
<td>
|
|
||||||
<button class="link-button" type="button" @click="editProfile(profile)">
|
|
||||||
<Pencil :size="15" aria-hidden="true" />
|
|
||||||
<span>编辑</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-if="!profiles.length && !loading" class="empty-state">
|
|
||||||
<p>暂无薪资档案</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -106,7 +106,7 @@ function roleLabel(role: string): string {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="summary-strip">
|
||||||
<StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" />
|
<StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" />
|
||||||
<StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" />
|
<StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" />
|
||||||
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
|
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
|
||||||
|
|||||||
51
frontend/tests/cardSystemConsistency.test.ts
Normal file
51
frontend/tests/cardSystemConsistency.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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 rootBlock = cssBlock(":root");
|
||||||
|
assertIncludes(rootBlock, "--card-border: 1px solid var(--line);");
|
||||||
|
assertIncludes(rootBlock, "--card-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.035);");
|
||||||
|
assertIncludes(rootBlock, "--card-inner-border: 1px solid var(--line-soft);");
|
||||||
|
assertIncludes(rootBlock, "--card-inner-bg: var(--surface);");
|
||||||
|
assertIncludes(rootBlock, "--card-inner-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56);");
|
||||||
|
|
||||||
|
const mainCardBlock = cssBlock(".panel,\\s*\\n\\.action-card,\\s*\\n\\.salary-profile-summary-strip,\\s*\\n\\.organization-summary-strip");
|
||||||
|
assertIncludes(mainCardBlock, "border: var(--card-border);");
|
||||||
|
assertIncludes(mainCardBlock, "background: var(--card);");
|
||||||
|
assertIncludes(mainCardBlock, "box-shadow: var(--card-shadow);");
|
||||||
|
|
||||||
|
const summaryStripBlock = cssBlock(".summary-strip");
|
||||||
|
assertIncludes(summaryStripBlock, "border: var(--card-border);");
|
||||||
|
assertIncludes(summaryStripBlock, "background: var(--card);");
|
||||||
|
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");
|
||||||
|
assertIncludes(innerCardBlock, "border: var(--card-inner-border);");
|
||||||
|
assertIncludes(innerCardBlock, "background: var(--card-inner-bg);");
|
||||||
|
assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);");
|
||||||
|
|
||||||
|
function cssBlock(selector: string): string {
|
||||||
|
const pattern = new RegExp(`${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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/tests/compactSummaryStrip.test.ts
Normal file
69
frontend/tests/compactSummaryStrip.test.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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 css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
|
||||||
|
const statCard = readFileSync(join(root, "src/components/StatCard.vue"), "utf-8");
|
||||||
|
|
||||||
|
const compactPages = [
|
||||||
|
"DashboardView.vue",
|
||||||
|
"CommissionsView.vue",
|
||||||
|
"ConfigCenterView.vue",
|
||||||
|
"UsersView.vue",
|
||||||
|
"OperationLogsView.vue",
|
||||||
|
"PayrollView.vue",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const page of compactPages) {
|
||||||
|
const view = readFileSync(join(root, "src/views", page), "utf-8");
|
||||||
|
assertIncludes(view, "summary-strip", `${page} 应使用紧凑摘要条`);
|
||||||
|
assertNotIncludes(view, "<section class=\"stats-grid", `${page} 不应再使用大统计卡片区`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIncludes(statCard, "summary-metric", "StatCard 应输出紧凑指标项");
|
||||||
|
assertNotIncludes(statCard, "class=\"stat-card\"", "StatCard 不应再输出旧大卡片类名");
|
||||||
|
|
||||||
|
const summaryStripBlock = cssBlock(".summary-strip");
|
||||||
|
assertIncludes(summaryStripBlock, "display: flex;");
|
||||||
|
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
|
||||||
|
|
||||||
|
const summaryMetricBlock = cssBlock(".summary-metric");
|
||||||
|
assertIncludes(summaryMetricBlock, "min-height: 42px;");
|
||||||
|
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
|
||||||
|
|
||||||
|
assertNotIncludes(css, "min-height: 118px;", "旧大统计卡片高度不应保留");
|
||||||
|
|
||||||
|
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, "\\$&");
|
||||||
|
}
|
||||||
96
frontend/tests/globalUiAtmosphere.test.ts
Normal file
96
frontend/tests/globalUiAtmosphere.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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 css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
|
||||||
|
const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8");
|
||||||
|
|
||||||
|
const rootBlock = cssBlock(":root");
|
||||||
|
assertIncludes(rootBlock, "--base-font-size: 14px;");
|
||||||
|
assertIncludes(rootBlock, "--page-padding: 20px;");
|
||||||
|
assertIncludes(rootBlock, "--content-gap: 14px;");
|
||||||
|
assertIncludes(rootBlock, "--panel-padding: 16px;");
|
||||||
|
|
||||||
|
const pageHeaderBlock = cssBlock(".page-header");
|
||||||
|
assertIncludes(pageHeaderBlock, "min-height: 52px;");
|
||||||
|
assertIncludes(pageHeaderBlock, "padding-bottom: 2px;");
|
||||||
|
|
||||||
|
const pageTitleBlock = cssBlock(".page-header h1");
|
||||||
|
assertIncludes(pageTitleBlock, "font-size: 26px;");
|
||||||
|
|
||||||
|
const summaryStripBlock = cssBlock(".summary-strip");
|
||||||
|
assertIncludes(summaryStripBlock, "display: flex;");
|
||||||
|
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
|
||||||
|
|
||||||
|
const summaryMetricBlock = cssBlock(".summary-metric");
|
||||||
|
assertIncludes(summaryMetricBlock, "min-height: 42px;");
|
||||||
|
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
|
||||||
|
|
||||||
|
const summaryIconBlock = cssBlock(".summary-icon");
|
||||||
|
assertIncludes(summaryIconBlock, "width: 26px;");
|
||||||
|
assertIncludes(summaryIconBlock, "height: 26px;");
|
||||||
|
|
||||||
|
const panelHeaderBlock = cssBlock(".panel-header");
|
||||||
|
assertIncludes(panelHeaderBlock, "padding: 14px var(--panel-padding);");
|
||||||
|
|
||||||
|
const tableFilterBlock = cssBlock(".table-filter-bar");
|
||||||
|
assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);");
|
||||||
|
assertIncludes(tableFilterBlock, "background: var(--surface);");
|
||||||
|
|
||||||
|
const tableHeaderBlock = cssBlock(".data-table th");
|
||||||
|
assertIncludes(tableHeaderBlock, "position: sticky;");
|
||||||
|
assertIncludes(tableHeaderBlock, "top: 0;");
|
||||||
|
|
||||||
|
const modalBackdropBlock = cssBlock(".modal-backdrop");
|
||||||
|
assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);");
|
||||||
|
|
||||||
|
const employeeMetricBlock = cssBlock(".employee-inline-metric");
|
||||||
|
assertIncludes(employeeMetricBlock, "min-height: 42px;");
|
||||||
|
assertIncludes(employeeMetricBlock, "padding: 6px 10px;");
|
||||||
|
|
||||||
|
const userFormBlock = cssBlock(".user-form");
|
||||||
|
assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
|
||||||
|
|
||||||
|
const paginationActionsBlock = cssBlock(".table-pagination .form-actions");
|
||||||
|
assertIncludes(paginationActionsBlock, "justify-content: flex-end;");
|
||||||
|
assertIncludes(paginationActionsBlock, "background: transparent;");
|
||||||
|
|
||||||
|
assertIncludes(themeStore, '"--base-font-size": "14px"');
|
||||||
|
assertIncludes(themeStore, '"--page-padding": "20px"');
|
||||||
|
assertIncludes(themeStore, '"--content-gap": "14px"');
|
||||||
|
assertIncludes(themeStore, '"--panel-padding": "16px"');
|
||||||
|
|
||||||
|
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, "\\$&");
|
||||||
|
}
|
||||||
87
frontend/tests/reportsViewLayout.test.ts
Normal file
87
frontend/tests/reportsViewLayout.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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 view = readFileSync(join(process.cwd(), "src/views/ReportsView.vue"), "utf-8");
|
||||||
|
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||||
|
|
||||||
|
assertIncludes(view, "report-page");
|
||||||
|
assertIncludes(view, "report-ledger-workspace");
|
||||||
|
assertIncludes(view, "工资明细台账");
|
||||||
|
assertIncludes(view, "report-ledger-toolbar");
|
||||||
|
assertIncludes(view, "report-ledger-totals");
|
||||||
|
assertIncludes(view, "report-inline-metrics");
|
||||||
|
assertIncludes(view, "report-detail-grid");
|
||||||
|
assertIncludes(view, "report-record-panel");
|
||||||
|
assertIncludes(view, "最近工资明细");
|
||||||
|
assertNotIncludes(view, "<section class=\"stats-grid\">");
|
||||||
|
assertNotIncludes(view, "StatCard");
|
||||||
|
assertNotIncludes(view, "report-summary-panel");
|
||||||
|
assertNotIncludes(view, "report-metric-card");
|
||||||
|
assertNotIncludes(view, "先看明细再看拆分");
|
||||||
|
assertNotIncludes(view, "汇总金额和最近工资明细");
|
||||||
|
assertBefore(view, "report-ledger-workspace", "report-detail-grid");
|
||||||
|
assertBefore(view, "最近工资明细", "report-detail-grid");
|
||||||
|
|
||||||
|
assertIncludes(css, ".report-ledger-workspace");
|
||||||
|
assertIncludes(css, ".report-ledger-toolbar");
|
||||||
|
assertIncludes(css, ".report-ledger-totals");
|
||||||
|
assertIncludes(css, ".report-inline-metrics");
|
||||||
|
assertIncludes(css, ".report-record-panel");
|
||||||
|
assertIncludes(css, ".report-detail-grid");
|
||||||
|
assertNotIncludes(css, ".report-summary-panel");
|
||||||
|
assertNotIncludes(css, ".report-metric-card");
|
||||||
|
|
||||||
|
const ledgerToolbarBlock = cssBlock(".report-ledger-toolbar");
|
||||||
|
assertIncludes(ledgerToolbarBlock, "padding: 10px var(--panel-padding) 8px;");
|
||||||
|
|
||||||
|
const totalItemBlock = cssBlock(".report-total-item");
|
||||||
|
assertIncludes(totalItemBlock, "min-height: 52px;");
|
||||||
|
|
||||||
|
const inlineMetricsBlock = cssBlock(".report-inline-metrics");
|
||||||
|
assertIncludes(inlineMetricsBlock, "padding: 7px var(--panel-padding);");
|
||||||
|
|
||||||
|
const tableHeadingBlock = cssBlock(".report-table-heading");
|
||||||
|
assertIncludes(tableHeadingBlock, "padding: 9px var(--panel-padding) 6px;");
|
||||||
|
|
||||||
|
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 assertBefore(content: string, earlier: string, later: string): void {
|
||||||
|
const earlierIndex = content.indexOf(earlier);
|
||||||
|
const laterIndex = content.indexOf(later);
|
||||||
|
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||||
|
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
71
frontend/tests/salaryProfileAtmosphere.test.ts
Normal file
71
frontend/tests/salaryProfileAtmosphere.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
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 view = readFileSync(join(process.cwd(), "src/views/SalaryProfilesView.vue"), "utf-8");
|
||||||
|
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||||
|
|
||||||
|
assertIncludes(view, "salary-profile-page");
|
||||||
|
assertIncludes(view, "salary-profile-summary-strip");
|
||||||
|
assertIncludes(view, "salary-profile-summary-item");
|
||||||
|
assertIncludes(view, "salary-profile-toolbar");
|
||||||
|
assertIncludes(view, "salary-profile-modal-section");
|
||||||
|
assertIncludes(view, "salary-profile-modal-section-title");
|
||||||
|
assertBefore(view, "salary-profile-summary-strip", "薪资结构一览");
|
||||||
|
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
|
||||||
|
|
||||||
|
const summaryBlock = cssBlock(".salary-profile-summary-strip");
|
||||||
|
assertIncludes(summaryBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
|
||||||
|
assertIncludes(summaryBlock, "padding: 10px;");
|
||||||
|
assertNotIncludes(summaryBlock, "min-height: 178px;");
|
||||||
|
|
||||||
|
const toolbarBlock = cssBlock(".salary-profile-toolbar");
|
||||||
|
assertIncludes(toolbarBlock, "display: grid;");
|
||||||
|
assertIncludes(toolbarBlock, "grid-template-columns:");
|
||||||
|
|
||||||
|
const modalSectionBlock = cssBlock(".salary-profile-modal-section");
|
||||||
|
assertIncludes(modalSectionBlock, "border: var(--card-inner-border);");
|
||||||
|
assertIncludes(css, ".salary-profile-modal-section {\n display: grid;");
|
||||||
|
assertIncludes(css, "padding: 14px;");
|
||||||
|
assertIncludes(css, ".salary-profile-modal-fields");
|
||||||
|
assertIncludes(css, ".salary-profile-summary-strip,");
|
||||||
|
assertIncludes(css, ".salary-profile-toolbar");
|
||||||
|
|
||||||
|
function cssBlock(selector: string): string {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const pattern = new RegExp(`${escaped}\\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 assertBefore(content: string, earlier: string, later: string): void {
|
||||||
|
const earlierIndex = content.indexOf(earlier);
|
||||||
|
const laterIndex = content.indexOf(later);
|
||||||
|
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||||
|
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/tests/salaryProfileCardsLayout.test.ts
Normal file
50
frontend/tests/salaryProfileCardsLayout.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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 view = readFileSync(join(process.cwd(), "src/views/SalaryProfilesView.vue"), "utf-8");
|
||||||
|
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||||
|
|
||||||
|
assertIncludes(view, "salary-profile-list-tools");
|
||||||
|
assertIncludes(view, "salary-profile-edit-modal");
|
||||||
|
assertIncludes(view, "salary-profile-edit-card");
|
||||||
|
assertIncludes(view, "薪资结构一览");
|
||||||
|
assertIncludes(view, "编辑薪资");
|
||||||
|
assertIncludes(view, "<table class=\"data-table salary-profile-table\">");
|
||||||
|
assertNotIncludes(view, "salary-profile-card-grid");
|
||||||
|
assertNotIncludes(view, "salary-profile-form-panel");
|
||||||
|
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
|
||||||
|
|
||||||
|
assertIncludes(css, ".salary-profile-list-tools");
|
||||||
|
assertIncludes(css, ".salary-profile-table");
|
||||||
|
assertIncludes(css, ".salary-profile-edit-modal");
|
||||||
|
assertIncludes(css, ".salary-profile-edit-card");
|
||||||
|
|
||||||
|
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 assertBefore(content: string, earlier: string, later: string): void {
|
||||||
|
const earlierIndex = content.indexOf(earlier);
|
||||||
|
const laterIndex = content.indexOf(later);
|
||||||
|
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||||
|
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/tests/toastMessagesCss.test.ts
Normal file
46
frontend/tests/toastMessagesCss.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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 messageBlock = cssBlock(".form-error,\\s*\\n\\.form-success");
|
||||||
|
|
||||||
|
assertIncludes(messageBlock, "position: fixed;");
|
||||||
|
assertIncludes(messageBlock, "z-index: 120;");
|
||||||
|
assertIncludes(messageBlock, "left: 50%;");
|
||||||
|
assertIncludes(messageBlock, "right: auto;");
|
||||||
|
assertIncludes(messageBlock, "animation: toast-autohide");
|
||||||
|
assertIncludes(messageBlock, "pointer-events: none;");
|
||||||
|
assertNotIncludes(messageBlock, "right: 28px;");
|
||||||
|
assertIncludes(css, "@keyframes toast-autohide");
|
||||||
|
assertIncludes(css, "visibility: hidden;");
|
||||||
|
|
||||||
|
function cssBlock(selector: string): string {
|
||||||
|
const pattern = new RegExp(`${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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,13 @@ assertIncludes(ledgerVisual, "padding: 32px;");
|
|||||||
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));");
|
||||||
|
|
||||||
|
const primaryFocus = cssBlock(".primary-button:focus-visible");
|
||||||
|
assertIncludes(primaryFocus, "outline: 0;");
|
||||||
|
assertIncludes(primaryFocus, "box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16);");
|
||||||
|
|
||||||
|
const tableRows = cssBlock(".data-table tbody tr");
|
||||||
|
assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;");
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
55
tests/payroll_exceptions_test.py
Normal file
55
tests/payroll_exceptions_test.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from financial_system.domain.models import EmployeeAttendance, PayrollResult
|
||||||
|
from financial_system.domain.payroll_exceptions import detect_payroll_exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_payroll_exceptions_flags_core_payroll_risks() -> None:
|
||||||
|
result = PayrollResult(
|
||||||
|
employee=EmployeeAttendance(name="张三", employee_no="ZA0001", department="生产中心"),
|
||||||
|
salary_month="2026-06",
|
||||||
|
salary_mode="monthly",
|
||||||
|
attendance_days=0,
|
||||||
|
absence_days=22,
|
||||||
|
actual_work_hours=0,
|
||||||
|
punch_overtime_hours=1,
|
||||||
|
leave_hours=3,
|
||||||
|
remaining_overtime_hours=-2,
|
||||||
|
workday_overtime_hours=0,
|
||||||
|
weekend_overtime_hours=0,
|
||||||
|
holiday_overtime_hours=0,
|
||||||
|
minor_late_count=4,
|
||||||
|
major_late_count=1,
|
||||||
|
penalized_late_count=2,
|
||||||
|
late_deduction=60,
|
||||||
|
missing_card_count=1,
|
||||||
|
missing_card_deduction=20,
|
||||||
|
total_deduction=80,
|
||||||
|
base_salary=None,
|
||||||
|
base_pay=None,
|
||||||
|
commission_amount=0,
|
||||||
|
overtime_rate=0,
|
||||||
|
weekend_overtime_rate=0,
|
||||||
|
holiday_overtime_rate=0,
|
||||||
|
overtime_pay=0,
|
||||||
|
gross_salary=None,
|
||||||
|
net_salary=None,
|
||||||
|
note="未配置薪资档案",
|
||||||
|
)
|
||||||
|
|
||||||
|
exception_types = {item.exception_type for item in detect_payroll_exceptions([result])}
|
||||||
|
|
||||||
|
assert "missing_salary_profile" in exception_types
|
||||||
|
assert "missing_card" in exception_types
|
||||||
|
assert "late_penalty" in exception_types
|
||||||
|
assert "negative_remaining_overtime" in exception_types
|
||||||
|
assert "empty_attendance" in exception_types
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_detect_payroll_exceptions_flags_core_payroll_risks()
|
||||||
80
tests/realtime_attendance_service_test.py
Normal file
80
tests/realtime_attendance_service_test.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from financial_system.services.realtime_attendance_service import TodayAttendanceItemData, build_realtime_summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_realtime_summary_counts_today_status() -> None:
|
||||||
|
items = [
|
||||||
|
TodayAttendanceItemData(
|
||||||
|
employee_id=1,
|
||||||
|
employee_no="ZA0001",
|
||||||
|
employee_name="张三",
|
||||||
|
department="生产中心",
|
||||||
|
position="技工",
|
||||||
|
first_punch="08:58",
|
||||||
|
last_punch="18:30",
|
||||||
|
attendance_status="present",
|
||||||
|
late_minutes=0,
|
||||||
|
missing_card_count=0,
|
||||||
|
leave_hours=0,
|
||||||
|
today_overtime_hours=1,
|
||||||
|
month_remaining_overtime_hours=4,
|
||||||
|
estimated_net_salary=6800,
|
||||||
|
note="",
|
||||||
|
),
|
||||||
|
TodayAttendanceItemData(
|
||||||
|
employee_id=2,
|
||||||
|
employee_no="ZA0002",
|
||||||
|
employee_name="李四",
|
||||||
|
department="行政人事部",
|
||||||
|
position="人事专员",
|
||||||
|
first_punch="09:08",
|
||||||
|
last_punch="",
|
||||||
|
attendance_status="missing",
|
||||||
|
late_minutes=8,
|
||||||
|
missing_card_count=1,
|
||||||
|
leave_hours=2,
|
||||||
|
today_overtime_hours=0,
|
||||||
|
month_remaining_overtime_hours=-2,
|
||||||
|
estimated_net_salary=None,
|
||||||
|
note="缺少下班卡",
|
||||||
|
),
|
||||||
|
TodayAttendanceItemData(
|
||||||
|
employee_id=3,
|
||||||
|
employee_no="ZA0003",
|
||||||
|
employee_name="王五",
|
||||||
|
department="财务部",
|
||||||
|
position="财务",
|
||||||
|
first_punch="",
|
||||||
|
last_punch="",
|
||||||
|
attendance_status="unchecked",
|
||||||
|
late_minutes=0,
|
||||||
|
missing_card_count=0,
|
||||||
|
leave_hours=0,
|
||||||
|
today_overtime_hours=0,
|
||||||
|
month_remaining_overtime_hours=0,
|
||||||
|
estimated_net_salary=5000,
|
||||||
|
note="",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = build_realtime_summary(date(2026, 6, 22), items)
|
||||||
|
|
||||||
|
assert summary.employee_total == 3
|
||||||
|
assert summary.checked_in_count == 2
|
||||||
|
assert summary.unchecked_count == 1
|
||||||
|
assert summary.late_count == 1
|
||||||
|
assert summary.leave_count == 1
|
||||||
|
assert summary.missing_card_count == 1
|
||||||
|
assert summary.estimated_overtime_count == 1
|
||||||
|
assert summary.estimated_net_total == 11800
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_build_realtime_summary_counts_today_status()
|
||||||
47
tests/settings_env_test.py
Normal file
47
tests/settings_env_test.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from financial_system.core.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_environment_variable_overrides_config_file() -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
config_path = Path(temp_dir) / "app_settings.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"database": {
|
||||||
|
"url": "mysql+pymysql://json-user:json-pass@json-host:3306/financial_system?charset=utf8mb4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
docker_database_url = (
|
||||||
|
"mysql+pymysql://docker-user:docker-pass@mysql.example.com:3306/"
|
||||||
|
"financial_system?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"APP_CONFIG_FILE": str(config_path),
|
||||||
|
"DATABASE_URL": docker_database_url,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.database_url == docker_database_url
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_database_url_environment_variable_overrides_config_file()
|
||||||
Loading…
Reference in New Issue
Block a user