diff --git a/public/index.html b/public/index.html
index 489195f..8e46515 100644
--- a/public/index.html
+++ b/public/index.html
@@ -177,7 +177,7 @@ async function loadList(p){
function page(d){const n=pageCur+d;if(n>=1&&n<=pageTotal)loadList(n)}
function exportCsv(){location.href='/api/export.csv'+qs()}
async function showDetail(id){
- const r=await api('/api/candidates/'+id);
+ const r=await api('/api/candidates/'+encodeURIComponent(id));
let det='';try{const j=JSON.parse(r.score_details);det=Object.entries(j).map(([k,v])=>`${k}=${Array.isArray(v)?JSON.stringify(v):v}`).join(' ')}catch{}
$('detailBody').innerHTML=`
${esc(r.name)} ${esc(r.score_level)} ${r.score}分 ${r.role_tag?`${esc(r.role_tag)}`:''}
diff --git a/scripts/smoke.js b/scripts/smoke.js
new file mode 100644
index 0000000..a1d7ea2
--- /dev/null
+++ b/scripts/smoke.js
@@ -0,0 +1,97 @@
+// smoke.js — zhaopin-admin 全流程冒烟测试(浏览器级)
+// 用法: node scripts/smoke.js [baseUrl]
+// 注意: 会在目标实例注册超管(testsuper)并创建HR(hr01) — 生产实例跑完需重置 users.db
+'use strict';
+const { chromium } = require('/www/projects/zc_recruitment-screener/scripts/node_modules/playwright-core');
+const BASE = process.argv[2] || 'http://192.168.100.133:8300';
+const fs = require('fs');
+fs.mkdirSync('/tmp/smoke-shots', { recursive: true });
+
+let passed = 0, failed = 0;
+const ok = (name, cond) => { cond ? passed++ : failed++; console.log(` ${cond ? '✅' : '❌'} ${name}`); };
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+(async () => {
+ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
+ const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
+ const $ = (sel) => p.locator(sel);
+
+ console.log('== 1. 初始化页面 ==');
+ await p.goto(BASE + '/', { waitUntil: 'networkidle' });
+ ok('标题=初始化超管账号', await $('#loginTitle').textContent() === '初始化超管账号');
+ ok('详情弹窗隐藏', await p.evaluate(() => getComputedStyle(document.getElementById('detailModal')).display === 'none'));
+ await p.screenshot({ path: '/tmp/smoke-shots/01-init.png' });
+
+ console.log('== 2. 注册超管并自动登录 ==');
+ await $('#f_user').fill('testsuper');
+ await $('#f_pass').fill('test123456');
+ await $('#loginBtn').click();
+ await p.waitForNavigation({ waitUntil: 'networkidle', timeout: 8000 }).catch(() => {});
+ await sleep(800);
+ ok('进入主界面', await $('#main').isVisible());
+ ok('身份=超管', (await $('#whoami').textContent()).includes('超管'));
+ await p.screenshot({ path: '/tmp/smoke-shots/02-dashboard.png' });
+
+ console.log('== 3. 看板 ==');
+ const statText = await $('#statCards').innerText();
+ ok('累计候选人为五位数(数据在库)', /\d{5,}/.test(statText));
+ ok('级别分布含推荐', statText.includes('推荐'));
+ ok('近7天趋势有柱', await p.evaluate(() => document.querySelectorAll('#trend .b').length) > 0);
+
+ console.log('== 4. 候选人列表与筛选 ==');
+ await sleep(500);
+ const rows1 = await p.evaluate(() => document.querySelectorAll('#tbody tr').length);
+ ok(`默认列表有数据(${rows1}行)`, rows1 > 0);
+ await p.selectOption('#q_level', '推荐');
+ await $('button:has-text("查询")').click(); await sleep(700);
+ const lvOk = await p.evaluate(() => [...document.querySelectorAll('#tbody tr')].every(tr => tr.textContent.includes('推荐')));
+ ok('筛选"推荐"后全部为推荐', lvOk);
+ await p.fill('#q_kw', '外贸'); await p.selectOption('#q_level', '');
+ await $('button:has-text("查询")').click(); await sleep(700);
+ const kwOk = await p.evaluate(() => document.querySelectorAll('#tbody tr').length >= 0);
+ ok('关键词筛选不报错', kwOk);
+
+ console.log('== 5. 候选人详情弹窗 ==');
+ await p.evaluate(() => document.querySelector('#tbody tr')?.click()); await sleep(800);
+ ok('弹窗打开且含简历全文', (await $('#detailBody').innerText()).includes('简历全文'));
+ await p.screenshot({ path: '/tmp/smoke-shots/03-detail.png' });
+ await p.evaluate(() => document.getElementById('detailModal').classList.add('hidden'));
+
+ console.log('== 6. CSV导出(带登录态) ==');
+ const csv = await p.evaluate(async () => {
+ const r = await fetch('/api/export.csv?level=' + encodeURIComponent('不相关'));
+ return (await r.text()).slice(0, 100);
+ });
+ ok('CSV表头正确', csv.includes('姓名') && csv.includes('门槛备注'));
+
+ console.log('== 7. 超管建HR账号 ==');
+ ok('账号管理面板可见', await $('#userPanel').isVisible());
+ await $('#nu_user').fill('hr01'); await $('#nu_pass').fill('hr123456');
+ await $('button:has-text("创建HR账号")').click(); await sleep(700);
+ const uText = await $('#utable').innerText();
+ ok('用户列表含hr01', uText.includes('hr01'));
+ await p.screenshot({ path: '/tmp/smoke-shots/04-users.png' });
+
+ console.log('== 8. 退出 → HR登录 ==');
+ await p.evaluate(() => logout()); await sleep(800);
+ await p.goto(BASE + '/', { waitUntil: 'networkidle' });
+ await $('#f_user').fill('hr01'); await $('#f_pass').fill('hr123456');
+ await $('#loginBtn').click(); await sleep(900);
+ ok('HR进入主界面', await $('#main').isVisible());
+ ok('HR看不到账号管理', await p.evaluate(() => !document.getElementById('userPanel') || document.getElementById('userPanel').classList.contains('hidden')));
+ const forbidden = await p.evaluate(async () => {
+ const r = await fetch('/api/users'); return r.status;
+ });
+ ok('HR访问用户API被403拦截', forbidden === 403);
+
+ console.log('== 9. 错误密码 ==');
+ await p.evaluate(() => logout()); await sleep(600);
+ await p.goto(BASE + '/', { waitUntil: 'networkidle' });
+ await $('#f_user').fill('hr01'); await $('#f_pass').fill('wrongpwd');
+ await $('#loginBtn').click(); await sleep(700);
+ ok('错误提示显示', (await $('#loginErr').innerText()).includes('错误'));
+
+ await b.close();
+ console.log(`\n结果: ${passed} 通过, ${failed} 失败`);
+ process.exit(failed ? 1 : 0);
+})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });