feat: 分层 RBAC 权限管理系统

后端:
- 新增 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>
This commit is contained in:
Developer
2026-06-08 23:25:22 +08:00
parent c57c3028e2
commit ba33d517c1
17 changed files with 1386 additions and 87 deletions
+113 -38
View File
@@ -1,7 +1,18 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:13001';
const SA_REPLIES = ['需要审查代码质量和安全性', '还要检查逻辑正确性和性能问题'];
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) {
@@ -24,14 +35,19 @@ async function clickSendButton(page) {
}, { timeout: 15000 });
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 });
} catch {
// Regular click failed (stale/detached element); wait briefly and force
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 考核多轮对话测试 ===\n');
console.log('=== AuraK 10题考核多轮对话测试 ===\n');
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
@@ -62,40 +78,67 @@ async function run() {
await new Promise(r => setTimeout(r, 2000));
}
console.log(' ✅ 第 1 题已出现');
// Wait spinner to finish
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
await new Promise(r => setTimeout(r, 2000));
await waitForIdle(page);
// Answer questions
let qIdx = 1;
let saCount = 0, followUpCount = 0;
const totalQs = 4;
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 choiceBtns = Array.from(document.querySelectorAll('button'))
.filter(b => /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5)
.filter(b => !(b.textContent || '').startsWith('AuraK'))
.filter(b => !(b.textContent || '').startsWith('Admin'));
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: choiceBtns.length,
choiceCount: optionBtns.length,
hasTextarea: ta !== null && ta.offsetParent !== null,
firstChoice: choiceBtns[0]?.textContent || '',
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) {
await new Promise(r => setTimeout(r, 2000));
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 ──
console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${state.firstChoice.substring(0, 30)}..."`);
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();
@@ -104,12 +147,14 @@ async function run() {
await new Promise(r => setTimeout(r, 500));
}
const confirm = page.locator('button:has-text("确认答案")');
if (await confirm.isVisible().catch(() => false)) {
await confirm.click();
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++;
@@ -123,17 +168,15 @@ async function run() {
console.log(` ✅ 已提交`);
// Wait for grading
await new Promise(r => setTimeout(r, 3000));
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
await new Promise(r => setTimeout(r, 2000));
await waitForIdle(page);
// Check for follow-up: textarea visible again at same question position
// Check for follow-up question
const stillTA = await page.evaluate(() => {
const ta = document.querySelector('textarea');
return ta !== null && ta.offsetParent !== null;
});
if (stillTA && followUpCount < SA_REPLIES.length - 1) {
if (stillTA) {
followUpCount++;
const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)];
console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`);
@@ -144,33 +187,62 @@ async function run() {
await clickSendButton(page);
console.log(` ✅ 追问已提交`);
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
await new Promise(r => setTimeout(r, 2000));
await waitForIdle(page);
}
qIdx++;
} else {
// Wait for anything to appear
console.log(` ⏳ 等待第 ${qIdx} 题...`);
await new Promise(r => setTimeout(r, 3000));
// ── 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 next question
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {});
await new Promise(r => setTimeout(r, 2000));
}
// Results
await new Promise(r => setTimeout(r, 4000));
// 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 📊 结果:`);
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(` AI追问: ${followUpCount}`);
console.log(` 分数: ${scores ? scores.join(', ') : '无'}`);
console.log(` 等级: ${level}`);
console.log(` ${passed ? '🎉 合格!' : '😅 未合格'}`);
if (followUpCount > 0) {
console.log(`\n 🎉 多轮对话正常工作!`);
@@ -180,6 +252,9 @@ async function run() {
console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`);
}
await page.screenshot({ path: 'assessment-10q-result.png', fullPage: true });
console.log(' 📸 截图已保存');
await browser.close();
console.log('\n=== 完成 ===');
}