ba33d517c1
后端: - 新增 Role / RolePermission 实体(自动 seed 系统角色) - PermissionService——通过 isAdmin / TenantMember 链路解析用户权限 - @Permission() 装饰器 + PermissionsGuard 守卫 - /api/permissions 和 /api/roles REST API - UserController 内联 role 检查迁移到 @Permission() - PermissionModule 全局注册 前端: - usePermissions hook——获取当前用户权限集 - PermissionGate 组件级门控 - PermissionSettingsView——角色列表+权限矩阵编辑页面 - SettingsView 新增「权限管理」Tab(仅 admin 可见) - 权限预览(26 项,7 分类) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
import { chromium } from 'playwright';
|
||
|
||
const BASE = 'http://localhost:13001';
|
||
const SA_REPLIES = [
|
||
'需要审查代码质量和安全性', // #1 代码审查
|
||
'还要检查逻辑正确性和性能问题', // #2 代码质量
|
||
'用清晰的提示词告诉AI具体需求', // #3 prompt技巧
|
||
'要注意数据安全和隐私保护', // #4 安全
|
||
'AI协作时要明确分工,人做决策', // #5 协作
|
||
'检查AI生成的内容是否准确', // #6 验证输出
|
||
'要测试边界情况和异常处理', // #7 测试
|
||
'把大任务拆成小步骤和AI沟通', // #8 任务拆分
|
||
'持续学习和更新对AI工具的认识', // #9 学习成长
|
||
'评估成本效益,选最合适的方案', // #10 综合
|
||
];
|
||
|
||
/** Fill a textarea via native setter + input event (reliable for React controlled inputs) */
|
||
async function fillTextarea(page, text) {
|
||
await page.evaluate((t) => {
|
||
const ta = document.querySelector('textarea');
|
||
if (!ta) return;
|
||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||
setter?.call(ta, t);
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, text);
|
||
await new Promise(r => setTimeout(r, 300));
|
||
}
|
||
|
||
/** Click the send button (wait for it to be enabled, then regular or force click) */
|
||
async function clickSendButton(page) {
|
||
try {
|
||
await page.waitForFunction(() => {
|
||
const btn = document.querySelector('button:has(svg.lucide-send)');
|
||
return btn && !btn.disabled && btn.offsetParent !== null;
|
||
}, { timeout: 15000 });
|
||
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 });
|
||
} catch {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
/** Wait for spinner to disappear */
|
||
async function waitForIdle(page) {
|
||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 90000 }).catch(() => {});
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
}
|
||
|
||
async function run() {
|
||
console.log('=== AuraK 10题考核多轮对话测试 ===\n');
|
||
const browser = await chromium.launch({ headless: true });
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||
|
||
// Login
|
||
console.log('[1] 登录...');
|
||
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
|
||
await page.waitForTimeout(1000);
|
||
await page.locator('input[type="text"]').first().fill('admin');
|
||
await page.locator('input[type="password"]').first().fill('admin123');
|
||
await page.locator('button[type="submit"]').click();
|
||
await page.waitForURL('**/');
|
||
console.log(' ✅ 登录成功');
|
||
|
||
// Assessment page
|
||
await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// Select template
|
||
await page.locator('button:has-text("AI协作技巧")').first().click();
|
||
await page.waitForTimeout(500);
|
||
await page.locator('button:has-text("开始专业评估")').first().click();
|
||
|
||
// Wait for first question
|
||
console.log('[2] 等待出题...');
|
||
for (let i = 0; i < 90; i++) {
|
||
const text = await page.textContent('body').catch(() => '');
|
||
if (text.includes('问题 ') || text.includes('Question ')) break;
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
}
|
||
console.log(' ✅ 第 1 题已出现');
|
||
await waitForIdle(page);
|
||
|
||
// Answer questions
|
||
let qIdx = 1;
|
||
let saCount = 0, followUpCount = 0;
|
||
const totalQs = 10;
|
||
const startTime = Date.now();
|
||
// Per-question timeout: 5 minutes (AI generation can be slow)
|
||
const Q_TIMEOUT = 300; // 5 min in seconds
|
||
|
||
while (qIdx <= totalQs) {
|
||
// Detect question type
|
||
const state = await page.evaluate(() => {
|
||
const allBtns = Array.from(document.querySelectorAll('button'));
|
||
const optionBtns = allBtns.filter(b =>
|
||
/^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5 &&
|
||
!(b.textContent || '').includes('AuraK') && !(b.textContent || '').includes('Admin')
|
||
);
|
||
const confirmBtn = allBtns.find(b => (b.textContent || '').includes('确认答案'));
|
||
const ta = document.querySelector('textarea');
|
||
return {
|
||
choiceCount: optionBtns.length,
|
||
hasTextarea: ta !== null && ta.offsetParent !== null,
|
||
firstChoice: optionBtns[0]?.textContent?.trim().substring(0, 40) || '',
|
||
hasConfirmBtn: confirmBtn !== null,
|
||
busy: document.querySelector('.animate-spin') !== null,
|
||
hasQuestion: (document.body.textContent || '').includes('问题 ') || (document.body.textContent || '').includes('Question '),
|
||
};
|
||
});
|
||
|
||
// If busy (spinner visible), wait and retry
|
||
if (state.busy) {
|
||
console.log(` ⏳ AI正在处理...`);
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
continue;
|
||
}
|
||
|
||
// If neither question type detected but question text exists, wait more
|
||
if (state.choiceCount === 0 && !state.hasTextarea && state.hasQuestion) {
|
||
console.log(` ⏳ 题型未就绪,等待...`);
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
continue;
|
||
}
|
||
|
||
// Read current question text
|
||
const questionText = await page.evaluate(() => {
|
||
const bubbles = Array.from(document.querySelectorAll('.px-5.py-4'));
|
||
for (let i = bubbles.length - 1; i >= 0; i--) {
|
||
const el = bubbles[i];
|
||
const text = el.textContent || '';
|
||
if (text.length > 25 && !(el.getAttribute('class') || '').includes('bg-indigo')) {
|
||
return text.substring(0, 160);
|
||
}
|
||
}
|
||
return '';
|
||
});
|
||
|
||
if (state.choiceCount > 0) {
|
||
// ── CHOICE ──
|
||
const qShort = questionText.replace(/\s+/g, ' ').substring(0, 100);
|
||
console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${qShort}..."`);
|
||
|
||
const btns = page.locator('button.w-full.text-left.px-5.py-4');
|
||
const count = await btns.count();
|
||
if (count > 0) {
|
||
await btns.first().click();
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
|
||
if (state.hasConfirmBtn) {
|
||
await page.locator('button:has-text("确认答案")').click();
|
||
console.log(` ✅ 已提交`);
|
||
}
|
||
qIdx++;
|
||
// After submitting a choice, wait for transition
|
||
await waitForIdle(page);
|
||
|
||
} else if (state.hasTextarea) {
|
||
// ── SHORT ANSWER ──
|
||
saCount++;
|
||
const replyIdx = Math.min(saCount - 1, SA_REPLIES.length - 1);
|
||
console.log(`\n 🟩 第 ${qIdx}/${totalQs} 题 (简答 #${saCount})`);
|
||
|
||
await page.locator('textarea').first().click();
|
||
await fillTextarea(page, SA_REPLIES[replyIdx]);
|
||
console.log(` 📝 已输入: "${SA_REPLIES[replyIdx].substring(0, 20)}..."`);
|
||
await clickSendButton(page);
|
||
console.log(` ✅ 已提交`);
|
||
|
||
// Wait for grading
|
||
await waitForIdle(page);
|
||
|
||
// Check for follow-up question
|
||
const stillTA = await page.evaluate(() => {
|
||
const ta = document.querySelector('textarea');
|
||
return ta !== null && ta.offsetParent !== null;
|
||
});
|
||
|
||
if (stillTA) {
|
||
followUpCount++;
|
||
const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)];
|
||
console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`);
|
||
|
||
await page.locator('textarea').first().click();
|
||
await fillTextarea(page, fReply);
|
||
console.log(` 📝 追问已输入: "${fReply.substring(0, 20)}..."`);
|
||
await clickSendButton(page);
|
||
console.log(` ✅ 追问已提交`);
|
||
|
||
await waitForIdle(page);
|
||
}
|
||
|
||
qIdx++;
|
||
|
||
} else {
|
||
// ── WAITING for question to appear ──
|
||
// Check for question text in body
|
||
const bodyText = await page.textContent('body').catch(() => '');
|
||
if (bodyText.includes('问题 ') || bodyText.includes('Question ')) {
|
||
// Question is there but types not detected yet - wait for spinner then retry
|
||
console.log(` ⏳ 第 ${qIdx} 题文本已见,等待组件渲染...`);
|
||
await waitForIdle(page);
|
||
continue;
|
||
}
|
||
|
||
// Check if assessment completed
|
||
if (bodyText.includes('合格') || bodyText.includes('VERIFIED') || bodyText.includes('LEVEL')) {
|
||
console.log(`\n 📋 考核已完成!`);
|
||
break;
|
||
}
|
||
|
||
// Check per-question timeout
|
||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||
if (elapsed > Q_TIMEOUT * qIdx + 120) {
|
||
console.log(` ⏰ 第 ${qIdx} 题生成超时,跳过`);
|
||
qIdx++;
|
||
continue;
|
||
}
|
||
|
||
console.log(` ⏳ 等待 AI 生成第 ${qIdx} 题...`);
|
||
await waitForIdle(page);
|
||
await new Promise(r => setTimeout(r, 5000));
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Wait for results page
|
||
console.log('\n ⏳ 等待考核结果完成...');
|
||
await waitForIdle(page);
|
||
await new Promise(r => setTimeout(r, 5000));
|
||
|
||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||
const body = await page.textContent('body');
|
||
const scores = body.match(/\d+\/10/g);
|
||
const level = body.match(/LEVEL:\s*(\w+)/i)?.[1] || body.match(/等级[::]\s*(\w+)/)?.[1] || '?';
|
||
const passed = body.includes('合格') || body.includes('VERIFIED');
|
||
|
||
console.log(`\n 📊 结果 (耗时 ${Math.floor(elapsed/60)}分${elapsed%60}秒):`);
|
||
console.log(` 总题数: ${totalQs}`);
|
||
console.log(` 选择题: ${totalQs - saCount}`);
|
||
console.log(` 简答题: ${saCount}`);
|
||
console.log(` AI追问: ${followUpCount}次`);
|
||
console.log(` 分数: ${scores ? scores.join(', ') : '无'}`);
|
||
console.log(` 等级: ${level}`);
|
||
console.log(` ${passed ? '🎉 合格!' : '😅 未合格'}`);
|
||
|
||
if (followUpCount > 0) {
|
||
console.log(`\n 🎉 多轮对话正常工作!`);
|
||
} else if (saCount > 0) {
|
||
console.log(`\n ✅ 简答题正常回答,未触发追问(回答已完整)。`);
|
||
} else {
|
||
console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`);
|
||
}
|
||
|
||
await page.screenshot({ path: 'assessment-10q-result.png', fullPage: true });
|
||
console.log(' 📸 截图已保存');
|
||
|
||
await browser.close();
|
||
console.log('\n=== 完成 ===');
|
||
}
|
||
|
||
run().catch(e => { console.error('\n❌', e.message); process.exit(1); });
|