Files
TurboHR/docs/acceptance-test.html
selfrelease 68ebe6a4ff feat: 新增验收测试清单(199项24域)替代功能调查问卷; 修复工作台评分环文字重叠
- 新增 acceptance-test.html 交互式验收测试页面(验收人姓名/自动补全/自动统计/CSV+Excel下载)
- 新增 AcceptanceTestModal 组件, TopNav 中用验收测试弹窗替代 SurveyModal
- Settings 页问卷导出替换为验收测试结果导出(支持多验收人汇总MD)
- 修复 Dashboard 合规健康度评分环文字重叠: 圆环增大至w-32, 加overflow-hidden+truncate
2026-08-02 09:22:51 +08:00

1024 lines
57 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.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-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; }
/* 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 { position: static; }
.domain-section { break-inside: avoid; }
.domain-section.collapsed .test-table { display: table !important; }
.result-radio label { border: 1px solid #ccc; }
.btn { display: none !important; }
}
</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-delete" onclick="deleteSaved()">删除当前</button>
<button class="btn btn-delete" onclick="clearAllSaved()" style="background:#fee2e2;color:#ef4444">清空全部</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="window.print()">打印</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>
<!-- 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:"显示未找到相关结果" },
]},
];
const RESULT_LABELS = { pass: "✅", fail: "❌", partial: "⚠️", untested: "🚫" };
const STORAGE_PREFIX = "turbohr_acceptance_";
// ========== 渲染 ==========
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>
</tr></thead>
<tbody>`;
domain.items.forEach((item, ii) => {
const uid = `${di}-${ii}`;
html += `<tr>
<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>
</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
html += `<div class="conclusion-section">
<h2>验收结论</h2>
<div class="field"><label>验收人</label><input type="text" id="conclusionName" placeholder="验收人姓名"></div>
<div class="field"><label>验收日期</label><input type="text" id="conclusionDate" placeholder="YYYY-MM-DD"></div>
<div class="field"><label>总体结论</label>
<div class="checkbox-group">
<label><input type="radio" name="conclusion" value="pass"> 通过</label>
<label><input type="radio" name="conclusion" value="conditional"> 有条件通过</label>
<label><input type="radio" name="conclusion" value="fail"> 不通过</label>
</div>
</div>
<div class="field"><label>遗留问题</label></div>
<textarea id="conclusionIssues" placeholder="1. ...&#10;2. ...&#10;3. ..."></textarea>
</div>`;
main.innerHTML = html;
updateStats();
}
// ========== 交互 ==========
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 onResultChange(di, ii, result) {
updateStats();
autoSave();
}
function onRemarkChange(di, ii, value) {
autoSave();
}
function onVerifierChange() {
updateSavedList();
autoSave();
showAutocomplete();
}
// ========== 自动补全 ==========
let acActiveIndex = -1;
let acItems = [];
function getSavedNames() {
const names = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(STORAGE_PREFIX)) {
const name = key.substring(STORAGE_PREFIX.length);
let meta = '';
try {
const data = JSON.parse(localStorage.getItem(key) || '{}');
const vals = Object.values(data.results || {});
const pass = vals.filter(v => v === 'pass').length;
const total = vals.length;
const tested = vals.filter(v => v !== 'untested').length;
meta = `${tested}/${total} 已测 · ${pass} 通过`;
} catch {}
names.push({ name, meta });
}
}
return names.sort((a, b) => a.name.localeCompare(b.name));
}
function showAutocomplete() {
const input = document.getElementById('verifierName');
const list = document.getElementById('autocompleteList');
const q = input.value.trim().toLowerCase();
const saved = getSavedNames();
if (!q) {
// 输入为空时显示全部已保存
acItems = saved;
} else {
// 按字母/拼音模糊匹配
acItems = saved.filter(s => s.name.toLowerCase().includes(q));
}
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 + "%";
}
// ========== 保存/加载 ==========
function collectData() {
const results = {};
const remarks = {};
DOMAINS.forEach((domain, di) => {
domain.items.forEach((item, ii) => {
const uid = `${di}-${ii}`;
const checked = document.querySelector(`input[name="r-${uid}"]:checked`);
results[item.id] = checked ? checked.value : "untested";
const remarkEl = document.getElementById(`remark-${uid}`);
remarks[item.id] = remarkEl ? remarkEl.value : "";
});
});
return {
verifier: document.getElementById("verifierName").value,
results, remarks,
conclusionName: document.getElementById("conclusionName")?.value || "",
conclusionDate: document.getElementById("conclusionDate")?.value || "",
conclusionResult: document.querySelector('input[name="conclusion"]:checked')?.value || "",
conclusionIssues: document.getElementById("conclusionIssues")?.value || "",
savedAt: new Date().toISOString(),
};
}
function applyData(data) {
if (!data) return;
document.getElementById("verifierName").value = 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;
if (data.conclusionResult) {
const r = document.querySelector(`input[name="conclusion"][value="${data.conclusionResult}"]`);
if (r) r.checked = true;
}
if (data.conclusionIssues) document.getElementById("conclusionIssues").value = data.conclusionIssues;
updateStats();
}
function getStorageKey() {
const name = document.getElementById("verifierName").value.trim();
return name ? STORAGE_PREFIX + name : "";
}
function saveCurrent() {
const name = document.getElementById("verifierName").value.trim();
if (!name) { showToast("请先输入验收人姓名"); return; }
const data = collectData();
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data));
updateSavedList();
document.getElementById("savedSelect").value = name;
showToast(`已保存:${name}`);
}
function autoSave() {
const name = document.getElementById("verifierName").value.trim();
if (!name) return;
const data = collectData();
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data));
}
function loadSaved(name) {
if (!name) return;
const raw = localStorage.getItem(STORAGE_PREFIX + name);
if (!raw) { showToast("未找到保存记录"); return; }
applyData(JSON.parse(raw));
showToast(`已加载:${name}`);
}
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;
localStorage.removeItem(STORAGE_PREFIX + target);
updateSavedList();
if (target === name) document.getElementById("verifierName").value = "";
showToast(`已删除:${target}`);
}
function deleteSavedByName(name) {
if (!confirm(`确认删除「${name}」的验收记录?`)) return;
localStorage.removeItem(STORAGE_PREFIX + name);
updateSavedList();
showAutocomplete();
showToast(`已删除:${name}`);
}
function clearAllSaved() {
const keys = Object.keys(localStorage).filter(k => k.startsWith(STORAGE_PREFIX));
if (keys.length === 0) { showToast("无已保存记录"); return; }
if (!confirm(`确认清空全部 ${keys.length} 条验收记录?此操作不可恢复!`)) return;
keys.forEach(k => localStorage.removeItem(k));
updateSavedList();
showAutocomplete();
document.getElementById("verifierName").value = "";
showToast(`已清空 ${keys.length} 条记录`);
}
function updateSavedList() {
const sel = document.getElementById("savedSelect");
const current = sel.value;
sel.innerHTML = '<option value="">-- 加载已保存 --</option>';
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(STORAGE_PREFIX)) {
const name = key.substring(STORAGE_PREFIX.length);
sel.innerHTML += `<option value="${name}">${name}</option>`;
}
}
if (current) sel.value = current;
}
// ========== 下载 ==========
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")}`;
}
// ========== Toast ==========
function showToast(msg) {
const t = document.getElementById("toast");
t.textContent = msg;
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 2000);
}
// ========== 初始化 ==========
renderAll();
updateSavedList();
// 自动填充验收日期
const dateEl = document.getElementById("conclusionDate");
if (dateEl) dateEl.value = todayStr();
</script>
</body>
</html>