2968484d2d
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record<string,unknown> - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper
1559 lines
81 KiB
HTML
1559 lines
81 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<meta http-equiv="Pragma" content="no-cache">
|
||
<meta http-equiv="Expires" content="0">
|
||
<title>TurboHR 验收测试清单</title>
|
||
<style>
|
||
:root {
|
||
--primary: #4f46e5;
|
||
--primary-light: #e0e7ff;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--gray: #6b7280;
|
||
--gray-light: #f3f4f6;
|
||
--border: #e5e7eb;
|
||
--radius: 0.625rem;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: #f8f9fb;
|
||
color: #1f2937;
|
||
line-height: 1.6;
|
||
}
|
||
/* Header */
|
||
.header {
|
||
position: sticky; top: 0; z-index: 100;
|
||
background: #fff; border-bottom: 1px solid var(--border);
|
||
padding: 12px 24px;
|
||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||
}
|
||
.header h1 { font-size: 1.1rem; font-weight: 700; white-space: nowrap; }
|
||
.header .verifier-section {
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
.header label { font-size: 0.875rem; color: var(--gray); }
|
||
.header input[type="text"] {
|
||
padding: 6px 12px; border: 1px solid var(--border);
|
||
border-radius: 6px; font-size: 0.875rem; width: 140px;
|
||
}
|
||
.header select {
|
||
padding: 6px 12px; border: 1px solid var(--border);
|
||
border-radius: 6px; font-size: 0.875rem;
|
||
}
|
||
.header .btn {
|
||
padding: 6px 14px; border: none; border-radius: 6px;
|
||
font-size: 0.8125rem; cursor: pointer; white-space: nowrap;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.header .btn:hover { opacity: 0.85; }
|
||
.header .btn-save { background: var(--primary); color: #fff; }
|
||
.header .btn-submit { background: var(--success); color: #fff; }
|
||
.header .btn-load { background: var(--primary-light); color: var(--primary); }
|
||
.header .btn-delete { background: #fee2e2; color: var(--danger); }
|
||
.header .btn-download { background: #d1fae5; color: var(--success); }
|
||
.header .btn-print { background: var(--gray-light); color: var(--gray); }
|
||
|
||
/* Stats bar */
|
||
.stats-bar {
|
||
background: #fff; border-bottom: 1px solid var(--border);
|
||
padding: 10px 24px; display: flex; gap: 20px; flex-wrap: wrap;
|
||
font-size: 0.8125rem;
|
||
}
|
||
.stats-bar .stat-item { display: flex; align-items: center; gap: 6px; }
|
||
.stats-bar .stat-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||
.stats-bar .stat-num { font-weight: 700; font-size: 1rem; }
|
||
|
||
/* Main content */
|
||
.main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
||
.domain-section {
|
||
background: #fff; border: 1px solid var(--border);
|
||
border-radius: var(--radius); margin-bottom: 16px; overflow: hidden;
|
||
}
|
||
.domain-header {
|
||
padding: 12px 20px; background: var(--gray-light);
|
||
display: flex; align-items: center; gap: 12px; cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.domain-header h2 { font-size: 0.9375rem; font-weight: 600; }
|
||
.domain-header .domain-count { font-size: 0.75rem; color: var(--gray); }
|
||
.domain-header .toggle { margin-left: auto; font-size: 0.75rem; color: var(--gray); }
|
||
.domain-header .domain-progress {
|
||
width: 80px; height: 6px; background: #e5e7eb; border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.domain-header .domain-progress-fill {
|
||
height: 100%; background: var(--primary); transition: width 0.3s;
|
||
}
|
||
|
||
.test-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||
.test-table th {
|
||
text-align: left; padding: 8px 16px; background: #fafafa;
|
||
border-bottom: 1px solid var(--border); font-weight: 600;
|
||
color: var(--gray); white-space: nowrap;
|
||
}
|
||
.test-table td {
|
||
padding: 8px 16px; border-bottom: 1px solid #f3f4f6;
|
||
vertical-align: top;
|
||
}
|
||
.test-table tr:hover { background: #fafbfc; }
|
||
.test-table .col-num { width: 50px; color: var(--gray); text-align: center; }
|
||
.test-table .col-func { font-weight: 600; min-width: 140px; }
|
||
.test-table .col-steps { min-width: 200px; color: #4b5563; }
|
||
.test-table .col-expected { min-width: 200px; color: #4b5563; }
|
||
.test-table .col-result { white-space: nowrap; }
|
||
.test-table .col-remark { min-width: 100px; }
|
||
|
||
/* Result radio buttons */
|
||
.result-radio { display: inline-flex; gap: 4px; }
|
||
.result-radio label {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 28px; height: 28px; border-radius: 6px; cursor: pointer;
|
||
border: 1px solid var(--border); font-size: 0.875rem;
|
||
transition: all 0.15s; user-select: none;
|
||
}
|
||
.result-radio input { display: none; }
|
||
.result-radio label.pass { color: var(--success); }
|
||
.result-radio label.fail { color: var(--danger); }
|
||
.result-radio label.partial { color: var(--warning); }
|
||
.result-radio label.untested { color: var(--gray); }
|
||
.result-radio input:checked + label.pass { background: #d1fae5; border-color: var(--success); }
|
||
.result-radio input:checked + label.fail { background: #fee2e2; border-color: var(--danger); }
|
||
.result-radio input:checked + label.partial { background: #fef3c7; border-color: var(--warning); }
|
||
.result-radio input:checked + label.untested { background: #f3f4f6; border-color: var(--gray); }
|
||
|
||
.remark-input {
|
||
width: 100%; padding: 4px 8px; border: 1px solid var(--border);
|
||
border-radius: 4px; font-size: 0.75rem;
|
||
}
|
||
|
||
/* Summary table */
|
||
.summary-section {
|
||
background: #fff; border: 1px solid var(--border);
|
||
border-radius: var(--radius); margin-top: 24px; overflow: hidden;
|
||
}
|
||
.summary-section h2 {
|
||
padding: 12px 20px; background: var(--gray-light);
|
||
font-size: 0.9375rem; font-weight: 600;
|
||
}
|
||
.summary-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||
.summary-table th, .summary-table td {
|
||
padding: 8px 16px; border-bottom: 1px solid #f3f4f6; text-align: center;
|
||
}
|
||
.summary-table th { background: #fafafa; font-weight: 600; color: var(--gray); }
|
||
.summary-table td:first-child { text-align: left; }
|
||
.summary-table .total-row { font-weight: 700; background: var(--primary-light); }
|
||
|
||
/* Conclusion */
|
||
.conclusion-section {
|
||
background: #fff; border: 1px solid var(--border);
|
||
border-radius: var(--radius); margin-top: 16px; padding: 20px;
|
||
}
|
||
.conclusion-section h2 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 12px; }
|
||
.conclusion-section .field { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
|
||
.conclusion-section label { font-size: 0.8125rem; color: var(--gray); min-width: 80px; }
|
||
.conclusion-section input[type="text"] {
|
||
padding: 6px 12px; border: 1px solid var(--border);
|
||
border-radius: 6px; font-size: 0.875rem; flex: 1;
|
||
}
|
||
.conclusion-section textarea {
|
||
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
|
||
border-radius: 6px; font-size: 0.8125rem; resize: vertical; min-height: 60px;
|
||
}
|
||
.conclusion-section .checkbox-group { display: flex; gap: 20px; }
|
||
.conclusion-section .checkbox-group label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||
|
||
/* Toast */
|
||
.toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||
background: #1f2937; color: #fff; padding: 10px 24px;
|
||
border-radius: 8px; font-size: 0.875rem; z-index: 200;
|
||
opacity: 0; transition: opacity 0.3s; pointer-events: none;
|
||
}
|
||
.toast.show { opacity: 1; }
|
||
|
||
/* Collapsed state */
|
||
.domain-section.collapsed .test-table { display: none; }
|
||
|
||
/* Toolbar */
|
||
.toolbar {
|
||
background: #fff; border-bottom: 1px solid var(--border);
|
||
padding: 8px 24px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||
}
|
||
.toolbar .tool-btn {
|
||
padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||
font-size: 0.75rem; cursor: pointer; background: #fff; color: #4b5563;
|
||
transition: all 0.15s; white-space: nowrap;
|
||
}
|
||
.toolbar .tool-btn:hover { background: var(--gray-light); }
|
||
.toolbar .tool-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||
.toolbar .tool-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
||
.toolbar .tool-search {
|
||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||
font-size: 0.75rem; width: 160px;
|
||
}
|
||
.toolbar .tool-info { font-size: 0.6875rem; color: var(--gray); margin-left: auto; }
|
||
|
||
/* Overall progress bar */
|
||
.overall-progress {
|
||
height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden;
|
||
}
|
||
.overall-progress-fill {
|
||
height: 100%; background: linear-gradient(90deg, var(--primary), var(--success));
|
||
transition: width 0.4s; width: 0%;
|
||
}
|
||
|
||
/* Row highlight for filtered */
|
||
.test-table tr.filtered-out { display: none; }
|
||
.test-table tr.row-fail { background: #fef2f2; }
|
||
.test-table tr.row-partial { background: #fffbeb; }
|
||
|
||
/* Screenshot upload */
|
||
.screenshot-cell { min-width: 60px; }
|
||
.screenshot-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid var(--border); }
|
||
.screenshot-upload {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 28px; height: 28px; border: 1px dashed var(--border); border-radius: 4px;
|
||
cursor: pointer; color: var(--gray); font-size: 1rem;
|
||
}
|
||
.screenshot-upload:hover { border-color: var(--primary); color: var(--primary); }
|
||
|
||
/* Signature */
|
||
.signature-section {
|
||
background: #fff; border: 1px solid var(--border);
|
||
border-radius: var(--radius); margin-top: 16px; padding: 20px;
|
||
}
|
||
.signature-section h2 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 12px; }
|
||
.signature-box {
|
||
border: 1px dashed var(--border); border-radius: 8px;
|
||
min-height: 80px; display: flex; align-items: center; justify-content: center;
|
||
color: var(--gray); font-size: 0.8125rem; cursor: crosshair;
|
||
position: relative; background: #fafafa;
|
||
}
|
||
.signature-box canvas { width: 100%; height: 80px; }
|
||
.signature-box .sig-clear {
|
||
position: absolute; top: 4px; right: 8px; font-size: 0.6875rem;
|
||
color: var(--danger); cursor: pointer; background: #fff; padding: 2px 6px;
|
||
border-radius: 4px; border: 1px solid var(--border);
|
||
}
|
||
|
||
/* Autocomplete dropdown */
|
||
.autocomplete-wrap { position: relative; display: inline-block; }
|
||
.autocomplete-list {
|
||
position: absolute; top: 100%; left: 0; right: 0; z-index: 300;
|
||
background: #fff; border: 1px solid var(--border); border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-height: 200px; overflow-y: auto;
|
||
display: none;
|
||
}
|
||
.autocomplete-list.show { display: block; }
|
||
.autocomplete-item {
|
||
padding: 6px 12px; font-size: 0.8125rem; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||
}
|
||
.autocomplete-item:hover, .autocomplete-item.active { background: var(--primary-light); }
|
||
.autocomplete-item .ac-name { font-weight: 500; }
|
||
.autocomplete-item .ac-meta { font-size: 0.6875rem; color: var(--gray); }
|
||
.autocomplete-item .ac-del { color: var(--danger); font-size: 0.75rem; cursor: pointer; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 4px; }
|
||
.autocomplete-item .ac-del:hover { background: #fee2e2; }
|
||
.autocomplete-empty { padding: 8px 12px; font-size: 0.75rem; color: var(--gray); text-align: center; }
|
||
|
||
@media print {
|
||
.header, .stats-bar, .toolbar { display: none !important; }
|
||
.domain-section { break-inside: avoid; }
|
||
.domain-section.collapsed .test-table { display: table !important; }
|
||
.result-radio label { border: 1px solid #ccc; }
|
||
.btn, .tool-btn, .sig-clear, .screenshot-upload { display: none !important; }
|
||
.overall-progress { display: none !important; }
|
||
.signature-box { border: 1px solid #333; }
|
||
.main { padding: 0; }
|
||
body { background: #fff; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Header -->
|
||
<div class="header">
|
||
<h1>TurboHR 验收测试清单</h1>
|
||
<div class="verifier-section">
|
||
<label>验收人:</label>
|
||
<div class="autocomplete-wrap">
|
||
<input type="text" id="verifierName" placeholder="输入姓名" oninput="onVerifierChange()" onkeydown="onVerifierKeydown(event)" autocomplete="off">
|
||
<div class="autocomplete-list" id="autocompleteList"></div>
|
||
</div>
|
||
<select id="savedSelect" onchange="loadSaved(this.value)">
|
||
<option value="">-- 加载已保存 --</option>
|
||
</select>
|
||
<button class="btn btn-save" onclick="saveCurrent()">保存</button>
|
||
<button class="btn btn-submit" onclick="submitReport()">提交报告</button>
|
||
<button class="btn btn-delete" onclick="deleteSaved()">删除当前</button>
|
||
<button class="btn btn-download" onclick="downloadCSV()">下载CSV</button>
|
||
<button class="btn btn-download" onclick="downloadExcel()">下载Excel</button>
|
||
<button class="btn btn-print" onclick="exportPDF()">导出PDF</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats bar -->
|
||
<div class="stats-bar" id="statsBar">
|
||
<div class="stat-item"><span class="stat-dot" style="background:#10b981"></span>通过 <span class="stat-num" id="statPass">0</span></div>
|
||
<div class="stat-item"><span class="stat-dot" style="background:#ef4444"></span>失败 <span class="stat-num" id="statFail">0</span></div>
|
||
<div class="stat-item"><span class="stat-dot" style="background:#f59e0b"></span>部分通过 <span class="stat-num" id="statPartial">0</span></div>
|
||
<div class="stat-item"><span class="stat-dot" style="background:#9ca3af"></span>未测试 <span class="stat-num" id="statUntested">0</span></div>
|
||
<div class="stat-item" style="margin-left:auto">总计 <span class="stat-num" id="statTotal">0</span></div>
|
||
<div class="stat-item">完成率 <span class="stat-num" id="statRate">0%</span></div>
|
||
</div>
|
||
|
||
<!-- Overall progress bar -->
|
||
<div class="overall-progress"><div class="overall-progress-fill" id="overallProgress"></div></div>
|
||
|
||
<!-- Toolbar -->
|
||
<div class="toolbar">
|
||
<button class="tool-btn" onclick="expandAll()">展开全部</button>
|
||
<button class="tool-btn" onclick="collapseAll()">折叠全部</button>
|
||
<div class="tool-divider"></div>
|
||
<button class="tool-btn" onclick="setFilter('all')" id="filterAll">全部</button>
|
||
<button class="tool-btn" onclick="setFilter('untested')" id="filterUntested">未测试</button>
|
||
<button class="tool-btn" onclick="setFilter('fail')" id="filterFail">失败</button>
|
||
<button class="tool-btn" onclick="setFilter('partial')" id="filterPartial">部分通过</button>
|
||
<div class="tool-divider"></div>
|
||
<input type="text" class="tool-search" id="searchInput" placeholder="搜索功能/步骤..." oninput="onSearch()">
|
||
<div class="tool-divider"></div>
|
||
<button class="tool-btn" onclick="markAllPass()" title="将所有未测试项标记为通过">全部标通过</button>
|
||
<span class="tool-info">快捷键: P=通过 F=失败 W=部分通过 U=未测试 ↑↓=切换行</span>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div class="main" id="mainContent"></div>
|
||
|
||
<!-- Toast -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ========== 测试数据 ==========
|
||
const DOMAINS = [
|
||
{ name: "1. 认证与账户", items: [
|
||
{ id:"1.1", func:"管理端登录(正确凭证)", steps:"输入手机号+密码,点击登录", expected:"登录成功,跳转首页,顶部显示用户名" },
|
||
{ id:"1.2", func:"管理端登录(错误密码)", steps:"输入正确手机号+错误密码", expected:"提示「手机号或密码错误」,不跳转" },
|
||
{ id:"1.3", func:"登录空字段校验", steps:"不填任何字段,点击登录", expected:"前端提示请输入手机号/密码" },
|
||
{ id:"1.4", func:"注册新企业", steps:"填写企业名/联系人/手机号/密码/验证码", expected:"注册成功,提示请登录" },
|
||
{ id:"1.5", func:"忘记密码", steps:"发送验证码→输入验证码+新密码→提交", expected:"密码修改成功,跳转登录页" },
|
||
{ id:"1.6", func:"Token自动刷新", steps:"登录后篡改accessToken,触发API", expected:"自动用refreshToken刷新,请求重试成功" },
|
||
{ id:"1.7", func:"未登录路由保护", steps:"未登录访问/roster等受保护页面", expected:"自动重定向至/login" },
|
||
{ id:"1.8", func:"退出登录", steps:"点击顶部退出按钮", expected:"清除登录态,跳转至/login" },
|
||
]},
|
||
{ name: "2. 工作台与仪表盘", items: [
|
||
{ id:"2.1", func:"工作台概览加载", steps:"登录后访问首页", expected:"显示统计卡片、待办列表、成本分析" },
|
||
{ id:"2.2", func:"待办事项列表", steps:"查看首页待办区域", expected:"待办按优先级排序" },
|
||
{ id:"2.3", func:"待办标记已处理", steps:"点击某条待办的已处理", expected:"该待办消失,toast提示成功" },
|
||
{ id:"2.4", func:"待办忽略", steps:"点击某条待办的忽略", expected:"该待办消失" },
|
||
{ id:"2.5", func:"批量标记已处理", steps:"勾选多条待办→批量已处理", expected:"选中待办全部消失" },
|
||
{ id:"2.6", func:"批量忽略", steps:"勾选多条待办→批量忽略", expected:"选中待办全部消失" },
|
||
{ id:"2.7", func:"合规健康度评分", steps:"访问体检诊断页面", expected:"显示6维度评分环形图+总分+等级" },
|
||
{ id:"2.8", func:"成本分析", steps:"查看首页成本分析区域", expected:"显示总成本/人均/环比/同比" },
|
||
{ id:"2.9", func:"人力信息总览", steps:"查看首页人力信息区域", expected:"显示性别/年龄/学历/司龄分布图" },
|
||
{ id:"2.10", func:"入离职趋势", steps:"查看首页入离职趋势图", expected:"显示12个月趋势折线图" },
|
||
{ id:"2.11", func:"绩效统计", steps:"查看首页绩效统计区域", expected:"显示季度绩效数据或暂无数据" },
|
||
{ id:"2.12", func:"合同到期预警弹窗", steps:"存在到期合同时查看首页", expected:"顶部红色预警条,点击展开列表" },
|
||
{ id:"2.13", func:"任务中心", steps:"查看首页任务中心区域", expected:"显示待处理任务分类及数量" },
|
||
{ id:"2.14", func:"年度价值报告", steps:"查看首页年度价值报告", expected:"显示年度价值报告卡片" },
|
||
]},
|
||
{ name: "3. 花名册与员工档案", items: [
|
||
{ id:"3.1", func:"花名册分页列表", steps:"访问花名册页面", expected:"显示员工列表,含姓名/身份证/部门/状态/月薪/合同" },
|
||
{ id:"3.2", func:"花名册搜索", steps:"搜索框输入员工姓名", expected:"列表过滤显示匹配员工" },
|
||
{ id:"3.3", func:"花名册筛选(合同状态)", steps:"筛选已过期", expected:"仅显示合同过期员工,顶部红色提醒" },
|
||
{ id:"3.4", func:"花名册筛选(部门)", steps:"筛选生产车间", expected:"仅显示该部门员工" },
|
||
{ id:"3.5", func:"部门列表", steps:"查看部门筛选下拉", expected:"显示所有部门选项" },
|
||
{ id:"3.6", func:"添加员工", steps:"点击添加员工→填写信息→保存", expected:"新员工出现在列表中" },
|
||
{ id:"3.7", func:"员工详情档案", steps:"点击员工姓名进入详情", expected:"显示基本信息/合同/薪酬/考勤/绩效/违纪等tab" },
|
||
{ id:"3.8", func:"调薪", steps:"员工详情→调薪→输入新薪资→保存", expected:"薪资更新,记录变更历史" },
|
||
{ id:"3.9", func:"调岗", steps:"员工详情→调岗→选择新部门→保存", expected:"员工部门更新" },
|
||
{ id:"3.10", func:"添加违纪记录", steps:"员工详情→违纪tab→添加记录", expected:"违纪tab显示新记录" },
|
||
{ id:"3.11", func:"添加考勤记录", steps:"员工详情→考勤tab→添加记录", expected:"考勤tab显示新记录" },
|
||
{ id:"3.12", func:"添加培训记录", steps:"员工详情→培训tab→添加记录", expected:"培训tab显示新记录" },
|
||
{ id:"3.13", func:"添加绩效记录", steps:"员工详情→绩效tab→添加记录", expected:"绩效tab显示新记录" },
|
||
{ id:"3.14", func:"添加合同", steps:"员工详情→合同tab→添加合同", expected:"新合同创建成功,合同状态更新" },
|
||
{ id:"3.15", func:"批量续签预检", steps:"选择多个到期合同→批量续签预检", expected:"显示预检结果" },
|
||
{ id:"3.16", func:"批量续签", steps:"确认批量续签", expected:"合同状态更新为正常" },
|
||
{ id:"3.17", func:"删除员工", steps:"选择测试员工→删除", expected:"该员工从列表消失" },
|
||
{ id:"3.18", func:"员工重新入职", steps:"选择已离职员工→重新入职", expected:"员工状态变为在职" },
|
||
{ id:"3.19", func:"附件管理", steps:"员工详情→附件区域→上传附件", expected:"附件列表显示已上传文件" },
|
||
]},
|
||
{ name: "4. 考勤排班", items: [
|
||
{ id:"4.1", func:"考勤确认列表", steps:"访问考勤页面,选择月份", expected:"显示考勤确认记录及统计" },
|
||
{ id:"4.2", func:"考勤统计", steps:"查看考勤统计区域", expected:"显示总数/待确认/已确认/异议数" },
|
||
{ id:"4.3", func:"班次列表", steps:"访问班次管理", expected:"显示已创建的班次列表" },
|
||
{ id:"4.4", func:"创建班次", steps:"填写班次名/时间/颜色→保存", expected:"新班次出现在列表中" },
|
||
{ id:"4.5", func:"更新班次", steps:"编辑已有班次→修改→保存", expected:"班次信息更新" },
|
||
{ id:"4.6", func:"删除班次", steps:"删除某个班次", expected:"该班次从列表消失" },
|
||
{ id:"4.7", func:"排班(按日期)", steps:"选择日期查看排班", expected:"显示当日排班列表" },
|
||
{ id:"4.8", func:"批量排班", steps:"选择多个员工+班次+日期→批量排班", expected:"排班记录创建成功" },
|
||
{ id:"4.9", func:"每日出勤", steps:"选择日期查看出勤", expected:"显示当日出勤明细" },
|
||
{ id:"4.10", func:"月度报表", steps:"选择月份查看月报", expected:"显示月度考勤汇总" },
|
||
{ id:"4.11", func:"发布考勤", steps:"选择月份→发布考勤", expected:"发布状态变为已发布" },
|
||
{ id:"4.12", func:"取消发布", steps:"取消已发布的考勤", expected:"发布状态变为未发布" },
|
||
{ id:"4.13", func:"批量确认考勤", steps:"选择月份→批量确认", expected:"所有员工考勤标记为已确认" },
|
||
{ id:"4.14", func:"请假记录", steps:"查看请假列表/创建请假记录", expected:"列表显示请假记录,创建成功" },
|
||
]},
|
||
{ name: "5. 薪税管理", items: [
|
||
{ id:"5.1", func:"发薪批次列表", steps:"访问薪税管理页面", expected:"显示发薪批次列表" },
|
||
{ id:"5.2", func:"创建发薪批次", steps:"填写名称/月份/类型→创建", expected:"新批次创建成功,状态为草稿" },
|
||
{ id:"5.3", func:"批次添加人员", steps:"选择批次→添加员工", expected:"批次人数更新" },
|
||
{ id:"5.4", func:"批次条目更新(手动调薪)", steps:"编辑某员工条目→修改薪资项→保存", expected:"条目金额更新,合计自动计算" },
|
||
{ id:"5.5", func:"算薪前AI校验", steps:"点击AI校验", expected:"显示校验结果(警告/错误/建议)" },
|
||
{ id:"5.6", func:"归档批次", steps:"点击归档", expected:"批次状态变为已归档" },
|
||
{ id:"5.7", func:"取消归档", steps:"点击取消归档", expected:"批次状态回退为草稿" },
|
||
{ id:"5.8", func:"发布工资条", steps:"归档批次→发布工资条", expected:"员工端可见工资条" },
|
||
{ id:"5.9", func:"定时发送工资条", steps:"设置定时发送时间", expected:"保存成功,显示定时发送时间" },
|
||
{ id:"5.10", func:"薪酬模版列表", steps:"访问薪酬模版", expected:"显示模版项列表" },
|
||
{ id:"5.11", func:"创建模版项", steps:"填写名称/类型/公式→保存", expected:"新模版项出现在列表中" },
|
||
{ id:"5.12", func:"加班费记录", steps:"查看加班费列表", expected:"显示员工加班费记录" },
|
||
{ id:"5.13", func:"保存加班费记录", steps:"填写员工/月份/加班时长→保存", expected:"加班费按配置自动计算" },
|
||
{ id:"5.14", func:"加班费配置", steps:"查看/修改加班费倍率", expected:"保存成功,显示倍率配置" },
|
||
{ id:"5.15", func:"个税试算", steps:"输入薪资参数→试算", expected:"显示应纳税额/税率/实发" },
|
||
{ id:"5.16", func:"删除批次", steps:"删除某个草稿批次", expected:"批次从列表消失" },
|
||
{ id:"5.17", func:"批次重命名", steps:"修改批次名称→保存", expected:"批次名称更新" },
|
||
]},
|
||
{ name: "6. 社保公积金", items: [
|
||
{ id:"6.1", func:"社保配置(按城市)", steps:"选择城市查看社保配置", expected:"显示五险费率/基数上下限/生效日期" },
|
||
{ id:"6.2", func:"公积金配置", steps:"选择城市查看公积金配置", expected:"显示公积金费率/基数上下限" },
|
||
{ id:"6.3", func:"社保计算", steps:"输入基数+城市→计算", expected:"显示企业/个人各项明细及合计" },
|
||
{ id:"6.4", func:"公积金计算", steps:"输入基数+城市→计算", expected:"显示企业/个人公积金金额" },
|
||
{ id:"6.5", func:"创建社保配置版本", steps:"填写新费率/基数→保存", expected:"新版本创建成功" },
|
||
{ id:"6.6", func:"社保配置版本列表", steps:"查看版本历史", expected:"显示所有版本,当前版本标记" },
|
||
{ id:"6.7", func:"AI社保建议", steps:"点击AI建议", expected:"显示建议内容/置信度/推荐配置" },
|
||
{ id:"6.8", func:"月度办理状态", steps:"选择月份查看办理状态", expected:"显示社保/公积金是否已办理" },
|
||
{ id:"6.9", func:"月度变动(社保增减员)", steps:"点击获取月度变动", expected:"显示当月新增/减少/在保人员" },
|
||
{ id:"6.10", func:"公积金月度变动", steps:"查看公积金月度变动", expected:"显示当月公积金增减员" },
|
||
{ id:"6.11", func:"完成月度办理", steps:"点击完成社保/公积金办理", expected:"状态更新为已完成" },
|
||
{ id:"6.12", func:"专项附加扣除批量", steps:"查看专项附加扣除列表", expected:"显示员工各项扣除明细" },
|
||
{ id:"6.13", func:"保存专项附加扣除", steps:"修改某员工扣除金额→保存", expected:"保存成功" },
|
||
{ id:"6.14", func:"活跃申报员工", steps:"查看活跃申报列表", expected:"显示在保员工及状态" },
|
||
{ id:"6.15", func:"商业保险方案列表", steps:"查看商业保险", expected:"显示保险方案列表" },
|
||
{ id:"6.16", func:"创建商业保险方案", steps:"填写保险信息→保存", expected:"新方案创建成功" },
|
||
]},
|
||
{ name: "7. 解聘补偿", items: [
|
||
{ id:"7.1", func:"解聘列表", steps:"访问解聘管理页面", expected:"显示解聘记录列表" },
|
||
{ id:"7.2", func:"创建解聘草稿", steps:"新建解聘→6步向导→保存", expected:"草稿创建成功" },
|
||
{ id:"7.3", func:"解聘风险评估", steps:"向导中自动评估", expected:"显示风险等级/评分/因素/建议" },
|
||
{ id:"7.4", func:"解聘清单", steps:"向导中查看清单", expected:"显示所需办理事项清单" },
|
||
{ id:"7.5", func:"更新草稿", steps:"编辑草稿→修改→保存", expected:"草稿信息更新" },
|
||
{ id:"7.6", func:"提交审批", steps:"点击提交审批", expected:"状态变为待审批" },
|
||
{ id:"7.7", func:"审批通过", steps:"点击审批通过", expected:"状态变为已审批" },
|
||
{ id:"7.8", func:"审批驳回", steps:"点击驳回并填写意见", expected:"状态回退为草稿,附驳回意见" },
|
||
{ id:"7.9", func:"执行解聘", steps:"已审批记录→执行解聘", expected:"状态变为已完成,员工状态变更" },
|
||
{ id:"7.10", func:"撤销解聘", steps:"点击撤销", expected:"状态变为已撤销" },
|
||
{ id:"7.11", func:"撤回离职记录", steps:"撤回已执行的离职", expected:"员工状态恢复为在职" },
|
||
{ id:"7.12", func:"批量预览", steps:"选择多个员工→批量预览", expected:"显示每人预估补偿金和风险等级" },
|
||
]},
|
||
{ name: "8. 合规风控", items: [
|
||
{ id:"8.1", func:"风险中心列表", steps:"访问风险中心", expected:"显示风险项列表" },
|
||
{ id:"8.2", func:"风险分布统计", steps:"查看风险分布", expected:"显示风险数量及饼图" },
|
||
{ id:"8.3", func:"用工体检诊断", steps:"访问体检诊断页面", expected:"显示6维度评分+总分+等级" },
|
||
{ id:"8.4", func:"体检诊断历史", steps:"查看历史记录", expected:"显示历次诊断快照" },
|
||
{ id:"8.5", func:"保存体检诊断", steps:"点击保存当前诊断", expected:"保存成功,历史列表新增" },
|
||
{ id:"8.6", func:"医疗期计算器", steps:"输入参数→计算", expected:"显示医疗期月数/起止日期/说明" },
|
||
]},
|
||
{ name: "9. 规章制度", items: [
|
||
{ id:"9.1", func:"制度列表", steps:"访问制度管理页面", expected:"显示制度列表" },
|
||
{ id:"9.2", func:"创建制度", steps:"填写标题/分类/内容→保存", expected:"新制度创建成功,状态为草稿" },
|
||
{ id:"9.3", func:"制度详情", steps:"点击制度查看详情", expected:"显示完整内容及4步民主程序进度" },
|
||
{ id:"9.4", func:"推进民主程序步骤", steps:"点击推进下一步→填写备注", expected:"当前步骤更新,步骤标记完成" },
|
||
{ id:"9.5", func:"更新制度", steps:"编辑制度内容→保存", expected:"保存成功" },
|
||
{ id:"9.6", func:"删除制度", steps:"删除某制度", expected:"该制度从列表消失" },
|
||
{ id:"9.7", func:"阅读签收统计", steps:"查看已发布制度的签收统计", expected:"显示总数/已读/未读/阅读率" },
|
||
]},
|
||
{ name: "10. AI顾问", items: [
|
||
{ id:"10.1", func:"智能问答", steps:"输入问题→发送", expected:"AI返回回答+置信度+来源引用" },
|
||
{ id:"10.2", func:"上下文问答", steps:"基于企业数据提问", expected:"AI结合花名册数据回答" },
|
||
{ id:"10.3", func:"合同审查", steps:"粘贴合同文本→审查", expected:"显示风险点/严重程度/修改建议/总分" },
|
||
{ id:"10.4", func:"合同审查上传文件", steps:"上传合同PDF→审查", expected:"提取文本并返回审查结果" },
|
||
{ id:"10.5", func:"案例匹配", steps:"输入场景描述→匹配", expected:"显示相关案例列表及分析" },
|
||
{ id:"10.6", func:"案例转待办", steps:"将案例转为待办任务", expected:"待办创建成功" },
|
||
{ id:"10.7", func:"风险预测", steps:"访问风险预测tab", expected:"显示未来风险预测列表" },
|
||
{ id:"10.8", func:"对话历史", steps:"查看历史对话列表", expected:"显示历史对话记录" },
|
||
{ id:"10.9", func:"删除对话", steps:"删除某条对话", expected:"对话从历史消失" },
|
||
{ id:"10.10", func:"RAG知识库列表", steps:"查看知识库", expected:"显示知识库文档列表" },
|
||
{ id:"10.11", func:"RAG添加知识", steps:"填写标题/内容/分类→保存", expected:"新知识条目创建成功" },
|
||
{ id:"10.12", func:"RAG帮助搜索", steps:"输入关键词搜索", expected:"返回匹配的知识片段及评分" },
|
||
]},
|
||
{ name: "11. 工作流程", items: [
|
||
{ id:"11.1", func:"工作流程列表", steps:"访问用工办理页面", expected:"显示工作流程列表" },
|
||
{ id:"11.2", func:"创建工作流程", steps:"新建→选择类型→填写表单→保存草稿", expected:"草稿创建成功" },
|
||
{ id:"11.3", func:"工作流程详情", steps:"点击记录查看详情", expected:"显示表单信息及步骤进度" },
|
||
{ id:"11.4", func:"提交工作流程", steps:"点击提交办理", expected:"状态变为已完成" },
|
||
{ id:"11.5", func:"取消工作流程", steps:"点击取消", expected:"状态变为已取消" },
|
||
{ id:"11.6", func:"删除工作流程", steps:"删除某工作流程", expected:"从列表消失" },
|
||
{ id:"11.7", func:"工作流程预览", steps:"预览工作流程", expected:"显示文档模板及步骤预览" },
|
||
]},
|
||
{ name: "12. 特殊状态台账", items: [
|
||
{ id:"12.1", func:"特殊状态列表", steps:"访问特殊状态页面", expected:"显示特殊状态记录" },
|
||
{ id:"12.2", func:"统计概览", steps:"查看统计区域", expected:"显示总数及按类型/状态分布" },
|
||
{ id:"12.3", func:"创建特殊状态", steps:"填写员工/类型/起止日/描述→保存", expected:"新记录创建成功" },
|
||
{ id:"12.4", func:"更新特殊状态", steps:"编辑已有记录→保存", expected:"记录更新成功" },
|
||
{ id:"12.5", func:"删除特殊状态", steps:"删除某记录", expected:"该记录从列表消失" },
|
||
]},
|
||
{ name: "13. 日历与事件", items: [
|
||
{ id:"13.1", func:"月度日历", steps:"查看日历页面", expected:"显示当月事件标记" },
|
||
{ id:"13.2", func:"自定义事件列表", steps:"查看自定义事件", expected:"显示已创建的事件列表" },
|
||
{ id:"13.3", func:"创建自定义事件", steps:"填写标题/日期/类型/描述→保存", expected:"新事件创建成功" },
|
||
{ id:"13.4", func:"删除自定义事件", steps:"删除某事件", expected:"事件从日历消失" },
|
||
{ id:"13.5", func:"日历事件筛选", steps:"按类型筛选事件", expected:"仅显示选中类型事件" },
|
||
]},
|
||
{ name: "14. 通知管理", items: [
|
||
{ id:"14.1", func:"通知日志列表", steps:"访问通知管理页面", expected:"显示通知发送日志" },
|
||
{ id:"14.2", func:"通知设置", steps:"查看通知设置", expected:"显示各项通知开关及提前天数" },
|
||
{ id:"14.3", func:"更新通知设置", steps:"修改通知设置→保存", expected:"保存成功" },
|
||
{ id:"14.4", func:"检查合同到期", steps:"点击检查合同到期", expected:"返回检查数量及告警数量" },
|
||
{ id:"14.5", func:"测试通知发送", steps:"点击测试发送", expected:"提示测试通知已发送" },
|
||
]},
|
||
{ name: "15. 系统设置", items: [
|
||
{ id:"15.1", func:"企业信息", steps:"访问设置页面", expected:"显示企业名称/联系人/城市/套餐" },
|
||
{ id:"15.2", func:"更新企业信息", steps:"修改企业信息→保存", expected:"保存成功" },
|
||
{ id:"15.3", func:"用户列表", steps:"查看用户管理", expected:"显示系统用户列表" },
|
||
{ id:"15.4", func:"添加用户", steps:"填写姓名/手机号/密码/角色→保存", expected:"新用户创建成功" },
|
||
{ id:"15.5", func:"更新用户", steps:"编辑用户信息→保存", expected:"保存成功" },
|
||
{ id:"15.6", func:"切换用户禁用状态", steps:"禁用/启用某用户", expected:"用户状态切换" },
|
||
{ id:"15.7", func:"用量统计", steps:"查看用量统计", expected:"显示套餐/员工数/使用率/功能权限" },
|
||
{ id:"15.8", func:"更新套餐", steps:"切换套餐→保存", expected:"套餐更新成功" },
|
||
{ id:"15.9", func:"退休政策", steps:"查看退休政策", expected:"显示退休政策规则" },
|
||
{ id:"15.10", func:"确认退休政策生效", steps:"点击确认生效", expected:"状态变为已生效" },
|
||
]},
|
||
{ name: "16. 模板管理", items: [
|
||
{ id:"16.1", func:"系统模板列表", steps:"访问模板管理", expected:"显示系统模板列表" },
|
||
{ id:"16.2", func:"系统模板详情", steps:"点击模板查看详情", expected:"显示模板内容及变量列表" },
|
||
{ id:"16.3", func:"渲染系统模板", steps:"填入变量→渲染", expected:"显示渲染后的文档内容" },
|
||
{ id:"16.4", func:"企业模板列表", steps:"查看企业模板", expected:"显示企业自定义模板列表" },
|
||
{ id:"16.5", func:"创建企业模板", steps:"填写名称/分类/内容→保存", expected:"新模板创建成功" },
|
||
{ id:"16.6", func:"更新企业模板", steps:"编辑模板→保存", expected:"保存成功" },
|
||
{ id:"16.7", func:"删除企业模板", steps:"删除某模板", expected:"该模板从列表消失" },
|
||
{ id:"16.8", func:"渲染企业模板", steps:"填入变量→渲染", expected:"显示渲染后的内容" },
|
||
]},
|
||
{ name: "17. 证据链", items: [
|
||
{ id:"17.1", func:"证据链列表", steps:"访问证据链页面", expected:"显示证据记录列表" },
|
||
{ id:"17.2", func:"全量验证", steps:"点击全量验证", expected:"显示验证结果统计" },
|
||
{ id:"17.3", func:"按类别筛选", steps:"筛选某类别", expected:"仅显示该类别证据记录" },
|
||
]},
|
||
{ name: "18. 公司文件", items: [
|
||
{ id:"18.1", func:"文件列表", steps:"访问公司文件页面", expected:"显示已上传文件列表" },
|
||
{ id:"18.2", func:"添加文件", steps:"填写文件名/类型/上传文件→保存", expected:"新文件出现在列表中" },
|
||
{ id:"18.3", func:"删除文件", steps:"删除某文件", expected:"该文件从列表消失" },
|
||
]},
|
||
{ name: "19. 审计日志", items: [
|
||
{ id:"19.1", func:"审计日志列表", steps:"访问操作日志页面", expected:"显示操作日志" },
|
||
{ id:"19.2", func:"审计统计", steps:"查看操作类型分布", expected:"显示按类型统计" },
|
||
{ id:"19.3", func:"按实体筛选", steps:"筛选某实体类型", expected:"仅显示该实体类型的日志" },
|
||
{ id:"19.4", func:"按日期范围筛选", steps:"选择起止日期", expected:"仅显示该日期范围内的日志" },
|
||
]},
|
||
{ name: "20. 薪酬分析仪表盘", items: [
|
||
{ id:"20.1", func:"薪酬仪表盘", steps:"访问薪酬分析页面", expected:"显示月度趋势/部门薪资/分布/同比" },
|
||
]},
|
||
{ name: "21. 平台管理端", items: [
|
||
{ id:"21.1", func:"平台登录", steps:"使用SUPER_ADMIN账号登录", expected:"登录成功,跳转平台仪表盘" },
|
||
{ id:"21.2", func:"平台仪表盘", steps:"查看平台首页", expected:"显示总组织数/用户数/员工数/套餐分布" },
|
||
{ id:"21.3", func:"组织列表", steps:"查看组织管理", expected:"显示所有组织列表" },
|
||
{ id:"21.4", func:"组织搜索", steps:"搜索组织名称", expected:"列表过滤显示匹配组织" },
|
||
{ id:"21.5", func:"组织详情", steps:"点击组织查看详情", expected:"显示组织完整信息" },
|
||
{ id:"21.6", func:"创建组织", steps:"填写组织信息→创建", expected:"新组织创建成功" },
|
||
{ id:"21.7", func:"更新组织", steps:"编辑组织信息→保存", expected:"保存成功" },
|
||
{ id:"21.8", func:"更新组织管理员", steps:"修改管理员信息/密码→保存", expected:"保存成功" },
|
||
{ id:"21.9", func:"删除组织", steps:"删除某组织", expected:"该组织从列表消失" },
|
||
{ id:"21.10", func:"平台用户列表", steps:"查看用户管理", expected:"显示所有平台用户列表" },
|
||
{ id:"21.11", func:"切换用户状态", steps:"禁用/启用某用户", expected:"用户状态切换" },
|
||
]},
|
||
{ name: "22. 员工端Portal", items: [
|
||
{ id:"22.1", func:"员工端登录(密码)", steps:"输入手机号+密码→登录", expected:"登录成功,跳转员工端首页" },
|
||
{ id:"22.2", func:"员工端登录(验证码)", steps:"发送验证码→输入验证码→登录", expected:"登录成功" },
|
||
{ id:"22.3", func:"自动登录(Token)", steps:"使用管理端生成的自动登录链接", expected:"自动登录并跳转首页" },
|
||
{ id:"22.4", func:"首页概览", steps:"查看员工端首页", expected:"显示姓名/部门/本月实发/快捷入口" },
|
||
{ id:"22.5", func:"工资条", steps:"点击工资条查看", expected:"显示当月工资明细" },
|
||
{ id:"22.6", func:"工资条历史", steps:"查看工资条历史", expected:"显示历史工资条列表" },
|
||
{ id:"22.7", func:"确认工资条", steps:"点击确认工资条", expected:"工资条状态变为已确认" },
|
||
{ id:"22.8", func:"我的合同", steps:"点击我的合同", expected:"显示合同信息" },
|
||
{ id:"22.9", func:"合同确认(发送验证码)", steps:"点击确认合同→发送验证码", expected:"提示验证码已发送" },
|
||
{ id:"22.10", func:"合同确认(提交)", steps:"输入验证码→确认", expected:"提示合同确认成功" },
|
||
{ id:"22.11", func:"我的考勤", steps:"点击我的考勤", expected:"显示当月考勤记录及汇总" },
|
||
{ id:"22.12", func:"制度列表", steps:"点击规章制度", expected:"显示制度列表" },
|
||
{ id:"22.13", func:"制度详情", steps:"点击某制度查看详情", expected:"显示制度完整内容" },
|
||
{ id:"22.14", func:"制度阅读确认", steps:"阅读后点击已读", expected:"制度标记为已读" },
|
||
{ id:"22.15", func:"入职信息", steps:"使用入职链接查看", expected:"显示企业名/岗位/入职日期/所需材料" },
|
||
{ id:"22.16", func:"入职提交", steps:"填写个人信息→提交", expected:"提示入职信息提交成功" },
|
||
{ id:"22.17", func:"入职文件上传", steps:"上传身份证/学历等文件", expected:"文件上传成功" },
|
||
{ id:"22.18", func:"入职进度", steps:"查看入职进度", expected:"显示各步骤完成状态" },
|
||
{ id:"22.19", func:"离职申请状态", steps:"查看离职申请页面", expected:"显示已有记录或暂无" },
|
||
{ id:"22.20", func:"提交离职申请", steps:"选择原因→填写日期/备注→提交", expected:"提交成功,状态为待审批" },
|
||
{ id:"22.21", func:"撤回离职申请", steps:"点击撤回", expected:"状态变为已撤回" },
|
||
{ id:"22.22", func:"重发合同确认链接", steps:"管理端操作重发", expected:"提示链接已重新发送" },
|
||
{ id:"22.23", func:"生成自动登录令牌", steps:"管理端员工详情→复制链接", expected:"生成员工端自动登录链接" },
|
||
]},
|
||
{ name: "23. 数据导入导出", items: [
|
||
{ id:"23.1", func:"员工批量导入", steps:"上传Excel→导入", expected:"显示导入结果" },
|
||
{ id:"23.2", func:"考勤批量导入", steps:"上传Excel+月份→导入", expected:"显示导入结果" },
|
||
{ id:"23.3", func:"加班费批量导入", steps:"填写加班数据→导入", expected:"显示导入数量" },
|
||
{ id:"23.4", func:"花名册导出", steps:"点击导出", expected:"下载Excel文件" },
|
||
{ id:"23.5", func:"工资条导出", steps:"批次→导出工资条", expected:"下载Excel文件" },
|
||
{ id:"23.6", func:"解聘记录导出", steps:"点击导出", expected:"下载Excel文件" },
|
||
{ id:"23.7", func:"考勤月报导出", steps:"选择月份→导出", expected:"下载Excel文件" },
|
||
]},
|
||
{ name: "24. 全局搜索与命令面板", items: [
|
||
{ id:"24.1", func:"全局搜索", steps:"输入关键词搜索", expected:"返回匹配的员工/合同/制度" },
|
||
{ id:"24.2", func:"命令面板", steps:"按Cmd+K/Ctrl+K", expected:"弹出命令面板,可快速跳转" },
|
||
{ id:"24.3", func:"搜索无结果", steps:"输入不存在的关键词", expected:"显示未找到相关结果" },
|
||
]},
|
||
{ name: "25. 合同管理", items: [
|
||
{ id:"25.1", func:"合同列表", steps:"访问合同管理页面", expected:"显示合同列表,含员工/类型/起止日/状态" },
|
||
{ id:"25.2", func:"合同分页", steps:"翻页查看合同列表", expected:"分页正常,每页显示对应数量" },
|
||
{ id:"25.3", func:"合同搜索", steps:"搜索员工姓名", expected:"列表过滤显示匹配合同" },
|
||
{ id:"25.4", func:"合同筛选(状态)", steps:"筛选即将到期/已过期", expected:"仅显示对应状态合同" },
|
||
{ id:"25.5", func:"合同详情", steps:"点击合同查看详情", expected:"显示合同完整信息" },
|
||
{ id:"25.6", func:"添加合同", steps:"点击添加→填写信息→保存", expected:"新合同创建成功" },
|
||
{ id:"25.7", func:"编辑合同", steps:"编辑已有合同→保存", expected:"合同信息更新" },
|
||
{ id:"25.8", func:"删除合同", steps:"删除某合同", expected:"合同从列表消失" },
|
||
{ id:"25.9", func:"合同到期预警", steps:"查看即将到期合同", expected:"显示到期天数提醒" },
|
||
]},
|
||
{ name: "26. 薪酬模版管理", items: [
|
||
{ id:"26.1", func:"薪酬模版列表", steps:"访问薪酬模版页面", expected:"显示模版项列表" },
|
||
{ id:"26.2", func:"创建薪酬模版", steps:"填写名称/类型/公式→保存", expected:"新模版项创建成功" },
|
||
{ id:"26.3", func:"编辑薪酬模版", steps:"编辑已有模版→保存", expected:"模版信息更新" },
|
||
{ id:"26.4", func:"删除薪酬模版", steps:"删除某模版项", expected:"模版项从列表消失" },
|
||
{ id:"26.5", func:"模版分类筛选", steps:"按类型筛选(收入/扣除)", expected:"仅显示对应类型模版" },
|
||
]},
|
||
{ name: "27. 年度价值报告", items: [
|
||
{ id:"27.1", func:"年度价值报告", steps:"查看首页年度价值报告", expected:"显示年度价值报告卡片" },
|
||
{ id:"27.2", func:"价值报告详情", steps:"点击查看详情", expected:"显示年度人力成本/效率/风险等指标" },
|
||
]},
|
||
{ name: "28. 验收测试", items: [
|
||
{ id:"28.1", func:"验收测试保存", steps:"填写验收人→标记测试项→保存", expected:"保存成功,toast提示" },
|
||
{ id:"28.2", func:"验收测试加载", steps:"重新进入→选择已保存验收人", expected:"恢复之前保存的测试结果" },
|
||
{ id:"28.3", func:"验收测试提交", steps:"完成测试→点击提交报告", expected:"状态变为已提交" },
|
||
{ id:"28.4", func:"验收结论自动计算", steps:"标记测试项结果", expected:"总体结论根据结果自动计算" },
|
||
{ id:"28.5", func:"验收测试筛选", steps:"点击未测试/失败筛选", expected:"仅显示对应结果测试项" },
|
||
{ id:"28.6", func:"验收测试搜索", steps:"输入关键词搜索", expected:"仅显示匹配的测试项" },
|
||
{ id:"28.7", func:"验收测试导出", steps:"下载CSV/Excel/PDF", expected:"文件下载成功,内容完整" },
|
||
{ id:"28.8", func:"验收测试截图", steps:"点击截图列→上传图片", expected:"截图显示在单元格中" },
|
||
{ id:"28.9", func:"验收测试签字", steps:"在签名区域手写签名", expected:"签名显示在画布上" },
|
||
]},
|
||
];
|
||
|
||
const RESULT_LABELS = { pass: "✅", fail: "❌", partial: "⚠️", untested: "🚫" };
|
||
|
||
// ========== API 工具 ==========
|
||
function getAuthToken() {
|
||
try {
|
||
const raw = window.parent.localStorage.getItem('auth-storage');
|
||
if (raw) return JSON.parse(raw).state?.accessToken || '';
|
||
} catch {}
|
||
return '';
|
||
}
|
||
|
||
const API_BASE = (() => {
|
||
try {
|
||
const loc = window.parent.location;
|
||
if (loc.port === '5173' || loc.port === '3001') return 'http://localhost:3000/api/v1';
|
||
return loc.origin + '/api/v1';
|
||
} catch { return '/api/v1'; }
|
||
})();
|
||
|
||
async function apiCall(method, path, body) {
|
||
const token = getAuthToken();
|
||
const opts = { method, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' } };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const res = await fetch(`${API_BASE}${path}`, opts);
|
||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || `请求失败(${res.status})`); }
|
||
return res.json();
|
||
}
|
||
|
||
// ========== 渲染 ==========
|
||
function renderAll() {
|
||
const main = document.getElementById("mainContent");
|
||
let html = "";
|
||
DOMAINS.forEach((domain, di) => {
|
||
html += `<div class="domain-section" id="domain-${di}" data-domain="${di}">
|
||
<div class="domain-header" onclick="toggleDomain(${di})">
|
||
<h2>${domain.name}</h2>
|
||
<span class="domain-count">${domain.items.length} 项</span>
|
||
<div class="domain-progress"><div class="domain-progress-fill" id="progress-${di}" style="width:0%"></div></div>
|
||
<span class="toggle">▼</span>
|
||
</div>
|
||
<table class="test-table">
|
||
<thead><tr>
|
||
<th class="col-num">#</th><th class="col-func">功能</th>
|
||
<th class="col-steps">操作步骤</th><th class="col-expected">预期结果</th>
|
||
<th class="col-result">结果</th><th class="col-remark">备注</th>
|
||
<th class="screenshot-cell">截图</th>
|
||
</tr></thead>
|
||
<tbody>`;
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
html += `<tr id="row-${uid}">
|
||
<td class="col-num">${item.id}</td>
|
||
<td class="col-func">${item.func}</td>
|
||
<td class="col-steps">${item.steps}</td>
|
||
<td class="col-expected">${item.expected}</td>
|
||
<td class="col-result">
|
||
<div class="result-radio">
|
||
${["pass","fail","partial","untested"].map(r => `
|
||
<input type="radio" name="r-${uid}" id="r-${uid}-${r}" value="${r}" onchange="onResultChange(${di},${ii},'${r}')">
|
||
<label for="r-${uid}-${r}" class="${r}" title="${({pass:'通过',fail:'失败',partial:'部分通过',untested:'未测试'})[r]}">${RESULT_LABELS[r]}</label>
|
||
`).join("")}
|
||
</div>
|
||
</td>
|
||
<td class="col-remark"><input type="text" class="remark-input" id="remark-${uid}" placeholder="备注" onchange="onRemarkChange(${di},${ii},this.value)"></td>
|
||
<td class="screenshot-cell"><div class="screenshot-upload" onclick="uploadScreenshot(${di},${ii})" title="上传截图">+</div></td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
});
|
||
|
||
// Summary table
|
||
html += `<div class="summary-section">
|
||
<h2>验收统计(自动计算)</h2>
|
||
<table class="summary-table" id="summaryTable">
|
||
<thead><tr>
|
||
<th>域</th><th>总项数</th><th>通过</th><th>失败</th><th>部分通过</th><th>未测试</th><th>完成率</th>
|
||
</tr></thead>
|
||
<tbody>`;
|
||
DOMAINS.forEach((domain, di) => {
|
||
html += `<tr id="summary-row-${di}">
|
||
<td>${domain.name}</td>
|
||
<td>${domain.items.length}</td>
|
||
<td class="s-pass">0</td><td class="s-fail">0</td><td class="s-partial">0</td><td class="s-untested">0</td>
|
||
<td class="s-rate">0%</td>
|
||
</tr>`;
|
||
});
|
||
html += `<tr class="total-row" id="summary-total">
|
||
<td>合计</td><td id="total-count">0</td>
|
||
<td id="total-pass">0</td><td id="total-fail">0</td><td id="total-partial">0</td><td id="total-untested">0</td>
|
||
<td id="total-rate">0%</td>
|
||
</tr>`;
|
||
html += `</tbody></table></div>`;
|
||
|
||
// Conclusion (auto-calculated)
|
||
html += `<div class="conclusion-section">
|
||
<h2>验收结论(系统自动计算)</h2>
|
||
<div class="field"><label>验收人</label><input type="text" id="conclusionName" placeholder="验收人姓名" readonly></div>
|
||
<div class="field"><label>验收日期</label><input type="text" id="conclusionDate" placeholder="YYYY-MM-DD" readonly></div>
|
||
<div class="field"><label>总体结论</label><span id="conclusionResult" style="font-size:0.875rem;font-weight:600;color:var(--gray);">未测试</span></div>
|
||
<div class="field"><label>通过率</label><span id="conclusionPassRate" style="font-size:0.875rem;">-</span></div>
|
||
<div class="field"><label>主要问题</label></div>
|
||
<textarea id="conclusionIssues" placeholder="系统自动汇总失败和部分通过的问题..." readonly style="background:#f9fafb;"></textarea>
|
||
</div>`;
|
||
|
||
// Signature
|
||
html += `<div class="signature-section">
|
||
<h2>验收签字</h2>
|
||
<div style="display:flex;gap:40px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:300px">
|
||
<div style="font-size:0.8125rem;color:var(--gray);margin-bottom:6px">验收人签字</div>
|
||
<div class="signature-box" id="sigBox1">
|
||
<canvas id="sigCanvas1"></canvas>
|
||
<span class="sig-clear" onclick="clearSig(1)">清除</span>
|
||
</div>
|
||
</div>
|
||
<div style="flex:1;min-width:300px">
|
||
<div style="font-size:0.8125rem;color:var(--gray);margin-bottom:6px">项目经理签字</div>
|
||
<div class="signature-box" id="sigBox2">
|
||
<canvas id="sigCanvas2"></canvas>
|
||
<span class="sig-clear" onclick="clearSig(2)">清除</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
main.innerHTML = html;
|
||
updateStats();
|
||
initSignatures();
|
||
}
|
||
|
||
// ========== 交互 ==========
|
||
function toggleDomain(di) {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
el.classList.toggle("collapsed");
|
||
const toggle = el.querySelector(".toggle");
|
||
toggle.textContent = el.classList.contains("collapsed") ? "▶" : "▼";
|
||
}
|
||
|
||
function expandAll() {
|
||
DOMAINS.forEach((_, di) => {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
if (el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; }
|
||
});
|
||
}
|
||
|
||
function collapseAll() {
|
||
DOMAINS.forEach((_, di) => {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
if (el) { el.classList.add("collapsed"); el.querySelector(".toggle").textContent = "▶"; }
|
||
});
|
||
}
|
||
|
||
// ========== 筛选 ==========
|
||
let currentFilter = 'all';
|
||
|
||
function setFilter(filter) {
|
||
currentFilter = filter;
|
||
['all','untested','fail','partial'].forEach(f => {
|
||
const btn = document.getElementById('filter' + f.charAt(0).toUpperCase() + f.slice(1));
|
||
if (btn) btn.classList.toggle('active', f === filter);
|
||
});
|
||
applyFilter();
|
||
}
|
||
|
||
function applyFilter() {
|
||
const searchQ = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const row = document.getElementById(`row-${uid}`);
|
||
if (!row) return;
|
||
|
||
// Get current result
|
||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val = checked ? checked.value : "untested";
|
||
|
||
// Filter by result
|
||
let showByFilter = true;
|
||
if (currentFilter === 'untested' && val !== 'untested') showByFilter = false;
|
||
else if (currentFilter === 'fail' && val !== 'fail') showByFilter = false;
|
||
else if (currentFilter === 'partial' && val !== 'partial') showByFilter = false;
|
||
|
||
// Filter by search
|
||
let showBySearch = true;
|
||
if (searchQ) {
|
||
const text = (item.func + item.steps + item.expected + item.id).toLowerCase();
|
||
showBySearch = text.includes(searchQ);
|
||
}
|
||
|
||
row.classList.toggle('filtered-out', !(showByFilter && showBySearch));
|
||
|
||
// Row color
|
||
row.classList.remove('row-fail', 'row-partial');
|
||
if (val === 'fail') row.classList.add('row-fail');
|
||
else if (val === 'partial') row.classList.add('row-partial');
|
||
});
|
||
|
||
// Auto-expand domain if it has visible items when filtering
|
||
if (currentFilter !== 'all' || searchQ) {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
const hasVisible = domain.items.some((_, ii) => {
|
||
const row = document.getElementById(`row-${di}-${ii}`);
|
||
return row && !row.classList.contains('filtered-out');
|
||
});
|
||
if (hasVisible && el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; }
|
||
}
|
||
});
|
||
}
|
||
|
||
function onSearch() { applyFilter(); }
|
||
|
||
function markAllPass() {
|
||
if (!confirm('将所有「未测试」项标记为「通过」?')) return;
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val = checked ? checked.value : "untested";
|
||
if (val === "untested") {
|
||
const radio = document.querySelector(`input[name="r-${uid}"][value="pass"]`);
|
||
if (radio) radio.checked = true;
|
||
}
|
||
});
|
||
});
|
||
updateStats();
|
||
scheduleAutoSave();
|
||
showToast("已将所有未测试项标记为通过");
|
||
}
|
||
|
||
// ========== 键盘快捷键 ==========
|
||
let currentRowIdx = 0;
|
||
const flatItems = [];
|
||
function buildFlatItems() {
|
||
flatItems.length = 0;
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
flatItems.push({ di, ii, uid: `${di}-${ii}` });
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
// Skip if typing in input
|
||
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
|
||
if (e.target.tagName === 'TEXTAREA') return;
|
||
if (e.target.tagName === 'SELECT') return;
|
||
|
||
const key = e.key.toLowerCase();
|
||
const resultMap = { p: 'pass', f: 'fail', w: 'partial', u: 'untested' };
|
||
|
||
if (key === 'arrowdown') {
|
||
e.preventDefault();
|
||
buildFlatItems();
|
||
currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1);
|
||
focusRow(currentRowIdx);
|
||
} else if (key === 'arrowup') {
|
||
e.preventDefault();
|
||
buildFlatItems();
|
||
currentRowIdx = Math.max(currentRowIdx - 1, 0);
|
||
focusRow(currentRowIdx);
|
||
} else if (resultMap[key]) {
|
||
e.preventDefault();
|
||
buildFlatItems();
|
||
if (currentRowIdx >= 0 && currentRowIdx < flatItems.length) {
|
||
const { di, ii, uid } = flatItems[currentRowIdx];
|
||
const radio = document.querySelector(`input[name="r-${uid}"][value="${resultMap[key]}"]`);
|
||
if (radio) {
|
||
radio.checked = true;
|
||
onResultChange(di, ii, resultMap[key]);
|
||
// Auto-advance to next row
|
||
if (key !== 'u') {
|
||
currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1);
|
||
setTimeout(() => focusRow(currentRowIdx), 100);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
function focusRow(idx) {
|
||
buildFlatItems();
|
||
if (idx >= 0 && idx < flatItems.length) {
|
||
const { uid } = flatItems[idx];
|
||
const row = document.getElementById(`row-${uid}`);
|
||
if (row) {
|
||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
row.style.transition = 'background 0.3s';
|
||
row.style.background = 'var(--primary-light)';
|
||
setTimeout(() => { row.style.background = ''; }, 600);
|
||
}
|
||
}
|
||
}
|
||
|
||
let saveTimer = null;
|
||
function onResultChange(di, ii, result) {
|
||
updateStats();
|
||
scheduleAutoSave();
|
||
}
|
||
|
||
function onRemarkChange(di, ii, value) {
|
||
scheduleAutoSave();
|
||
}
|
||
|
||
function onVerifierChange() {
|
||
showAutocomplete();
|
||
}
|
||
|
||
function scheduleAutoSave() {
|
||
const name = document.getElementById("verifierName").value.trim();
|
||
if (!name) return;
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => autoSave(), 1000);
|
||
}
|
||
|
||
// ========== 自动补全(从后端加载)==========
|
||
let acActiveIndex = -1;
|
||
let acItems = [];
|
||
let savedRecords = [];
|
||
|
||
async function loadSavedList() {
|
||
try {
|
||
const res = await apiCall('GET', '/acceptance-tests');
|
||
savedRecords = res.data || [];
|
||
updateSavedSelect();
|
||
} catch (err) { savedRecords = []; }
|
||
}
|
||
|
||
function updateSavedSelect() {
|
||
const sel = document.getElementById("savedSelect");
|
||
const current = sel.value;
|
||
sel.innerHTML = '<option value="">-- 加载已保存 --</option>';
|
||
savedRecords.forEach(r => {
|
||
const tag = r.status === 'SUBMITTED' ? ' [已提交]' : '';
|
||
sel.innerHTML += `<option value="${r.verifierName}">${r.verifierName} (${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${tag})</option>`;
|
||
});
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
function showAutocomplete() {
|
||
const input = document.getElementById('verifierName');
|
||
const list = document.getElementById('autocompleteList');
|
||
const q = input.value.trim().toLowerCase();
|
||
|
||
if (!q) {
|
||
acItems = savedRecords.map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||
} else {
|
||
acItems = savedRecords.filter(r => r.verifierName.toLowerCase().includes(q)).map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` }));
|
||
}
|
||
|
||
acActiveIndex = -1;
|
||
|
||
if (acItems.length === 0) {
|
||
list.innerHTML = '<div class="autocomplete-empty">无匹配记录</div>';
|
||
list.classList.add('show');
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = acItems.map((item, i) =>
|
||
`<div class="autocomplete-item" data-index="${i}" onmousedown="selectAutocomplete('${item.name}')">
|
||
<span class="ac-name">${item.name}</span>
|
||
<span style="display:flex;align-items:center;gap:8px">
|
||
<span class="ac-meta">${item.meta}</span>
|
||
<span class="ac-del" onmousedown="event.stopPropagation();event.preventDefault();deleteSavedByName('${item.name}')" title="删除">✕</span>
|
||
</span>
|
||
</div>`
|
||
).join('');
|
||
list.classList.add('show');
|
||
}
|
||
|
||
function hideAutocomplete() {
|
||
document.getElementById('autocompleteList').classList.remove('show');
|
||
acActiveIndex = -1;
|
||
}
|
||
|
||
function selectAutocomplete(name) {
|
||
document.getElementById('verifierName').value = name;
|
||
hideAutocomplete();
|
||
loadSaved(name);
|
||
document.getElementById('savedSelect').value = name;
|
||
}
|
||
|
||
function onVerifierKeydown(e) {
|
||
const list = document.getElementById('autocompleteList');
|
||
if (!list.classList.contains('show')) return;
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
acActiveIndex = Math.min(acActiveIndex + 1, acItems.length - 1);
|
||
updateAcActive();
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
acActiveIndex = Math.max(acActiveIndex - 1, 0);
|
||
updateAcActive();
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (acActiveIndex >= 0 && acActiveIndex < acItems.length) {
|
||
selectAutocomplete(acItems[acActiveIndex].name);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
hideAutocomplete();
|
||
}
|
||
}
|
||
|
||
function updateAcActive() {
|
||
const items = document.querySelectorAll('.autocomplete-item');
|
||
items.forEach((el, i) => {
|
||
el.classList.toggle('active', i === acActiveIndex);
|
||
});
|
||
if (acActiveIndex >= 0 && items[acActiveIndex]) {
|
||
items[acActiveIndex].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
// 点击外部关闭自动补全
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.autocomplete-wrap')) {
|
||
hideAutocomplete();
|
||
}
|
||
});
|
||
|
||
// ========== 统计 ==========
|
||
function updateStats() {
|
||
let totalPass=0, totalFail=0, totalPartial=0, totalUntested=0, totalItems=0;
|
||
|
||
DOMAINS.forEach((domain, di) => {
|
||
let dPass=0, dFail=0, dPartial=0, dUntested=0;
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val = checked ? checked.value : "untested";
|
||
if (val==="pass") dPass++;
|
||
else if (val==="fail") dFail++;
|
||
else if (val==="partial") dPartial++;
|
||
else dUntested++;
|
||
});
|
||
|
||
const row = document.getElementById(`summary-row-${di}`);
|
||
if (row) {
|
||
row.querySelector(".s-pass").textContent = dPass;
|
||
row.querySelector(".s-fail").textContent = dFail;
|
||
row.querySelector(".s-partial").textContent = dPartial;
|
||
row.querySelector(".s-untested").textContent = dUntested;
|
||
const tested = dPass + dFail + dPartial;
|
||
const rate = domain.items.length > 0 ? Math.round(tested / domain.items.length * 100) : 0;
|
||
row.querySelector(".s-rate").textContent = rate + "%";
|
||
}
|
||
|
||
// Domain progress bar
|
||
const tested = dPass + dFail + dPartial;
|
||
const rate = domain.items.length > 0 ? Math.round(tested / domain.items.length * 100) : 0;
|
||
const fill = document.getElementById(`progress-${di}`);
|
||
if (fill) fill.style.width = rate + "%";
|
||
|
||
totalPass += dPass; totalFail += dFail; totalPartial += dPartial; totalUntested += dUntested;
|
||
totalItems += domain.items.length;
|
||
});
|
||
|
||
document.getElementById("statPass").textContent = totalPass;
|
||
document.getElementById("statFail").textContent = totalFail;
|
||
document.getElementById("statPartial").textContent = totalPartial;
|
||
document.getElementById("statUntested").textContent = totalUntested;
|
||
document.getElementById("statTotal").textContent = totalItems;
|
||
const tested = totalPass + totalFail + totalPartial;
|
||
const rate = totalItems > 0 ? Math.round(tested / totalItems * 100) : 0;
|
||
document.getElementById("statRate").textContent = rate + "%";
|
||
|
||
document.getElementById("total-count").textContent = totalItems;
|
||
document.getElementById("total-pass").textContent = totalPass;
|
||
document.getElementById("total-fail").textContent = totalFail;
|
||
document.getElementById("total-partial").textContent = totalPartial;
|
||
document.getElementById("total-untested").textContent = totalUntested;
|
||
document.getElementById("total-rate").textContent = rate + "%";
|
||
|
||
// Overall progress bar
|
||
const progressEl = document.getElementById("overallProgress");
|
||
if (progressEl) progressEl.style.width = rate + "%";
|
||
|
||
// Row colors
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const row = document.getElementById(`row-${uid}`);
|
||
if (!row) return;
|
||
const checked2 = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val2 = checked2 ? checked2.value : "untested";
|
||
row.classList.remove('row-fail', 'row-partial');
|
||
if (val2 === 'fail') row.classList.add('row-fail');
|
||
else if (val2 === 'partial') row.classList.add('row-partial');
|
||
});
|
||
});
|
||
|
||
// 自动计算结论
|
||
updateConclusion(totalPass, totalFail, totalPartial, totalUntested, totalItems);
|
||
}
|
||
|
||
function updateConclusion(pass, fail, partial, untested, total) {
|
||
const resultEl = document.getElementById("conclusionResult");
|
||
const passRateEl = document.getElementById("conclusionPassRate");
|
||
const issuesEl = document.getElementById("conclusionIssues");
|
||
const nameEl = document.getElementById("conclusionName");
|
||
const dateEl = document.getElementById("conclusionDate");
|
||
if (!resultEl) return;
|
||
|
||
const tested = pass + fail + partial;
|
||
const passRate = tested > 0 ? Math.round(pass / tested * 100) : 0;
|
||
let conclusion = "未测试", color = "var(--gray)";
|
||
|
||
if (tested === 0) { conclusion = "未测试"; color = "var(--gray)"; }
|
||
else if (fail === 0 && partial === 0) { conclusion = "✅ 通过"; color = "var(--success)"; }
|
||
else if (fail === 0 && partial > 0) { conclusion = "⚠️ 有条件通过"; color = "var(--warning)"; }
|
||
else { conclusion = "❌ 不通过"; color = "var(--danger)"; }
|
||
|
||
resultEl.textContent = conclusion;
|
||
resultEl.style.color = color;
|
||
passRateEl.textContent = tested > 0 ? `${passRate}%(${pass}/${tested})` : "-";
|
||
|
||
// 自动汇总主要问题
|
||
const issues = [];
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val = checked ? checked.value : "untested";
|
||
const remarkEl = document.getElementById(`remark-${uid}`);
|
||
const remark = remarkEl ? remarkEl.value.trim() : "";
|
||
if (val === "fail") issues.push(`❌ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`);
|
||
else if (val === "partial") issues.push(`⚠️ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`);
|
||
});
|
||
});
|
||
issuesEl.value = issues.length > 0 ? issues.join("\n") : "无主要问题";
|
||
|
||
const verifier = document.getElementById("verifierName").value.trim();
|
||
if (verifier) nameEl.value = verifier;
|
||
if (dateEl) dateEl.value = todayStr();
|
||
}
|
||
|
||
// ========== 数据收集/恢复 ==========
|
||
function collectData() {
|
||
const results = {};
|
||
const remarks = {};
|
||
let totalPass=0, totalFail=0, totalPartial=0, totalUntested=0, totalItems=0;
|
||
const issuesList = [];
|
||
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
|
||
const val = checked ? checked.value : "untested";
|
||
results[item.id] = val;
|
||
const remarkEl = document.getElementById(`remark-${uid}`);
|
||
remarks[item.id] = remarkEl ? remarkEl.value : "";
|
||
|
||
if (val==="pass") totalPass++;
|
||
else if (val==="fail") { totalFail++; issuesList.push(`❌ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); }
|
||
else if (val==="partial") { totalPartial++; issuesList.push(`⚠️ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); }
|
||
else totalUntested++;
|
||
totalItems++;
|
||
});
|
||
});
|
||
|
||
const tested = totalPass + totalFail + totalPartial;
|
||
let conclusionResult = "";
|
||
if (tested === 0) conclusionResult = "";
|
||
else if (totalFail === 0 && totalPartial === 0) conclusionResult = "pass";
|
||
else if (totalFail === 0 && totalPartial > 0) conclusionResult = "conditional";
|
||
else conclusionResult = "fail";
|
||
|
||
return {
|
||
verifier: document.getElementById("verifierName").value,
|
||
results, remarks,
|
||
conclusionName: document.getElementById("conclusionName")?.value || "",
|
||
conclusionDate: document.getElementById("conclusionDate")?.value || "",
|
||
conclusionResult,
|
||
conclusionIssues: issuesList.join("\n"),
|
||
screenshots,
|
||
signature1: getSigData(1),
|
||
signature2: getSigData(2),
|
||
};
|
||
}
|
||
|
||
function applyData(data) {
|
||
if (!data) return;
|
||
document.getElementById("verifierName").value = data.verifierName || data.verifier || "";
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
const val = data.results?.[item.id] || "untested";
|
||
const radio = document.querySelector(`input[name="r-${uid}"][value="${val}"]`);
|
||
if (radio) radio.checked = true;
|
||
const remarkEl = document.getElementById(`remark-${uid}`);
|
||
if (remarkEl) remarkEl.value = data.remarks?.[item.id] || "";
|
||
});
|
||
});
|
||
if (data.conclusionName) document.getElementById("conclusionName").value = data.conclusionName;
|
||
if (data.conclusionDate) document.getElementById("conclusionDate").value = data.conclusionDate;
|
||
|
||
// Restore screenshots
|
||
if (data.screenshots) {
|
||
Object.assign(screenshots, data.screenshots);
|
||
DOMAINS.forEach((domain, di) => {
|
||
domain.items.forEach((item, ii) => {
|
||
const uid = `${di}-${ii}`;
|
||
if (screenshots[uid]) {
|
||
const cell = document.querySelector(`#row-${uid} .screenshot-cell`);
|
||
if (cell) cell.innerHTML = `<img class="screenshot-thumb" src="${screenshots[uid]}" onclick="viewScreenshot('${uid}')" title="点击查看">`;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
updateStats();
|
||
}
|
||
|
||
// ========== 保存/加载/提交(API)==========
|
||
async function saveCurrent() {
|
||
const name = document.getElementById("verifierName").value.trim();
|
||
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||
const data = collectData();
|
||
try {
|
||
await apiCall('POST', '/acceptance-tests', {
|
||
verifierName: name, results: data.results, remarks: data.remarks,
|
||
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||
});
|
||
await loadSavedList();
|
||
document.getElementById("savedSelect").value = name;
|
||
showToast(`✅ 已保存:${name}`);
|
||
} catch (err) { showToast(`保存失败:${err.message}`); }
|
||
}
|
||
|
||
async function autoSave() {
|
||
const name = document.getElementById("verifierName").value.trim();
|
||
if (!name) return;
|
||
const data = collectData();
|
||
try {
|
||
await apiCall('POST', '/acceptance-tests', {
|
||
verifierName: name, results: data.results, remarks: data.remarks,
|
||
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||
});
|
||
await loadSavedList();
|
||
} catch (err) { console.warn('自动保存失败:', err.message); }
|
||
}
|
||
|
||
async function loadSaved(name) {
|
||
if (!name) return;
|
||
try {
|
||
const res = await apiCall('GET', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||
applyData(res.data);
|
||
showToast(`已加载:${name}`);
|
||
} catch (err) { showToast(`加载失败:${err.message}`); }
|
||
}
|
||
|
||
async function deleteSaved() {
|
||
const name = document.getElementById("verifierName").value.trim();
|
||
const sel = document.getElementById("savedSelect").value;
|
||
const target = name || sel;
|
||
if (!target) { showToast("请先选择要删除的记录"); return; }
|
||
if (!confirm(`确认删除「${target}」的验收记录?`)) return;
|
||
try {
|
||
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(target)}`);
|
||
await loadSavedList();
|
||
if (target === name) document.getElementById("verifierName").value = "";
|
||
showToast(`已删除:${target}`);
|
||
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||
}
|
||
|
||
async function deleteSavedByName(name) {
|
||
if (!confirm(`确认删除「${name}」的验收记录?`)) return;
|
||
try {
|
||
await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(name)}`);
|
||
await loadSavedList();
|
||
showAutocomplete();
|
||
showToast(`已删除:${name}`);
|
||
} catch (err) { showToast(`删除失败:${err.message}`); }
|
||
}
|
||
|
||
async function submitReport() {
|
||
const name = document.getElementById("verifierName").value.trim();
|
||
if (!name) { showToast("请先输入验收人姓名"); return; }
|
||
const data = collectData();
|
||
if (!data.conclusionResult) { showToast("请先完成至少一项测试"); return; }
|
||
if (!confirm(`确认提交「${name}」的验收报告?提交后不可再编辑。`)) return;
|
||
try {
|
||
await apiCall('POST', '/acceptance-tests', {
|
||
verifierName: name, results: data.results, remarks: data.remarks,
|
||
conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate,
|
||
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||
screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2,
|
||
});
|
||
await apiCall('POST', `/acceptance-tests/${encodeURIComponent(name)}/submit`, {
|
||
conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues,
|
||
});
|
||
await loadSavedList();
|
||
showToast(`✅ 验收报告已提交:${name}`);
|
||
} catch (err) { showToast(`提交失败:${err.message}`); }
|
||
}
|
||
|
||
// ========== 下载 ==========
|
||
function downloadCSV() {
|
||
const data = collectData();
|
||
if (!data.verifier) { showToast("请先输入验收人姓名"); return; }
|
||
let csv = "\uFEFF验收人,功能域,编号,功能,操作步骤,预期结果,结果,备注\n";
|
||
const resultMap = { pass:"通过", fail:"失败", partial:"部分通过", untested:"未测试" };
|
||
DOMAINS.forEach((domain) => {
|
||
domain.items.forEach((item) => {
|
||
const result = resultMap[data.results[item.id] || "untested"];
|
||
const remark = (data.remarks[item.id] || "").replace(/"/g,'""');
|
||
const func = item.func.replace(/"/g,'""');
|
||
const steps = item.steps.replace(/"/g,'""');
|
||
const expected = item.expected.replace(/"/g,'""');
|
||
csv += `"${data.verifier}","${domain.name}","${item.id}","${func}","${steps}","${expected}","${result}","${remark}"\n`;
|
||
});
|
||
});
|
||
// Summary
|
||
csv += "\n验收统计\n域,总项数,通过,失败,部分通过,未测试,完成率\n";
|
||
DOMAINS.forEach((domain, di) => {
|
||
let p=0,f=0,pa=0,u=0;
|
||
domain.items.forEach((item) => {
|
||
const v = data.results[item.id] || "untested";
|
||
if(v==="pass")p++; else if(v==="fail")f++; else if(v==="partial")pa++; else u++;
|
||
});
|
||
const tested = p+f+pa;
|
||
const rate = domain.items.length>0 ? Math.round(tested/domain.items.length*100) : 0;
|
||
csv += `"${domain.name}",${domain.items.length},${p},${f},${pa},${u},${rate}%\n`;
|
||
});
|
||
// Conclusion
|
||
const cr = data.conclusionResult || "";
|
||
const crText = {pass:"通过",conditional:"有条件通过",fail:"不通过"}[cr] || "未选择";
|
||
csv += `\n验收结论\n验收人,${data.verifier}\n验收日期,${data.conclusionDate}\n总体结论,${crText}\n遗留问题,${(data.conclusionIssues||"").replace(/"/g,'""')}\n`;
|
||
downloadFile(csv, `验收测试_${data.verifier}_${todayStr()}.csv`, "text/csv;charset=utf-8");
|
||
showToast("CSV已下载");
|
||
}
|
||
|
||
function downloadExcel() {
|
||
const data = collectData();
|
||
if (!data.verifier) { showToast("请先输入验收人姓名"); return; }
|
||
const resultMap = { pass:"✅通过", fail:"❌失败", partial:"⚠️部分通过", untested:"🚫未测试" };
|
||
|
||
// Build HTML table for Excel
|
||
let html = `<table border="1"><tr style="font-weight:bold;background:#e0e7ff"><td>验收人</td><td>功能域</td><td>编号</td><td>功能</td><td>操作步骤</td><td>预期结果</td><td>结果</td><td>备注</td></tr>`;
|
||
DOMAINS.forEach((domain) => {
|
||
domain.items.forEach((item) => {
|
||
const result = resultMap[data.results[item.id] || "untested"];
|
||
const remark = data.remarks[item.id] || "";
|
||
html += `<tr><td>${data.verifier}</td><td>${domain.name}</td><td>${item.id}</td><td>${item.func}</td><td>${item.steps}</td><td>${item.expected}</td><td>${result}</td><td>${remark}</td></tr>`;
|
||
});
|
||
});
|
||
html += `</table>`;
|
||
|
||
// Summary
|
||
html += `<br><table border="1"><tr style="font-weight:bold;background:#e0e7ff"><td>域</td><td>总项数</td><td>通过</td><td>失败</td><td>部分通过</td><td>未测试</td><td>完成率</td></tr>`;
|
||
DOMAINS.forEach((domain) => {
|
||
let p=0,f=0,pa=0,u=0;
|
||
domain.items.forEach((item) => {
|
||
const v = data.results[item.id] || "untested";
|
||
if(v==="pass")p++; else if(v==="fail")f++; else if(v==="partial")pa++; else u++;
|
||
});
|
||
const tested=p+f+pa;
|
||
const rate = domain.items.length>0 ? Math.round(tested/domain.items.length*100) : 0;
|
||
html += `<tr><td>${domain.name}</td><td>${domain.items.length}</td><td>${p}</td><td>${f}</td><td>${pa}</td><td>${u}</td><td>${rate}%</td></tr>`;
|
||
});
|
||
html += `</table>`;
|
||
|
||
// Conclusion
|
||
const cr = data.conclusionResult || "";
|
||
const crText = {pass:"通过",conditional:"有条件通过",fail:"不通过"}[cr] || "未选择";
|
||
html += `<br><table border="1"><tr><td>验收人</td><td>${data.verifier}</td></tr><tr><td>验收日期</td><td>${data.conclusionDate}</td></tr><tr><td>总体结论</td><td>${crText}</td></tr><tr><td>遗留问题</td><td>${data.conclusionIssues||""}</td></tr></table>`;
|
||
|
||
const fullHtml = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="UTF-8"></head><body>${html}</body></html>`;
|
||
downloadFile(fullHtml, `验收测试_${data.verifier}_${todayStr()}.xls`, "application/vnd.ms-excel;charset=utf-8");
|
||
showToast("Excel已下载");
|
||
}
|
||
|
||
function downloadFile(content, filename, mime) {
|
||
const blob = new Blob([content], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url; a.download = filename;
|
||
document.body.appendChild(a); a.click();
|
||
document.body.removeChild(a); URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function todayStr() {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||
}
|
||
|
||
// ========== 截图上传 ==========
|
||
const screenshots = {}; // { "di-ii": dataURL }
|
||
|
||
function uploadScreenshot(di, ii) {
|
||
const uid = `${di}-${ii}`;
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
screenshots[uid] = ev.target.result;
|
||
const cell = document.querySelector(`#row-${uid} .screenshot-cell`);
|
||
if (cell) cell.innerHTML = `<img class="screenshot-thumb" src="${ev.target.result}" onclick="viewScreenshot('${uid}')" title="点击查看">`;
|
||
scheduleAutoSave();
|
||
showToast("截图已上传");
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
input.click();
|
||
}
|
||
|
||
function viewScreenshot(uid) {
|
||
const data = screenshots[uid];
|
||
if (!data) return;
|
||
const w = window.open('', '_blank');
|
||
w.document.write(`<html><head><title>截图查看</title></head><body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1f2937"><img src="${data}" style="max-width:100%;max-height:100vh"></body></html>`);
|
||
}
|
||
|
||
// ========== 手写签名 ==========
|
||
let sigCtx1, sigCtx2, sigDrawing = false;
|
||
|
||
function initSignatures() {
|
||
[1, 2].forEach(n => {
|
||
const canvas = document.getElementById(`sigCanvas${n}`);
|
||
if (!canvas) return;
|
||
const box = document.getElementById(`sigBox${n}`);
|
||
canvas.width = box.offsetWidth;
|
||
canvas.height = 80;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.strokeStyle = '#1f2937';
|
||
ctx.lineWidth = 2;
|
||
ctx.lineCap = 'round';
|
||
|
||
const getPos = (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||
const y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
|
||
return { x, y };
|
||
};
|
||
|
||
const start = (e) => { e.preventDefault(); sigDrawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); };
|
||
const move = (e) => { if (!sigDrawing) return; e.preventDefault(); const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); };
|
||
const end = () => { sigDrawing = false; };
|
||
|
||
canvas.addEventListener('mousedown', start);
|
||
canvas.addEventListener('mousemove', move);
|
||
canvas.addEventListener('mouseup', end);
|
||
canvas.addEventListener('mouseleave', end);
|
||
canvas.addEventListener('touchstart', start);
|
||
canvas.addEventListener('touchmove', move);
|
||
canvas.addEventListener('touchend', end);
|
||
});
|
||
}
|
||
|
||
function clearSig(n) {
|
||
const canvas = document.getElementById(`sigCanvas${n}`);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
function getSigData(n) {
|
||
const canvas = document.getElementById(`sigCanvas${n}`);
|
||
if (!canvas) return '';
|
||
// Check if canvas is empty
|
||
const ctx = canvas.getContext('2d');
|
||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||
const isEmpty = !data.some((v, i) => i % 4 === 3 && v > 0);
|
||
return isEmpty ? '' : canvas.toDataURL();
|
||
}
|
||
|
||
// ========== PDF 导出(打印优化)==========
|
||
function exportPDF() {
|
||
const data = collectData();
|
||
if (!data.verifier) { showToast("请先输入验收人姓名"); return; }
|
||
|
||
// 临时展开所有折叠的域
|
||
const collapsed = [];
|
||
DOMAINS.forEach((_, di) => {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
if (el && el.classList.contains('collapsed')) { collapsed.push(di); el.classList.remove('collapsed'); }
|
||
});
|
||
|
||
// 设置打印模式
|
||
document.body.classList.add('print-mode');
|
||
window.print();
|
||
|
||
// 恢复
|
||
document.body.classList.remove('print-mode');
|
||
collapsed.forEach(di => {
|
||
const el = document.getElementById(`domain-${di}`);
|
||
if (el) { el.classList.add('collapsed'); el.querySelector(".toggle").textContent = "▶"; }
|
||
});
|
||
}
|
||
|
||
// ========== Toast ==========
|
||
function showToast(msg) {
|
||
const t = document.getElementById("toast");
|
||
t.textContent = msg;
|
||
t.classList.add("show");
|
||
setTimeout(() => t.classList.remove("show"), 2500);
|
||
}
|
||
|
||
// ========== 初始化 ==========
|
||
renderAll();
|
||
loadSavedList();
|
||
|
||
// 自动填充验收日期
|
||
const dateEl = document.getElementById("conclusionDate");
|
||
if (dateEl) dateEl.value = todayStr();
|
||
</script>
|
||
</body>
|
||
</html>
|