初始提交:ai-review 项目当前版本(含赛道一/二提交规范修订与时间节点文档)

This commit is contained in:
hangshuo652
2026-08-23 11:52:45 +08:00
commit 4da7044c4b
152 changed files with 33490 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+215
View File
@@ -0,0 +1,215 @@
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
const ROOT = 'D:\\Projects\\ai-review';
const SERVER_DIR = path.join(ROOT, 'server');
const WEB_DIR = path.join(ROOT, 'web');
const SCREENSHOTS = 'C:\\Users\\NB-076\\AppData\\Local\\Temp\\opencode\\screenshots';
fs.mkdirSync(SCREENSHOTS, { recursive: true });
async function waitForPort(port, timeout = 15000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const res = await fetch(`http://localhost:${port}`);
if (res.ok || res.status < 500) return true;
} catch {}
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Port ${port} not ready in ${timeout}ms`);
}
async function main() {
// Start backend
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], {
cwd: SERVER_DIR,
stdio: 'pipe',
shell: true,
env: { ...process.env }
});
server.stdout.on('data', d => process.stdout.write(`[server] ${d}`));
// Start frontend
const frontend = spawn('npx.cmd', ['vite', '--port', '14001'], {
cwd: WEB_DIR,
stdio: 'pipe',
shell: true,
});
frontend.stdout.on('data', d => process.stdout.write(`[web] ${d}`));
console.log('Waiting for services...');
await waitForPort(3002);
await waitForPort(14001);
console.log('Both services ready');
// Get password from .env
const envContent = fs.readFileSync(path.join(SERVER_DIR, '.env'), 'utf-8');
const pwdMatch = envContent.match(/AUTH_PASSWORD=(.+)/);
const password = pwdMatch ? pwdMatch[1].trim() : '620f4c96';
console.log(`Using password: ${password}`);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
// Step 1: Login page
console.log('\n=== Step 1: Login ===');
await page.goto('http://localhost:14001/login', { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
await page.screenshot({ path: path.join(SCREENSHOTS, '01-login-page.png'), fullPage: true });
// Login
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL('**/');
await page.waitForTimeout(1000);
console.log('Logged in successfully');
// Step 2: Dashboard
await page.screenshot({ path: path.join(SCREENSHOTS, '02-dashboard.png'), fullPage: true });
console.log('Dashboard screenshot taken');
// Step 3: Create project
console.log('\n=== Step 2: Create Project ===');
await page.click('.btn-new-project');
await page.waitForTimeout(300);
await page.fill('.new-project-form input', 'Demo Project');
await page.screenshot({ path: path.join(SCREENSHOTS, '03-create-project-form.png'), fullPage: true });
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SCREENSHOTS, '04-project-created.png'), fullPage: true });
console.log('Project created');
// Get project ID from URL
const url = page.url();
const projectId = url.split('/').pop();
console.log(`Project ID: ${projectId}`);
// Step 4: Upload standard
console.log('\n=== Step 3: Upload Standard ===');
await page.click('.tab:has-text("标准")');
await page.waitForTimeout(500);
await page.click('.section-header button');
await page.waitForTimeout(300);
const standardContent = `## 代码质量(40分)
规范、可读、健壮
## 架构设计(30分)
模块化、可扩展
## 功能完整性(30分)
需求覆盖、边界处理`;
await page.fill('.standard-form input:first-child', '通用评审标准');
await page.fill('.standard-form textarea', standardContent);
await page.screenshot({ path: path.join(SCREENSHOTS, '05-standard-form.png'), fullPage: true });
await page.click('.form-actions button:first-child');
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SCREENSHOTS, '06-standard-created.png'), fullPage: true });
console.log('Standard uploaded');
// Step 5: Create entries via API for speed
console.log('\n=== Step 4: Create Entries ===');
const entries = [
{ title: 'Todo App', repo_url: 'https://github.com/tastejs/todomvc.git', participant: '张三', difficulty: '中等' },
{ title: 'Express Example', repo_url: 'https://github.com/expressjs/express.git', participant: '李四', difficulty: '困难' },
];
const entryIds = [];
for (const entry of entries) {
const res = await fetch(`http://localhost:3002/api/projects/${projectId}/entries`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getToken(page)}` },
body: JSON.stringify(entry),
});
const data = await res.json();
entryIds.push(data.id);
console.log(`Created entry: ${entry.title} (${data.id})`);
}
// Navigate to entries tab
await page.click('.tab:has-text("条目")');
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SCREENSHOTS, '07-entries-list.png'), fullPage: true });
console.log('Entries visible');
// Step 6: Start review
console.log('\n=== Step 5: Start Review ===');
const startBtns = await page.locator('.btn-action:has-text("启动")');
const btnCount = await startBtns.count();
console.log(`Found ${btnCount} start buttons`);
if (btnCount > 0) {
await startBtns.first().click();
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(SCREENSHOTS, '08-review-started.png'), fullPage: true });
console.log('Review started');
// Wait a bit for AI review to process
await page.waitForTimeout(3000);
await page.reload();
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SCREENSHOTS, '09-after-review.png'), fullPage: true });
}
// Step 7: Force review for demo (set entries to reviewed state)
console.log('\n=== Step 6: Force Complete for Demo ===');
for (const eid of entryIds) {
await fetch(`http://localhost:3002/api/projects/${projectId}/entries/${eid}/force-review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getToken(page)}` },
body: JSON.stringify({
dimensions: [
{ name: '代码质量', score: 35 + Math.floor(Math.random() * 10), maxScore: 40 },
{ name: '架构设计', score: 22 + Math.floor(Math.random() * 8), maxScore: 30 },
{ name: '功能完整性', score: 25 + Math.floor(Math.random() * 5), maxScore: 30 },
]
}),
});
}
// Refresh and go to summary
await page.reload();
await page.waitForTimeout(500);
await page.click('.tab:has-text("汇总")');
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(SCREENSHOTS, '10-summary-view.png'), fullPage: true });
console.log('Summary view');
// Step 8: Detail panel
await page.click('.tab:has-text("条目")');
await page.waitForTimeout(500);
await page.reload();
await page.waitForTimeout(1000);
const titleCells = await page.locator('.title-cell');
if (await titleCells.count() > 0) {
await titleCells.first().click();
await page.waitForTimeout(500);
await page.screenshot({ path: path.join(SCREENSHOTS, '11-detail-panel.png'), fullPage: true });
// Close detail
await page.click('.btn-close');
await page.waitForTimeout(300);
}
console.log('\n=== All screenshots captured! ===');
console.log(`Screenshots in: ${SCREENSHOTS}`);
} catch (err) {
console.error('Error:', err.message);
await page.screenshot({ path: path.join(SCREENSHOTS, 'error.png'), fullPage: true });
} finally {
await browser.close();
server.kill();
frontend.kill();
}
}
async function getToken(page) {
return await page.evaluate(() => localStorage.getItem('token'));
}
main().catch(console.error);
+193
View File
@@ -0,0 +1,193 @@
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
const ROOT = 'D:\\Projects\\ai-review';
const SCREENSHOTS = 'C:\\Users\\NB-076\\AppData\\Local\\Temp\\opencode\\story';
fs.mkdirSync(SCREENSHOTS, { recursive: true });
async function waitForPort(port, timeout = 15000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try { const res = await fetch(`http://localhost:${port}`); if (res.ok || res.status < 500) return true; } catch {}
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Port ${port} not ready`);
}
async function ss(page, name) {
await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true });
console.log(` Screenshot: ${name}`);
}
async function main() {
// Start services
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], { cwd: path.join(ROOT, 'server'), stdio: 'pipe', shell: true });
const frontend = spawn('npx.cmd', ['vite', '--port', '14001'], { cwd: path.join(ROOT, 'web'), stdio: 'pipe', shell: true });
server.stdout.on('data', d => process.stdout.write(`[s] ${d}`));
frontend.stdout.on('data', d => process.stdout.write(`[f] ${d}`));
console.log('Waiting for services...');
await waitForPort(3002); await waitForPort(14001);
console.log('Ready!');
const envContent = fs.readFileSync(path.join(ROOT, 'server', '.env'), 'utf-8');
const password = (envContent.match(/AUTH_PASSWORD=(.+)/) || [,'620f4c96'])[1].trim();
const browser = await chromium.launch({ headless: false }); // visible so you can watch
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
// ═══════════════════════════════════════════════
// STORY: 技术大赛双赛道评审
// ═══════════════════════════════════════════════
// STEP 1: Login → Dashboard
console.log('\n=== 1. Login ===');
await page.goto('http://localhost:14001/login', { waitUntil: 'networkidle' });
await ss(page, '01-login.png');
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL('**/');
await page.waitForTimeout(1000);
await ss(page, '02-dashboard.png');
// STEP 2: Create Project "2026技术大赛"
console.log('\n=== 2. Create Project ===');
await page.click('.btn-new-project');
await page.waitForTimeout(300);
await page.fill('.new-project-form input', '2026技术大赛');
await ss(page, '03-create-project.png');
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
await page.waitForTimeout(500);
const projectUrl = page.url();
const projectId = projectUrl.split('/').pop();
console.log(`Project: ${projectId}`);
// STEP 3: Upload standards for both tracks
console.log('\n=== 3. Upload Standards ===');
await page.click('.tab:has-text("标准")');
await page.waitForTimeout(500);
const standard1 = fs.readFileSync(path.join(ROOT, 'docs', 'standards', '技术大赛-赛道一评审标准.md'), 'utf-8');
const standard2 = fs.readFileSync(path.join(ROOT, 'docs', 'standards', '技术大赛-赛道二评审标准.md'), 'utf-8');
// Upload 赛道一 standard
await page.click('.section-header button');
await page.waitForTimeout(300);
await page.fill('.standard-form input:first-child', '技术大赛-赛道一评审标准');
await page.fill('.standard-form input[placeholder*="标签"]', '赛道一');
await page.fill('.standard-form textarea', standard1);
await ss(page, '04-standard-track1-form.png');
await page.click('.form-actions button:first-child');
await page.waitForTimeout(1000);
// Upload 赛道二 standard
await page.click('.section-header button');
await page.waitForTimeout(300);
await page.fill('.standard-form input:first-child', '技术大赛-赛道二评审标准');
await page.fill('.standard-form input[placeholder*="标签"]', '赛道二');
await page.fill('.standard-form textarea', standard2);
await ss(page, '05-standard-track2-form.png');
await page.click('.form-actions button:first-child');
await page.waitForTimeout(1000);
await ss(page, '06-standards-list.png');
// STEP 4: Create entries via API (faster)
console.log('\n=== 4. Create Entries ===');
const token = await page.evaluate(() => localStorage.getItem('token'));
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
// Real repos that actually exist
const entries = [
{ title: '张三 - 代码助手', repo_url: 'https://github.com/features/copilot', category_tag: '赛道一', participant: '张三', difficulty: '困难' },
{ title: '李四 - TDD工具', repo_url: 'https://github.com/testdouble/tdd-toolkit', category_tag: '赛道一', participant: '李四', difficulty: '中等' },
{ title: '王五 - IDE插件', repo_url: 'https://github.com/microsoft/vscode-extension-samples', category_tag: '赛道二', participant: '王五', difficulty: '困难' },
{ title: '赵六 - 范式创新', repo_url: 'https://github.com/features/copilot', category_tag: '赛道二', participant: '赵六', difficulty: '中等' },
];
for (const entry of entries) {
await fetch(`http://localhost:3002/api/projects/${projectId}/entries`, {
method: 'POST', headers: auth,
body: JSON.stringify(entry),
});
}
await page.click('.tab:has-text("条目")');
await page.waitForTimeout(1000);
await ss(page, '07-entries-list.png');
console.log('4 entries created');
// STEP 5: Start all reviews via API (faster)
console.log('\n=== 5. Start Reviews via API ===');
for (const entry of entries) {
const listRes = await fetch(`http://localhost:3002/api/projects/${projectId}/entries`, { headers: auth });
const { items } = await listRes.json();
const match = items.find(e => e.title === entry.title);
if (match && match.status === 'pending') {
await fetch(`http://localhost:3002/api/projects/${projectId}/entries/${match.id}/start`, {
method: 'POST', headers: auth,
});
console.log(` Started: ${entry.title}`);
}
}
await page.reload();
await page.waitForTimeout(3000);
await ss(page, '08-after-start.png');
// STEP 6: Force-complete for demo (since AI review needs real repos)
console.log('\n=== 6. Force Complete for Demo ===');
const res = await fetch(`http://localhost:3002/api/projects/${projectId}/entries`, {
headers: auth,
});
const { items } = await res.json();
for (const entry of items) {
await fetch(`http://localhost:3002/api/projects/${projectId}/entries/${entry.id}/force-review`, {
method: 'PUT', headers: auth,
body: JSON.stringify({
dimensions: [
{ name: '开发范式设计清晰度', score: 3 + Math.floor(Math.random() * 3), maxScore: 5 },
{ name: 'IDE集成深度', score: 3 + Math.floor(Math.random() * 3), maxScore: 5 },
{ name: '提效幅度', score: 10 + Math.floor(Math.random() * 6), maxScore: 15 },
{ name: '稳定性与易用性', score: 8 + Math.floor(Math.random() * 7), maxScore: 15 },
{ name: 'AI使用日志', score: 3 + Math.floor(Math.random() * 2), maxScore: 10 },
{ name: '演示与文档', score: 5 + Math.floor(Math.random() * 5), maxScore: 10 },
]
}),
});
}
// STEP 7: Show Summary with track-based rankings
await page.reload();
await page.waitForTimeout(500);
await page.click('.tab:has-text("汇总")');
await page.waitForTimeout(1000);
await ss(page, '09-summary-tracks.png');
// STEP 8: Show detail panel
await page.click('.tab:has-text("条目")');
await page.waitForTimeout(500);
await page.reload();
await page.waitForTimeout(1000);
const titles = await page.locator('.title-cell');
if (await titles.count() > 0) {
await titles.first().click();
await page.waitForTimeout(800);
await ss(page, '10-detail-panel.png');
}
console.log('\n=== DONE ===');
console.log(`All screenshots: ${SCREENSHOTS}`);
console.log(`App running at: http://localhost:14001 (password: ${password})`);
} catch (err) {
console.error('Error:', err.message);
await page.screenshot({ path: path.join(SCREENSHOTS, 'error.png'), fullPage: true });
}
}
main().catch(console.error);
+794
View File
@@ -0,0 +1,794 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
const BASE = 'http://localhost:3002/api';
let apiCtx: APIRequestContext;
// 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准,
// 让条目创建时 resolveStandard 回退到用户上传的标准(category_tag=''
async function apiClearStandards(request: APIRequestContext, token: string, pid: string): Promise<void> {
const list = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` } });
const standards = await list.json();
for (const s of standards) {
await request.fetch(`${BASE}/projects/${pid}/standards/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
}
// ─── Helper: login via UI, then create API context ───
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
// ─── Helper: api call via page.request ───
async function api(page: Page, method: string, path: string, body?: any) {
const res = await page.request.fetch(`http://localhost:3002${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
data: body,
});
return { status: res.status(), data: await res.json() };
}
// ══════════════════════════════════════
// 1. 认证测试
// ══════════════════════════════════════
test.describe('认证', () => {
test('1.1 未登录跳转到登录页', async ({ page }) => {
await page.goto('/');
await page.waitForURL('/login');
expect(page.url()).toContain('/login');
});
test('1.2 空密码按钮禁用', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('button[type="submit"]')).toBeDisabled();
});
test('1.3 错误密码显示错误', async ({ page }) => {
await page.goto('/login');
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
});
test('1.4 正确密码登录成功', async ({ page }) => {
await login(page);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
test('1.5 token 持久化(刷新后仍登录)', async ({ page }) => {
await login(page);
await page.reload();
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
test('1.6 退出登录', async ({ page }) => {
await login(page);
await page.click('.sidebar-header button:has-text("退出")');
await page.waitForURL('/login');
});
});
// ══════════════════════════════════════
// 2. 侧边栏 & 项目
// ══════════════════════════════════════
test.describe('侧边栏 & 项目', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('2.1 空项目列表显示"新建项目"按钮', async ({ page }) => {
await expect(page.locator('.btn-new-project')).toHaveText('+ 新建项目');
});
test('2.2 取消创建项目', async ({ page }) => {
await page.click('.btn-new-project');
await expect(page.locator('.new-project-form input')).toBeVisible();
await page.click('.new-project-actions button:has-text("取消")');
await expect(page.locator('.btn-new-project')).toBeVisible();
});
test('2.3 创建项目', async ({ page }) => {
await page.click('.btn-new-project');
await page.fill('.new-project-form input', `测试项目-${UNIQUE}`);
await page.selectOption('.new-project-form select', '赛道一');
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
await expect(page.locator('.project-view h2')).toContainText(`测试项目-${UNIQUE}`);
});
test('2.4 项目统计显示', async ({ page }) => {
await page.goto('/');
const first = page.locator('.project-stats').first();
await expect(first).toBeVisible();
});
test('2.5 项目激活高亮', async ({ page }) => {
await page.click('.project-item:first-child');
await expect(page.locator('.project-item.active')).toBeVisible();
});
test('2.6 仪表盘显示', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.dashboard h1')).toContainText('仪表盘');
});
});
// ══════════════════════════════════════
// 3. 评审标准
// ══════════════════════════════════════
test.describe('评审标准', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `标准测试-${UNIQUE}`, track: '赛道一' },
});
expect(pr.status()).toBe(200);
pid = (await pr.json()).id;
await apiClearStandards(request, token, pid);
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
});
test('3.1 空标准显示提示', async ({ page }) => {
await expect(page.locator('.empty')).toContainText('暂无评审标准');
});
test('3.2 上传标准 - 显示表单', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.3 上传标准 - 无效格式', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '无效标准');
await page.fill('.standard-form textarea', '没有格式的普通文本');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.4 上传标准 - 总分超过上限(150)被拒', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '超分标准');
await page.fill('.standard-form textarea', '## 维度一(100分)\n内容\n## 维度二(60分)\n内容');
await page.click('.form-actions button:has-text("保存")');
// 被拒绝 → 表单保持打开
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.5 上传标准 - 正常创建(含分类标签)', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '算法标准');
await page.fill('.standard-form input:nth-child(2)', '算法');
await page.fill('.standard-form textarea',
'## 代码质量(30分)\n代码整洁度\n## 架构设计(40分)\n模块化\n## 测试覆盖(30分)\n覆盖率');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-card')).toHaveCount(1);
await expect(page.locator('.standard-card')).toContainText('算法标准');
await expect(page.locator('.standard-card')).toContainText('算法');
await expect(page.locator('.standard-card')).toContainText('代码质量(30分)');
await expect(page.locator('.standard-card')).toContainText('架构设计(40分)');
});
test('3.6 标准数量显示', async ({ page }) => {
await expect(page.locator('.section-header h3')).toContainText('评审标准 (1)');
});
test('3.7 删除标准', async ({ page }) => {
page.on('dialog', d => d.accept());
await page.click('.btn-danger');
await expect(page.locator('.empty')).toContainText('暂无评审标准');
});
});
// ══════════════════════════════════════
// 4. 条目管理
// ══════════════════════════════════════
test.describe('条目管理', () => {
let pid = '';
const entryTitle = `选手A-${UNIQUE}`;
const repoUrl = `https://example.com/${UNIQUE}.git`;
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `条目测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('4.1 空条目显示提示', async ({ page }) => {
await expect(page.locator('.empty-row')).toContainText('暂无条目');
});
test('4.2 筛选器存在', async ({ page }) => {
await expect(page.locator('.filter-select')).toBeVisible();
await expect(page.locator('.search-input')).toBeVisible();
});
test('4.3 全部筛选选项', async ({ page }) => {
const opts = await page.locator('.filter-select option').allTextContents();
expect(opts).toContain('全部');
expect(opts).toContain('待评审');
expect(opts).toContain('排队中');
expect(opts).toContain('克隆中');
expect(opts).toContain('分析中');
expect(opts).toContain('已完成');
expect(opts).toContain('已修正');
expect(opts).toContain('克隆失败');
expect(opts).toContain('分析失败');
expect(opts).toContain('失败');
});
test('4.4 通过API创建条目 → 页面可见', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: entryTitle, repo_url: repoUrl, participant: '张三', difficulty: '★★★' },
});
await page.reload();
await expect(page.locator('.entry-table')).toContainText(entryTitle);
});
test('4.5 条目列表显示字段', async ({ page }) => {
await expect(page.locator('.entry-table')).toContainText('张三');
await expect(page.locator('.entry-table')).toContainText('待评审');
await expect(page.locator('.entry-table')).toContainText('-');
});
test('4.6 "启动"按钮存在', async ({ page }) => {
await expect(page.locator('.btn-action:has-text("启动")')).toBeVisible();
});
test('4.7 启动条目', async ({ page }) => {
await page.click('.entry-table .btn-action:has-text("启动")');
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
});
test('4.8 取消评审', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `取消测试-${UNIQUE}`, repo_url: `https://cancel-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
method: 'POST', headers: auth,
});
await page.reload();
await expect(page.locator('.entry-table')).toContainText('取消测试');
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
});
test('4.9 搜索功能', async ({ page }) => {
await page.fill('.search-input', '选手A');
await page.waitForTimeout(500);
await page.press('.search-input', 'Enter');
await expect(page.locator('.entry-table')).toContainText('选手A', { timeout: 10000 });
});
test('4.10 状态筛选', async ({ page }) => {
await page.selectOption('.filter-select', 'pending');
await page.waitForTimeout(500);
const badges = await page.locator('.entry-table .badge').allTextContents();
const statusTexts = badges.filter(b => !['赛道一', '新規', '修正'].includes(b.trim()));
for (const b of statusTexts) {
expect(b.trim()).toBe('待评审');
}
});
});
// ══════════════════════════════════════
// 5. 批量导入
// ══════════════════════════════════════
test.describe('批量导入', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `导入测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('5.1 打开导入面板', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
await expect(page.locator('.import-panel')).toBeVisible();
});
test('5.2 批量导入 CSV 格式', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url,participant,difficulty
小明作品,https://xiaoming-${UNIQUE}.git,小明,★★
小红作品,https://xiaohong-${UNIQUE}.git,小红,★★★`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 2 条');
});
test('5.3 导入结果显示错误行', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url
,https://empty-title.git
有效,https://valid-${UNIQUE}.git`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 1 条');
await expect(page.locator('.import-result')).toContainText('失败 1 条');
await expect(page.locator('.error-row')).toContainText('标题为空');
});
test('5.4 表格显示导入的条目', async ({ page }) => {
await page.waitForTimeout(500);
await expect(page.locator('.section-header h3')).not.toContainText('(0)');
});
});
// ══════════════════════════════════════
// 6. 详情面板
// ══════════════════════════════════════
test.describe('详情面板', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `详情测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
// Create an entry and mark it as review_done via report
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `详情条目-${UNIQUE}`, repo_url: `https://detail-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
method: 'PUT', headers: auth,
data: { dimensions: [
{ name: '代码质量', score: 40, maxScore: 50, comment: '代码整洁' },
{ name: '架构设计', score: 35, maxScore: 50, comment: '合理' },
]},
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('6.1 点击标题打开详情', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await expect(page.locator('.detail-panel h3')).toContainText(`详情条目-${UNIQUE}`);
});
test('6.2 详情面板显示元信息', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-meta')).toContainText(`https://detail-${UNIQUE}.git`);
});
test('6.3 详情面板显示维度', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-dims')).toBeVisible();
const rows = await page.locator('.detail-dims tbody tr').count();
expect(rows).toBe(2);
});
test('6.4 评分可编辑', async ({ page }) => {
await page.click('.title-cell');
const input = page.locator('.score-input').first();
await input.fill('45');
await expect(input).toHaveValue('45');
});
test('6.5 评语可编辑', async ({ page }) => {
await page.click('.title-cell');
// 评语为点击后进入编辑态(textarea.comment-input
await page.locator('.suggestion-cell').first().click();
const comment = page.locator('.comment-input').first();
await comment.fill('修改后评语');
await expect(comment).toHaveValue('修改后评语');
});
test('6.6 保存修正按钮存在', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.btn-primary:has-text("保存修正")')).toBeVisible();
});
test('6.7 关闭详情面板', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await page.click('.btn-close');
await expect(page.locator('.detail-panel')).not.toBeVisible();
});
test('6.8 点击遮罩关闭详情', async ({ page }) => {
await page.click('.title-cell');
await page.waitForTimeout(300);
await page.click('.detail-overlay', { position: { x: 10, y: 10 } });
await expect(page.locator('.detail-panel')).not.toBeVisible();
});
});
// ══════════════════════════════════════
// 7. 汇总视图
// ══════════════════════════════════════
test.describe('汇总视图', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `汇总测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '标准1', category_tag: '赛道一',
content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
for (let i = 0; i < 3; i++) {
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `选手${i}-${UNIQUE}`, repo_url: `https://p${i}-${UNIQUE}.git`,
participant: `参赛者${i}`, category_tag: '赛道一', difficulty: i === 0 ? '★★' : '★★★' },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
method: 'PUT', headers: auth,
data: { dimensions: [
{ name: '代码质量', score: 40 + i * 5, maxScore: 50, comment: 'ok' },
{ name: '架构设计', score: 30 + i * 5, maxScore: 50, comment: 'ok' },
]},
});
}
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("汇总")');
});
test('7.1 汇总页面显示标题', async ({ page }) => {
await expect(page.locator('h3')).toContainText('汇总排名');
});
test('7.2 按分类显示排名', async ({ page }) => {
await expect(page.locator('.summary-section')).toBeVisible();
await expect(page.locator('.summary-section h4')).toContainText('赛道一');
});
test('7.3 排名表格中显示选手', async ({ page }) => {
await expect(page.locator('.summary-section')).toContainText('参赛者0');
await expect(page.locator('.summary-section')).toContainText('参赛者1');
await expect(page.locator('.summary-section')).toContainText('参赛者2');
});
test('7.4 排名按分数降序', async ({ page }) => {
const scores = await page.locator('.summary-section .entry-table td:nth-child(4)').allTextContents();
const nums = scores.map(s => parseInt(s));
for (let i = 1; i < nums.length; i++) {
expect(nums[i]).toBeLessThanOrEqual(nums[i - 1]);
}
});
test('7.5 排名序号 #1 #2 #3', async ({ page }) => {
const ranks = await page.locator('.rank').allTextContents();
expect(ranks).toEqual(['#1', '#2', '#3']);
});
test('7.6 参赛者合格判定区域', async ({ page }) => {
await expect(page.locator('h4:has-text("参赛者合格判定")')).toBeVisible();
});
test('7.7 无参赛者时显示空状态', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `空汇总-${UNIQUE}`, track: '赛道一' },
});
const emptyPid = (await pr.json()).id;
await login(page);
await page.goto(`/project/${emptyPid}`);
await page.click('.tab:has-text("汇总")');
await expect(page.locator('h3')).toContainText('汇总排名');
await expect(page.locator('.summary-section')).toHaveCount(0);
});
});
// ══════════════════════════════════════
// 8. 异常与临界值测试
// ══════════════════════════════════════
test.describe('异常与临界值', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `异常测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '标准', content: '## 代码质量(100分)\n质量' },
});
});
test('8.1 导航到不存在的项目', async ({ page }) => {
await login(page);
// navigating to non-existent project should show error or fallback
await page.goto('/project/non-existent-id');
// Should show something that indicates error or at least not crash
const body = await page.locator('.project-view').textContent();
expect(body).toBeTruthy();
});
test('8.2 空标题创建条目被阻止(通过API验证)', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { repo_url: 'https://no-title.git' },
});
expect(er.status()).toBe(400);
});
test('8.3 删除标准前确认弹窗', async ({ page, request }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
// Create a standard to delete
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '待删除标准');
await page.fill('.standard-form textarea', '## 维度(100分)\n内容');
await page.click('.form-actions button:has-text("保存")');
await page.waitForTimeout(300);
let dialogSeen = false;
page.on('dialog', d => { dialogSeen = true; d.accept(); });
await page.locator('.btn-danger').first().click();
await page.waitForTimeout(300);
expect(dialogSeen).toBe(true);
});
test('8.4 导入空CSV', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.click('.btn-secondary:has-text("导入")');
await page.fill('.import-panel textarea', '');
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-panel')).toBeVisible();
});
test('8.5 同一个条目无法两次启动', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `双启动-${UNIQUE}`, repo_url: `https://dual-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
method: 'POST', headers: auth,
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.reload();
await expect(page.locator('.btn-action').first()).toBeVisible();
});
});
// ══════════════════════════════════════
// 9. UI 一致性
// ══════════════════════════════════════
test.describe('UI 一致性', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `UI测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
});
test('9.1 四个Tab都存在', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await expect(page.locator('.tab')).toHaveText(['标准', '条目', '成果物', '汇总']);
});
test('9.2 Tab切换工作', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await expect(page.locator('.tab.active')).toContainText('标准');
await page.click('.tab:has-text("条目")');
await expect(page.locator('.tab.active')).toContainText('条目');
await page.click('.tab:has-text("汇总")');
await expect(page.locator('.tab.active')).toContainText('汇总');
});
test('9.3 项目统计显示所有项', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
const meta = await page.locator('.project-meta').textContent();
expect(meta).toContain('总计');
expect(meta).toContain('✓');
expect(meta).toContain('▶');
expect(meta).toContain('✕');
});
});
// ══════════════════════════════════════
// 10. 完整用户流程
// ══════════════════════════════════════
test.describe('完整用户流程', () => {
test('完整流程:创建项目→标准→条目→查看汇总', async ({ page }) => {
await login(page);
// Step 1: Create project
await page.click('.btn-new-project');
await page.fill('.new-project-form input', `全流程-${UNIQUE}`);
await page.selectOption('.new-project-form select', '赛道一');
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
// 清掉赛道自动标准,保证下方断言 `.standard-card` 唯一(上传的「全流程标准」)
const pid10 = page.url().split('/project/')[1];
const loginR = await page.request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token: tok10 } = await loginR.json();
const listS = await page.request.get(`http://localhost:3002/api/projects/${pid10}/standards`, { headers: { Authorization: `Bearer ${tok10}` } });
const stds10 = await listS.json();
for (const s of stds10) {
await page.request.delete(`http://localhost:3002/api/projects/${pid10}/standards/${s.id}`, { headers: { Authorization: `Bearer ${tok10}` } });
}
// Step 2: Switch to standards tab, create standard
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '全流程标准');
await page.fill('.standard-form textarea',
'## 功能完整性(40分)\n功能完整\n## 代码质量(30分)\n代码整洁\n## 文档(30分)\n文档齐全');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-card')).toContainText('全流程标准');
// Step 3: Switch to entries tab, batch import
await page.click('.tab:has-text("条目")');
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url,participant,difficulty
选手1,https://p1-${UNIQUE}.git,张三,★★
选手2,https://p2-${UNIQUE}.git,李四,★★★`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 2 条');
// Step 4: Verify entries visible
await page.waitForTimeout(500);
await expect(page.locator('.section-header h3')).toContainText('(2)');
// Step 5: Switch to summary
await page.click('.tab:has-text("汇总")');
// Since entries haven't been reviewed, summary should show categories
await page.waitForTimeout(500);
const body = await page.locator('.tab-content').textContent();
expect(body).toBeTruthy();
});
});
+73
View File
@@ -0,0 +1,73 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..', '..');
const SERVER_DIR = path.join(ROOT, 'server');
const WEB_DIR = path.join(ROOT, 'web');
const PID_FILE = path.join(__dirname, '.server-pids.json');
async function waitForServer(url: string, label: string, timeoutSec = 45) {
const start = Date.now();
while (Date.now() - start < timeoutSec * 1000) {
try {
const res = await fetch(url);
console.log(`[setup] ${label} probe: status ${res.status}`);
return;
} catch {}
await new Promise(r => setTimeout(r, 1000));
}
throw new Error(`[setup] ${label} at ${url} failed to start within ${timeoutSec}s`);
}
export default async function globalSetup() {
const env = {
...process.env,
PORT: '3002',
AUTH_PASSWORD: 'test123',
ADMIN_TEST_TOKEN: 'true',
// 测试模式下 writeEnvVar 只更新进程内 env,避免改密用例污染真实 server/.env
NODE_ENV: 'test',
} as Record<string, string>;
console.log('[setup] Starting backend server...');
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], {
cwd: SERVER_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env,
shell: true,
});
server.stdout.on('data', (d: Buffer) => process.stdout.write(`[server] ${d}`));
server.stderr.on('data', (d: Buffer) => process.stderr.write(`[server-err] ${d}`));
server.on('error', (e: Error) => console.error('[setup] server spawn error:', e.message));
console.log('[setup] Starting frontend...');
const frontend = spawn('npx.cmd', ['vite', '--port', '14001', '--strictPort'], {
cwd: WEB_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env } as Record<string, string>,
shell: true,
});
frontend.stdout.on('data', (d: Buffer) => process.stdout.write(`[web] ${d}`));
frontend.stderr.on('data', (d: Buffer) => process.stderr.write(`[web-err] ${d}`));
frontend.on('error', (e: Error) => console.error('[setup] frontend spawn error:', e.message));
fs.writeFileSync(PID_FILE, JSON.stringify({
serverPid: server.pid,
frontendPid: frontend.pid,
}));
try {
await waitForServer('http://localhost:3002/api/health', 'backend');
await waitForServer('http://localhost:14001', 'frontend');
console.log('[setup] Both servers ready');
} catch (e) {
console.error('[setup] Failed:', (e as Error).message);
server.kill();
frontend.kill();
throw e;
}
}
+24
View File
@@ -0,0 +1,24 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PID_FILE = path.join(__dirname, '.server-pids.json');
export default async function globalTeardown() {
try {
if (fs.existsSync(PID_FILE)) {
const { serverPid, frontendPid } = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
console.log(`[teardown] Killing server(pid=${serverPid}) frontend(pid=${frontendPid})`);
try { process.kill(serverPid, 'SIGTERM'); } catch {}
try { process.kill(frontendPid, 'SIGTERM'); } catch {}
fs.unlinkSync(PID_FILE);
console.log('[teardown] Done');
}
} catch (e) {
console.error('[teardown] Error:', (e as Error).message);
}
}
+453
View File
@@ -0,0 +1,453 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
// ══════════════════════════════════════════════════════════════════
// 硬编码修正 E2E 测试套件
// 覆盖对象:
// 1. 模板维度可声明「文件关键词」→ DIM_FILE_FILTERS 硬编码仅兑底
// 2. 模板 content 成为评审指南第一来源(评审要点完整保留并透传)
// 3. 人才测评 L2「功能完整性」盲区修复(isBuildRelatedDim
// 4. 阈值收拢命名常量后 pass_line / 及格线行为回归
// 5. question_id 分组过滤(快照只含 common + 所选 Qn
// ══════════════════════════════════════════════════════════════════
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
let entrySeq = 0; // 保证同 describe 内多条目的 repo_url 不撞车(中文标题消毒后 slug 可能为空)
const BASE = 'http://localhost:3002/api';
// ─── Helpers ───
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const r = await request.post(`${BASE}/auth/login`, { data: { password: PASSWORD } });
const { token } = await r.json();
return token;
}
async function apiCreateProject(request: APIRequestContext, token: string, name: string, track = ''): Promise<string> {
const r = await request.fetch(`${BASE}/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name, track },
});
expect(r.status()).toBe(200);
return (await r.json()).id;
}
async function apiCreateStandard(request: APIRequestContext, token: string, pid: string, content: string, category_tag = ''): Promise<string> {
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `标准-${UNIQUE}`, content, category_tag, max_score: 150 },
});
expect(r.status()).toBe(200);
return (await r.json()).id;
}
// 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准,
// 让条目创建时 resolveStandard 回退到用户上传的标准(category_tag=''
async function apiClearStandards(request: APIRequestContext, token: string, pid: string): Promise<void> {
const list = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` } });
const standards = await list.json();
for (const s of standards) {
await request.fetch(`${BASE}/projects/${pid}/standards/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
}
async function apiCreateEntry(request: APIRequestContext, token: string, pid: string, title: string, extra: any = {}): Promise<any> {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title, repo_url: `https://e2e-${UNIQUE}-${entrySeq++}-${title.replace(/[^\w]/g, '')}.git`, ...extra },
});
expect(r.status()).toBe(200);
return r.json();
}
// ══════════════════════════════════════
// 1. 项目创建与赛道自动标准
// ══════════════════════════════════════
test.describe('项目创建与赛道自动标准', () => {
test('1.1 缺赛道被拒绝(赛道必选)', async ({ request }) => {
const token = await apiLogin(request);
const r = await request.fetch(`${BASE}/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `无赛道-${UNIQUE}` },
});
expect(r.status()).toBe(400);
expect((await r.json()).error).toContain('赛道');
});
test('1.2 赛道二自动生成 8 维度标准', async ({ request }) => {
const token = await apiLogin(request);
const pid = await apiCreateProject(request, token, `赛道二-${UNIQUE}`, '赛道二');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
const standards = await r.json();
expect(standards).toHaveLength(1);
expect(standards[0].category_tag).toBe('赛道二');
expect(standards[0].dimensions).toHaveLength(8);
const names = standards[0].dimensions.map((d: any) => d.name);
expect(names).toContain('提效设计合理性');
expect(names).toContain('提效幅度');
expect(names).toContain('稳定性与易用性');
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('1.3 人才测评自动生成含「功能完整性」标准', async ({ request }) => {
const token = await apiLogin(request);
const pid = await apiCreateProject(request, token, `人才-${UNIQUE}`, '人才测评');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
const standards = await r.json();
expect(standards).toHaveLength(1);
const dims = standards[0].dimensions;
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 40)).toBe(true);
// 共通维度合计 100(L2 及格线 = 60)
const commonTotal = dims.filter((d: any) => (d.group || 'common') === 'common').reduce((s: number, d: any) => s + d.maxScore, 0);
expect(commonTotal).toBe(100);
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
});
// ══════════════════════════════════════
// 2. 标准上传:文件关键词解析
// ══════════════════════════════════════
test.describe('标准上传:文件关键词(fileKeywords', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `关键词-${UNIQUE}`, '赛道一');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('2.1 半角冒号 `文件关键词: a,b` 解析成功,content 剥离关键词行', async ({ request }) => {
const content = [
'## 提效幅度(10分)',
'检查以下4项',
'1. 对比数据(3分)',
'2. 可验证(2分)',
'文件关键词: data,report,benchmark',
].join('\n');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '半角标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions).toHaveLength(1);
expect(std.dimensions[0].fileKeywords).toBe('data,report,benchmark');
expect(std.dimensions[0].content).not.toContain('文件关键词');
expect(std.dimensions[0].content).toContain('对比数据');
});
test('2.2 全角冒号 `文件关键词:a,b` 同样解析', async ({ request }) => {
const content = '## 架构审查(20分)\n架构分层合理性\n文件关键词:design,arch,spec';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '全角标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions[0].fileKeywords).toBe('design,arch,spec');
expect(std.dimensions[0].content).not.toContain('文件关键词');
});
test('2.3 无关键词维度 fileKeywords 为 undefined', async ({ request }) => {
const content = '## 代码质量(50分)\n代码整洁、可读性\n## 测试覆盖(50分)\n覆盖率';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '无关键词标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions[0].fileKeywords).toBeUndefined();
expect(std.dimensions[1].fileKeywords).toBeUndefined();
});
test('2.4 文件关键词不改变标准总分校验', async ({ request }) => {
const content = '## A100分)\n说明\n文件关键词: a\n## B60分)\n说明';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '超分标准', content, max_score: 150 },
});
expect(r.status()).toBe(400);
});
test('2.5 UI 上传带关键词的标准 → 维度详情显示 content 不含关键词行', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', 'UI关键词标准');
await page.fill('.standard-form textarea',
'## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report');
await page.click('.form-actions button:has-text("保存")');
const card = page.locator('.standard-card', { hasText: 'UI关键词标准' });
await expect(card).toBeVisible();
await card.locator('summary').first().click();
const detailText = await card.textContent();
expect(detailText).toContain('对比数据');
expect(detailText).not.toContain('文件关键词');
});
test('2.6 textarea placeholder 提示文件关键词语法', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
const placeholder = await page.locator('.standard-form textarea').getAttribute('placeholder');
expect(placeholder).toContain('文件关键词');
});
});
// ══════════════════════════════════════
// 3. 条目快照:fileKeywords 与 content 透传
// ══════════════════════════════════════
test.describe('条目快照透传', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `快照-${UNIQUE}`, '赛道一');
await apiClearStandards(request, token, pid);
await apiCreateStandard(request, token, pid,
'## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report,benchmark\n## 演示与文档(5分)\n文档齐全');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('3.1 条目 standard_snapshot 携带 fileKeywords 且 content 保留评审要点', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '快照条目');
const dims = JSON.parse(entry.standard_snapshot);
expect(dims).toHaveLength(2);
const dim0 = dims.find((d: any) => d.name === '提效幅度');
expect(dim0.fileKeywords).toBe('data,report,benchmark');
expect(dim0.content).toContain('对比数据');
expect(dim0.content).not.toContain('文件关键词');
const dim1 = dims.find((d: any) => d.name === '演示与文档');
expect(dim1.fileKeywords).toBeUndefined();
expect(dim1.content).toContain('文档齐全');
});
test('3.2 该维度评审时文件将被关键词过滤(评审提示含评审要点)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '快照条目2');
const dims = JSON.parse(entry.standard_snapshot);
const dim0 = dims.find((d: any) => d.name === '提效幅度');
// content 是发送给子 Agent 的评审指南来源(模板优先于硬编码)
expect(dim0.content.length).toBeGreaterThan(5);
});
});
// ══════════════════════════════════════
// 4. 人才测评:question_id 分组过滤 + 功能完整性
// ══════════════════════════════════════
test.describe('人才测评 question_id 分组', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `人才Q-${UNIQUE}`, '人才测评');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('4.1 建条目未选题目被拒绝(400', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '无题目', repo_url: `https://noq-${UNIQUE}.git` },
});
expect(r.status()).toBe(400);
});
test('4.2 Q2 条目快照包含 common 与 [Q2] 维度(完整标准,评审时按题目过滤)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q2选手', { question_id: 'Q2' });
const dims = JSON.parse(entry.standard_snapshot);
const groups = new Set(dims.map((d: any) => d.group || 'common'));
expect(groups.has('common')).toBe(true);
expect(groups.has('Q2')).toBe(true);
// 快照保留完整标准(含其他题目维度),评审时按 question_id 过滤
expect(groups.has('Q3')).toBe(true);
// 功能完整性在 common 组且是构建封顶维度
const fnDim = dims.find((d: any) => d.name === '功能完整性');
expect(fnDim).toBeDefined();
expect(fnDim.maxScore).toBe(40);
});
test('4.3 pass_line = 共通维度 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q1选手', { question_id: 'Q1' });
expect(entry.pass_line).toBe(60);
});
});
// ══════════════════════════════════════
// 5. 赛道二 pass_line 与评审展示
// ══════════════════════════════════════
test.describe('赛道二 及格线与评审展示', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `赛道二P-${UNIQUE}`, '赛道二');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('5.1 赛道二 pass_line = 总分 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '赛道二选手');
expect(entry.pass_line).toBe(60);
});
test('5.2 force-review 后详情面板显示 8 维度与总分', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '展示选手');
const dims = JSON.parse(entry.standard_snapshot);
const reviewDims = dims.map((d: any) => ({
name: d.name,
score: Math.round(d.maxScore * 0.5),
maxScore: d.maxScore,
comment: `评审 ${d.name}`,
}));
await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: reviewDims },
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await expect(page.locator('.detail-dims')).toBeVisible();
const rows = await page.locator('.detail-dims tbody tr').count();
expect(rows).toBe(8);
const detailText = await page.locator('.detail-panel').textContent();
expect(detailText).toContain('提效设计合理性');
expect(detailText).toContain('提效幅度');
});
test('5.3 汇总页显示总分与通过判定', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '汇总选手');
const dims = JSON.parse(entry.standard_snapshot);
const reviewDims = dims.map((d: any) => ({ name: d.name, score: d.maxScore, maxScore: d.maxScore, comment: '满分' }));
await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: reviewDims },
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("汇总")');
await expect(page.locator('.summary-section')).toContainText('汇总选手');
await expect(page.locator('.summary-section')).toContainText('100');
});
});
// ══════════════════════════════════════
// 6. 异常与临界值
// ══════════════════════════════════════
test.describe('异常与临界值', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `异常-${UNIQUE}`, '赛道一');
await apiCreateStandard(request, token, pid, '## 代码质量(100分)\n质量');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('6.1 缺 repo_url 创建条目被拒(400', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '无仓库' },
});
expect(r.status()).toBe(400);
});
test('6.2 非法服务地址被拒(localhost 内网)', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '非法地址', repo_url: `https://svc-${UNIQUE}.git`, service_url: 'http://localhost:3000' },
});
expect(r.status()).toBe(400);
});
test('6.3 相同 repo_url 重复创建被拒(唯一约束)', async ({ request }) => {
const dupUrl = `https://dup-${UNIQUE}.git`;
await apiCreateEntry(request, token, pid, '首建', { repo_url: dupUrl });
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '重复', repo_url: dupUrl },
});
expect(r.status()).toBe(409);
});
test('6.4 未评审条目启动后被系统处理(不崩溃)', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '启动条目');
const r = await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/start`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
expect(r.status()).toBe(200);
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await expect(page.locator('.entry-table')).toContainText('启动条目');
});
});
+137
View File
@@ -0,0 +1,137 @@
import { test, expect, type Page } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
let pid = '';
let lastNewPwd = '';
async function login(page: Page, password = PASSWORD) {
await page.goto('/login');
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
// ══════════════════════════════════════
// K7: 改密流程(httpOnly cookie + 密钥轮换 → 需重登)
// ══════════════════════════════════════
test.describe('K7 改密流程', () => {
test.afterAll(async ({ request }) => {
// 崩溃安全:若改密测试中途失败,确保服务器密码还原,避免级联影响其他 spec
const ok = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
if (ok.ok()) return;
if (!lastNewPwd) return;
const loginNew = await request.post('http://localhost:3002/api/auth/login', { data: { password: lastNewPwd } });
if (!loginNew.ok()) return;
const { token } = await loginNew.json();
await request.post('http://localhost:3002/api/auth/password', {
headers: { Authorization: `Bearer ${token}` },
data: { currentPassword: lastNewPwd, newPassword: PASSWORD },
});
});
test('改密:当前密码错误时显示错误且不跳转', async ({ page }) => {
await login(page);
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', 'wrong-current');
await page.fill('input[placeholder="新密码(至少 6 位)"]', 'new-pass-123');
await page.click('.new-project-actions button:has-text("保存")');
await expect(page.locator('.new-project-form')).toContainText('当前密码错误');
await expect(page).toHaveURL('/');
});
test('改密成功后旧密码失效、新密码可登录,并还原原密码', async ({ page }) => {
const NEW_PWD = `new-${UNIQUE}-x9`;
lastNewPwd = NEW_PWD;
await login(page, PASSWORD);
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', PASSWORD);
await page.fill('input[placeholder="新密码(至少 6 位)"]', NEW_PWD);
await page.click('.new-project-actions button:has-text("保存")');
await page.waitForURL('/login');
// 旧密码登录失败
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
// 新密码登录成功
await login(page, NEW_PWD);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
// 还原原密码
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', NEW_PWD);
await page.fill('input[placeholder="新密码(至少 6 位)"]', PASSWORD);
await page.click('.new-project-actions button:has-text("保存")');
await page.waitForURL('/login');
await login(page, PASSWORD);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
});
// ══════════════════════════════════════
// D2: CSV 导入模板下载
// ══════════════════════════════════════
test.describe('D2 CSV 模板下载', () => {
test.beforeAll(async ({ request }) => {
const loginRes = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` },
data: { name: `csv-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
});
test('批量导入面板可下载 CSV 模板', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('button:has-text("批量导入")');
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("下载模板")');
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('entry-import-template.csv');
});
});
// ══════════════════════════════════════
// K4: 条目编辑保存补齐 sub_type/question_id
// ══════════════════════════════════════
test.describe('K4 编辑保存字段', () => {
let pid = '';
let eid = '';
test.beforeAll(async ({ request }) => {
const loginRes = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `k4-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'k4-entry', repo_url: `file://C:\\k4-${UNIQUE}` },
});
eid = (await entry.json()).id;
});
test('编辑保存 sub_type 持久化', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('button:has-text("编辑")');
await page.selectOption('.detail-overlay select', { label: '新規开发' });
await page.click('.detail-overlay button:has-text("保存")');
const loginRes = await page.request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const detailRes = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/${eid}`, {
headers: { Authorization: `Bearer ${token}` },
});
const detail = await detailRes.json();
expect(detail.sub_type).toBe('新規');
});
});
+229
View File
@@ -0,0 +1,229 @@
import { test, expect, type Page } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
async function apiToken(request: any): Promise<string> {
const res = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await res.json();
return token;
}
// ══════════════════════════════════════
// 1. 成果物 TabDeliverablesView
// ══════════════════════════════════════
test.describe('成果物 Tab', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `deliv-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'd-std', content: '## 场景价值(8分)' },
});
await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'deliv-entry', repo_url: `file://C:\\deliv-${UNIQUE}` },
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('初始化一覧 → 勾选 → 提交率更新 → CSV 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.getByRole('button', { name: '成果物' }).click();
await expect(page.getByText('成果物确认')).toBeVisible();
// 初始化一覧
await page.getByRole('button', { name: '初始化一覧' }).click();
await expect(page.locator('.entry-table input[type="checkbox"]').first()).toBeVisible();
// 勾选第一个成果物 → 提交率 +1(经 API 验证)
const before = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/deliverables/summary`, {
headers: { Authorization: `Bearer ${token}` },
});
const beforeData = await before.json();
await page.locator('.entry-table input[type="checkbox"]').first().check();
await expect.poll(async () => {
const r = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/deliverables/summary`, {
headers: { Authorization: `Bearer ${token}` },
});
return (await r.json()).totalSubmitted;
}).toBe(beforeData.totalSubmitted + 1);
// CSV 下载
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('deliverables');
});
});
// ══════════════════════════════════════
// 2. 评审进度时间线(DetailPanelD7
// ══════════════════════════════════════
test.describe('评审进度时间线', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `prog-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'p-std', content: '## 场景价值(8分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'prog-entry', repo_url: `file://C:\\prog-${UNIQUE}` },
});
eid = (await entry.json()).id;
// 启动评审(外部路径 → 秒级 clone_fail),写入 progress_log
await request.post(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
headers: { Authorization: `Bearer ${token}` },
});
await expect.poll(async () => {
const r = await request.get(`http://localhost:3002/api/projects/${pid}/entries/${eid}`, {
headers: { Authorization: `Bearer ${token}` },
});
return (await r.json()).status;
}).toBe('clone_fail');
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('详情面板显示评审进度时间线(含克隆步骤)', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
const progress = page.locator('details', { has: page.getByText('评审进度') });
await expect(progress).toBeVisible();
await expect(progress).toContainText('不允许克隆外部路径');
});
});
// ══════════════════════════════════════
// 3. PDF 下载(单条目报告 / 汇总报告)
// ══════════════════════════════════════
test.describe('PDF 下载', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `pdf-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'pdf-std', content: '## 架构设计(10分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'pdf-entry', repo_url: `file://C:\\pdf-${UNIQUE}` },
});
eid = (await entry.json()).id;
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: [{ name: '架构设计', score: 8, maxScore: 10 }] },
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('单条目报告 PDF 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载报告' }).click();
const download = await downloadPromise;
// 服务端 Content-Disposition 中文文件名在部分平台会被搅乱,这里只断言"触发了 .pdf 下载"
expect(download.suggestedFilename().toLowerCase()).toContain('.pdf');
});
test('汇总报告 PDF 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.getByRole('button', { name: '汇总' }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载汇总PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename().toLowerCase()).toContain('.pdf');
});
});
// ══════════════════════════════════════
// 4. 人才测评 L2/L3 分表展示
// ══════════════════════════════════════
test.describe('人才测评 L2/L3 分表', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2l3-${UNIQUE}`, track: '人才测评' },
});
pid = (await proj.json()).id;
// 删除自动标准,替换为自定义 Q2 标准
const list = await request.get(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
for (const s of await list.json()) {
await request.delete(`http://localhost:3002/api/projects/${pid}/standards/${s.id}`, { headers: { Authorization: `Bearer ${token}` } });
}
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
data: { name: 'l2-std', category_tag: '人才测评', content: '## 功能完整性(40分)\n## 设计文档(10分)\n## [Q2] LLM生成问卷(15分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'l2l3-entry', repo_url: `file://C:\\l2-${UNIQUE}`, question_id: 'Q2' },
});
eid = (await entry.json()).id;
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
dimensions: [
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
],
},
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('详情面板分别展示 L2共通 与 L3追加 分表', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
await expect(page.getByText('L2共通评分')).toBeVisible();
await expect(page.getByText('L3追加评分')).toBeVisible();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
// ⚠️ 本用例会锁定服务端 127.0.0.1 的登录 60 秒,必须作为 e2e 套件最后一个文件运行(zzz- 前缀)。
// 若与其它 spec 并行执行会级联影响登录;当前套件共享单一服务器、依赖串行(与既有改密用例一致)。
test.describe('登录限流 UI(§7.1', () => {
test('连续 5 次密码错误后第 6 次提示「登录尝试过多」', async ({ page }) => {
await page.goto('/login');
for (let i = 0; i < 5; i++) {
await page.fill('input[type="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
}
await page.fill('input[type="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('登录尝试过多');
});
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './e2e',
timeout: 90000,
retries: 0,
globalSetup: path.join(__dirname, 'e2e/global-setup.ts'),
globalTeardown: path.join(__dirname, 'e2e/global-teardown.ts'),
use: {
baseURL: 'http://localhost:14001',
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from './components/LoginPage';
import Dashboard from './components/Dashboard';
import Layout from './components/Layout';
import ProjectView from './components/ProjectView';
import { api } from './services/api';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const [ok, setOk] = useState<boolean | null>(null);
useEffect(() => {
api.getMe().then(() => setOk(true)).catch(() => setOk(false));
}, []);
if (ok === null) return <div className="loading">...</div>;
if (!ok) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="project/:id" element={<ProjectView />} />
</Route>
</Routes>
</BrowserRouter>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
export default function Dashboard() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
api.listProjects().then(data => {
setProjects(data);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
const totalEntries = projects.reduce((s, p) => s + (p.total || 0), 0);
const totalReviewed = projects.reduce((s, p) => s + (p.reviewed || 0), 0);
// Per-track stats
const byTrack: Record<string, { projects: number; entries: number; reviewed: number; failed: number }> = {};
for (const p of projects) {
const t = p.track || '未分类';
if (!byTrack[t]) byTrack[t] = { projects: 0, entries: 0, reviewed: 0, failed: 0 };
byTrack[t].projects++;
byTrack[t].entries += p.total || 0;
byTrack[t].reviewed += p.reviewed || 0;
byTrack[t].failed += p.failed || 0;
}
if (loading) return <div className="loading">...</div>;
return (
<div className="dashboard">
<h1></h1>
<p className="dashboard-subtitle">AI </p>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon" style={{ background: '#eef2ff', color: '#4f46e5' }}>📋</div>
<div className="stat-value">{projects.length}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-icon" style={{ background: '#f0fdf4', color: '#10b981' }}>📝</div>
<div className="stat-value">{totalEntries}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-icon" style={{ background: '#fef2f2', color: '#ef4444' }}></div>
<div className="stat-value">{totalReviewed}</div>
<div className="stat-label"></div>
</div>
</div>
{/* Per-track breakdown */}
{Object.entries(byTrack).map(([track, stats]) => (
<div key={track} className="dashboard-card" style={{ marginBottom: 16 }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="badge badge-blue" style={{ fontSize: 12 }}>{track}</span>
<span style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-secondary)' }}>{stats.projects} / {stats.entries} / {stats.reviewed}</span>
</h3>
{stats.entries > 0 ? (
<div className="activity-list">
{projects.filter(p => (p.track || '未分类') === track).slice(0, 4).map(p => (
<div key={p.id} className="activity-item" style={{ cursor: 'pointer' }} onClick={() => navigate(`/project/${p.id}`)}>
<div className="activity-dot" style={{ background: track === '赛道一' ? '#4f46e5' : track === '赛道二' ? '#06b6d4' : '#8b5cf6' }} />
<span className="activity-text">{p.name}</span>
<span className="activity-time">{p.reviewed}/{p.total} </span>
</div>
))}
</div>
) : <div className="empty" style={{ padding: 20 }}></div>}
</div>
))}
<div className="dashboard-grid">
<div className="dashboard-card">
<h3></h3>
<div className="quick-actions">
<button className="quick-action-btn" onClick={() => {
const btn = document.querySelector('.btn-new-project') as HTMLElement;
if (btn) btn.click();
}}>
<span className="qa-icon">📋</span>
<span></span>
</button>
<button className="quick-action-btn" onClick={() => {
if (projects.length > 0) navigate(`/project/${projects[0].id}`);
}}>
<span className="qa-icon">📊</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
export default function Layout() {
return (
<div className="layout">
<Sidebar />
<main className="main-content">
<Outlet />
</main>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../services/api';
export default function LoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.login(password);
navigate('/');
} catch (err: any) {
setError(err.message || '登录失败');
}
setLoading(false);
};
return (
<div className="login-page">
<form onSubmit={handleSubmit} className="login-form">
<div className="login-icon"></div>
<h1>AI-Review</h1>
<p className="login-desc">AI人才评测管理系统</p>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="请输入管理密码" autoFocus disabled={loading}
/>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading || !password}>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
);
}
+1270
View File
@@ -0,0 +1,1270 @@
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../services/api';
async function downloadPdf(url: string, options?: { silent?: boolean }) {
try {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) {
let msg = '下载失败';
try { msg = (await res.json()).error || msg; } catch { /* 非 JSON 响应 */ }
alert(msg);
return false;
}
const blob = await res.blob();
// 优先 filename*RFC 5987);其次 filename(剥离引号);最后兜底
const disposition = res.headers.get('Content-Disposition') || '';
let filename = '';
const star = disposition.match(/filename\*=(?:UTF-8'')?([^;\s]+)/i);
if (star) {
try { filename = decodeURIComponent(star[1]); } catch { filename = star[1]; }
} else {
const plain = disposition.match(/filename="?([^";\s]+)"?/i);
filename = plain ? plain[1] : '';
}
if (!filename) filename = url.includes('summary') ? '汇总报告.pdf' : url.includes('deliverables') ? '成果物清单.csv' : '报告.pdf';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
if (!options?.silent) alert(`已开始下载:${filename}(保存到浏览器下载目录)`);
return true;
} catch (e: any) {
alert('下载失败: ' + (e?.message || e));
return false;
}
}
type Tab = 'standards' | 'entries' | 'deliverables' | 'summary';
export default function ProjectView() {
const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null);
const [projectError, setProjectError] = useState('');
const [tab, setTab] = useState<Tab>('entries');
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
useEffect(() => {
if (id) {
setProjectError('');
let cancelled = false;
api.getProject(id).then(p => { if (!cancelled) setProject(p); }).catch((err: any) => {
if (!cancelled) {
setProjectError(err.message || '项目不存在');
setProject(null);
}
});
return () => { cancelled = true; };
}
}, [id]);
if (projectError) return <div className="project-view"><div className="empty">{projectError}</div></div>;
if (!project) return <div className="loading">...</div>;
return (
<div className="project-view">
<div className="project-header">
<div className="flex justify-between items-center">
<div className="flex items-center gap-10">
{editingName ? (
<input value={newName} onChange={e => setNewName(e.target.value)} autoFocus
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', border: '2px solid var(--primary)', borderRadius: 8, outline: 'none', width: 400 }}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) {
try {
await api.request('PUT', `/projects/${id}`, { name: newName.trim() });
setProject({ ...project, name: newName.trim() });
setEditingName(false);
} catch (err: any) { alert(err.message || '重命名失败'); }
}
if (e.key === 'Escape') { setNewName(project.name); setEditingName(false); }
}}
onBlur={async () => {
if (newName.trim() && newName.trim() !== project.name) {
try {
await api.request('PUT', `/projects/${id}`, { name: newName.trim() });
setProject({ ...project, name: newName.trim() });
} catch (err: any) { alert(err.message || '重命名失败'); }
}
setEditingName(false);
}}
/>
) : (
<h2 style={{ cursor: 'pointer' }} onClick={() => { setNewName(project.name); setEditingName(true); }} title="点击重命名">{project.name} </h2>
)}
{project.track && <span className="badge" style={{ background: '#6366f1', marginLeft: 8, fontSize: 12 }}>{project.track}</span>}
</div>
<button className="btn-danger-outline" onClick={async () => { if (confirm(`删除项目"${project.name}"?所有关联数据将丢失`)) { try { await api.request('DELETE', `/projects/${id}?force=true`); window.location.href = '/'; } catch (err: any) { alert(err.message); } } }}></button>
</div>
<div className="project-meta">
<span> {project.total}</span>
<span> {project.reviewed || 0}</span>
<span> {project.active || 0}</span>
<span> {project.failed || 0}</span>
</div>
</div>
<div className="tabs">
<button className={`tab ${tab === 'standards' ? 'active' : ''}`} onClick={() => setTab('standards')}></button>
<button className={`tab ${tab === 'entries' ? 'active' : ''}`} onClick={() => setTab('entries')}></button>
<button className={`tab ${tab === 'deliverables' ? 'active' : ''}`} onClick={() => setTab('deliverables')}></button>
<button className={`tab ${tab === 'summary' ? 'active' : ''}`} onClick={() => setTab('summary')}></button>
</div>
<div className="tab-content">
{tab === 'standards' && <StandardsManager projectId={id!} />}
{tab === 'entries' && <EntryManager projectId={id!} track={project.track} />}
{tab === 'deliverables' && <DeliverablesView projectId={id!} />}
{tab === 'summary' && <SummaryView projectId={id!} />}
</div>
</div>
);
}
function StandardsManager({ projectId }: { projectId: string }) {
const [standards, setStandards] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [content, setContent] = useState('');
const [catTag, setCatTag] = useState('');
const [maxScore, setMaxScore] = useState('150');
const load = () => api.listStandards(projectId).then(setStandards).catch(() => {});
useEffect(() => { load(); }, [projectId]);
const create = async () => {
if (!name.trim() || !content.trim()) return;
try {
await api.createStandard(projectId, { name: name.trim(), content, category_tag: catTag, max_score: parseInt(maxScore) || 150 });
setName(''); setContent(''); setCatTag(''); setMaxScore('150'); setShowForm(false); await load();
} catch (err: any) { alert(err.message || '保存失败,请检查服务端'); }
};
const remove = async (sid: string) => {
if (!confirm('确定删除?')) return;
try { await api.deleteStandard(projectId, sid); await load(); } catch (err: any) { alert(err.message || '删除失败'); }
};
return (
<div>
<div className="section-header"><h3> ({standards.length})</h3><button onClick={() => setShowForm(true)}>+ </button></div>
{showForm && (
<div className="standard-form">
<input value={name} onChange={e => setName(e.target.value)} placeholder="标准名称" />
<input value={catTag} onChange={e => setCatTag(e.target.value)} placeholder="分类标签(留空为默认标准)" />
<input value={maxScore} onChange={e => setMaxScore(e.target.value)} placeholder="总分上限" type="number" min={1} style={{ width: 120, padding: '10px 14px', border: '2px solid var(--border)', borderRadius: 8, fontSize: 14, outline: 'none' }} />
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder={'## 维度名(分值)\n评审要点\n可选:文件关键词: data,report,benchmark(限定该维度只看这些文件,留空则按系统规则)'} rows={8} />
<div className="form-actions">
<button onClick={create}></button>
<button onClick={() => setShowForm(false)}></button>
</div>
</div>
)}
<div className="standard-list">
{standards.length === 0 && <div className="empty"></div>}
{standards.map(s => (
<div key={s.id} className="standard-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<strong style={{ fontSize: 15 }}>{s.name}</strong>
{s.category_tag && <span className="tag">{s.category_tag}</span>}
<span className="tag" style={{ background: '#e0e7ff', color: '#4338ca' }}>{s.max_score || 150}</span>
</div>
<button className="btn-danger" onClick={() => remove(s.id)}></button>
</div>
<details style={{ fontSize: 13 }}>
<summary style={{ cursor: 'pointer', color: 'var(--primary)', fontWeight: 500, marginBottom: 4 }}>
{s.dimensions?.length || 0}
</summary>
{s.dimensions?.map((d: any, i: number) => (
<div key={i} style={{ padding: '10px 12px', margin: '6px 0', background: 'var(--bg-subtle)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{d.name}{d.maxScore}</div>
{d.group && d.group !== 'common' && <span className="tag" style={{ background: '#f3e8ff', color: '#7c3aed', marginBottom: 4, display: 'inline-block' }}>{d.group}</span>}
<div style={{ whiteSpace: 'pre-wrap', color: 'var(--text-secondary)', lineHeight: 1.6, fontSize: 12 }}>{d.content || '无详细说明'}</div>
</div>
))}
</details>
</div>
))}
</div>
</div>
);
}
function EntryManager({ projectId, track }: { projectId: string; track?: string }) {
const [entries, setEntries] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<any>(null);
const [showImport, setShowImport] = useState(false);
const [csvText, setCsvText] = useState('');
const [importResult, setImportResult] = useState<any>(null);
const [editEntry, setEditEntry] = useState<any>(null);
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
const [offset, setOffset] = useState(0);
const [questionFilter, setQuestionFilter] = useState('');
const PAGE_LIMIT = 50;
const latestSearchRef = useRef('');
const doSearch = (term: string, pageOffset = offset, qFilter = questionFilter) => {
latestSearchRef.current = term;
const params: any = { limit: PAGE_LIMIT, offset: pageOffset };
if (status) params.status = status;
if (term) params.search = term;
if (qFilter) params.question_id = qFilter;
api.listEntries(projectId, params).then(r => {
if (latestSearchRef.current === term) {
setEntries(r.items);
setTotal(r.total);
}
}).catch(() => {});
};
const load = () => doSearch(search, offset, questionFilter);
const goPage = (newOffset: number) => {
setOffset(newOffset);
setTimeout(() => doSearch(search, newOffset, questionFilter), 0);
};
useEffect(() => { setOffset(0); doSearch(search, 0, questionFilter); }, [projectId, status, search, questionFilter]);
const toggleSelect = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
const doAction = async (action: string, entryId: string) => {
try {
if (action === 'start') await api.request('POST', `/projects/${projectId}/entries/${entryId}/start`);
else if (action === 'cancel') await api.request('POST', `/projects/${projectId}/entries/${entryId}/cancel`);
else if (action === 'retry') await api.request('POST', `/projects/${projectId}/entries/${entryId}/retry`);
await load();
} catch (err: any) { alert(err.message || '操作失败'); }
};
// §2.2 人工构建确认:a_done 提供「确认构建完成 / 构建失败」两按钮,结果传给 /verify
const doVerify = async (entryId: string, buildStatus: 'done' | 'failed') => {
try {
await api.request('POST', `/projects/${projectId}/entries/${entryId}/verify`, { build_status: buildStatus });
await load();
} catch (err: any) { alert(err.message || '启动系统验证失败'); }
};
const doDelete = async (entryId: string, title: string) => {
if (!confirm(`删除条目"${title}"`)) return;
try { await api.request('DELETE', `/projects/${projectId}/entries/${entryId}`); await load(); } catch (err: any) { alert(err.message || '删除失败'); }
};
const doEditSave = async () => {
if (!editEntry) return;
try {
await api.request('PUT', `/projects/${projectId}/entries/${editEntry.id}`, {
title: editEntry.title,
repo_url: editEntry.repo_url,
participant: editEntry.participant,
branch: editEntry.branch,
sub_type: editEntry.sub_type,
question_id: editEntry.question_id,
service_url: editEntry.service_url,
build_status: editEntry.build_status,
});
setEditEntry(null);
await load();
} catch (err: any) { alert(err.message || '保存失败'); }
};
const openEdit = (e: any) => setEditEntry({
id: e.id, title: e.title, repo_url: e.repo_url, participant: e.participant || '',
sub_type: e.sub_type || '', question_id: e.question_id || '', branch: e.branch || '',
service_url: e.service_url || '', build_status: e.build_status || '',
});
const doAdd = async () => {
if (!addForm.title.trim() || !addForm.repo_url.trim()) return;
try {
await api.request('POST', `/projects/${projectId}/entries`, addForm);
setAddForm({ title: '', repo_url: '', participant: '', sub_type: '', branch: '', question_id: '' });
setShowAdd(false);
await load();
} catch (err: any) { alert(err.message || '添加失败'); }
};
const batchStart = async () => {
const ids = Array.from(selected);
if (ids.length === 0) return;
try { await api.request('POST', `/projects/${projectId}/entries/batch-start`, { entryIds: ids }); await load(); } catch (err: any) { alert(err.message || '启动失败'); }
};
const doImport = async () => {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return;
const headers = lines[0].split(',').map(h => h.trim());
const items = lines.slice(1).map(line => {
const vals = line.split(',').map(v => v.trim());
const item: any = {};
headers.forEach((h, i) => { if (vals[i]) item[h] = vals[i]; });
return item;
});
try {
const r = await api.batchImport(projectId, items);
setImportResult(r);
setCsvText('');
if (r.imported > 0) await load();
} catch (err: any) { alert(err.message || '导入失败'); }
};
const downloadTemplate = () => {
const header = ['title', 'repo_url', 'participant', 'branch', 'service_url', 'base_branch'];
if (track === '赛道一') header.push('sub_type');
if (track === '人才测评') header.push('question_id');
const example = header.map(h =>
h === 'repo_url' ? 'https://github.com/user/repo' :
h === 'question_id' ? 'Q1' :
h === 'sub_type' ? '新規' : ''
).join(',');
const blob = new Blob(['\uFEFF' + header.join(',') + '\n' + example], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'entry-import-template.csv';
a.click();
URL.revokeObjectURL(a.href);
};
const openDetail = async (eid: string) => {
try { const d = await api.getEntry(projectId, eid); setDetail(d); } catch (err: any) { alert(err.message || '加载详情失败'); }
};
const statusBadge = (s: string) => {
const map: Record<string, string> = {
pending: 'badge-gray', queued: 'badge-amber', cloning: 'badge-blue', analyzing: 'badge-blue',
a_done: 'badge-purple', verifying: 'badge-blue',
review_done: 'badge-green', admin_reviewed: 'badge-green',
clone_fail: 'badge-red', analysis_fail: 'badge-red', failed: 'badge-red', cancelled: 'badge-gray',
};
const label: Record<string, string> = {
pending: '待评审', queued: '排队中', cloning: '克隆中', analyzing: '分析中',
a_done: 'A阶段完成', verifying: '系统验证中',
review_done: '已完成', admin_reviewed: '已修正',
clone_fail: '克隆失败', analysis_fail: '分析失败', failed: '失败', cancelled: '已取消',
};
const cls = map[s] || 'badge-gray';
return <span className={`badge ${cls}`}>{label[s] || s}</span>;
};
const scoreColor = (_s: string, score: number | null | undefined) => {
if (score === null || score === undefined) return 'score-empty';
if (score >= 60) return 'score-ok';
if (score >= 40) return 'score-warn';
return 'score-low';
};
return (
<div>
<div className="section-header">
<h3> ({total})</h3>
<div style={{ display: 'flex', gap: 8 }}>
<select value={status} onChange={e => { setStatus(e.target.value); setOffset(0); }} className="filter-select">
<option value=""></option>
<option value="pending"></option>
<option value="queued"></option>
<option value="cloning"></option>
<option value="analyzing"></option>
<option value="a_done">A阶段完成</option>
<option value="verifying"></option>
<option value="review_done"></option>
<option value="admin_reviewed"></option>
<option value="clone_fail"></option>
<option value="analysis_fail"></option>
<option value="failed"></option>
</select>
{track === '人才测评' && (
<select value={questionFilter} onChange={e => { setQuestionFilter(e.target.value); setOffset(0); }} className="filter-select">
<option value=""></option>
<option value="Q1">Q1 </option>
<option value="Q2">Q2 </option>
<option value="Q3">Q3 RAG检索</option>
<option value="Q4">Q4 </option>
<option value="Q5">Q5 </option>
<option value="Q6">Q6 </option>
</select>
)}
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索标题..." className="search-input" onKeyDown={e => e.key === 'Enter' && doSearch(e.currentTarget.value)} />
<button onClick={() => setShowAdd(true)} className="btn-primary">+ </button>
<button onClick={batchStart} disabled={selected.size === 0} className="btn-primary"> ({selected.size})</button>
<button onClick={() => setShowImport(!showImport)} className="btn-secondary"></button>
</div>
</div>
{showAdd && (
<div className="import-panel" style={{ animation: 'slideDown 0.2s ease-out' }}>
<div className="flex-col gap-10">
<input value={addForm.title} onChange={e => setAddForm({ ...addForm, title: e.target.value })} placeholder="条目标题 *" className="input-field" autoFocus />
<input value={addForm.repo_url} onChange={e => setAddForm({ ...addForm, repo_url: e.target.value })} placeholder="Git仓库URL * (https://github.com/...)" className="input-field" />
<input value={addForm.branch} onChange={e => setAddForm({ ...addForm, branch: e.target.value })} placeholder="分支 (可选,默认 main)" className="input-field" />
<div className="flex gap-8">
<input value={addForm.participant} onChange={e => setAddForm({ ...addForm, participant: e.target.value })} placeholder="参赛者" className="input-field flex-1" />
{track ? (
<span className="badge badge-blue" style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, whiteSpace: 'nowrap' }}>{track}</span>
) : (
<span style={{ padding: '10px 14px', borderRadius: 8, fontSize: 13, background: '#f3f4f6', color: '#9ca3af', whiteSpace: 'nowrap' }}></span>
)}
</div>
{track === '赛道一' && (
<select value={addForm.sub_type} onChange={e => setAddForm({ ...addForm, sub_type: e.target.value })} className="select-field">
<option value=""></option>
<option value="新規"></option>
<option value="修正">/</option>
</select>
)}
{track === '人才测评' && (
<>
<select value={addForm.question_id} onChange={e => setAddForm({ ...addForm, question_id: e.target.value })} className="select-field">
<option value=""> *</option>
<option value="Q1">Q1 L2のみ</option>
<option value="Q2">Q2 L3可</option>
<option value="Q3">Q3 RAG检索 L3可</option>
<option value="Q4">Q4 L3可</option>
<option value="Q5">Q5 L3可</option>
<option value="Q6">Q6 L3可</option>
</select>
{addForm.question_id && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
{addForm.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
</div>
)}
</>
)}
<div className="form-actions">
<button onClick={doAdd} disabled={!addForm.title.trim() || !addForm.repo_url.trim()}></button>
<button onClick={() => setShowAdd(false)}></button>
</div>
</div>
</div>
)}
{showImport && (
<div className="import-panel">
<textarea value={csvText} onChange={e => setCsvText(e.target.value)} placeholder={'首行为列头(UTF-8 编码,推荐带 BOM):\ntitle,repo_url,participant,branch,service_url\n张三月结,https://gitea/zhang-01,张三,main,'} rows={5} />
<div className="form-actions">
<button onClick={doImport}></button>
<button onClick={downloadTemplate} className="btn-secondary"></button>
<button onClick={() => setShowImport(false)}></button>
</div>
{importResult && (
<div className="import-result">
{importResult.imported} {importResult.errors?.length > 0 ? `,失败 ${importResult.errors.length}` : ''}
{importResult.errors?.slice(0, 5).map((e: any, i: number) => <div key={i} className="error-row">{e.row}{e.reason}</div>)}
</div>
)}
</div>
)}
<table className="entry-table">
<thead>
<tr>
<th><input type="checkbox" onChange={e => { if (e.target.checked) setSelected(new Set(entries.map(x => x.id))); else setSelected(new Set()); }} checked={selected.size === entries.length && entries.length > 0} /></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={7} className="empty-row"></td></tr>}
{entries.map(e => (
<tr key={e.id} className={e.id === detail?.id ? 'active-row' : ''}>
<td><input type="checkbox" checked={selected.has(e.id)} onChange={() => toggleSelect(e.id)} /></td>
<td className="title-cell" onClick={() => openDetail(e.id)}>{e.title}</td>
<td>{e.participant}</td>
<td>
<span className="badge badge-blue">{e.category_tag}</span>
{e.sub_type && <span className="badge badge-purple ml-8">{e.sub_type}</span>}
{e.question_id && <span className="badge badge-gray ml-8">{e.question_id}</span>}
{e.final_level && (
<span className={`badge ml-8 ${e.final_level === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? 'badge-green' : 'badge-red'}`}>
{e.final_level}
</span>
)}
</td>
<td>{statusBadge(e.status)}</td>
<td className={`score-cell ${scoreColor(e.status, e.final_score ?? e.raw_score ?? (e.status === 'a_done' ? e.score_a : null))}`}>
{e.status === 'a_done'
? (e.score_a != null ? `A:${e.score_a}` : 'A:-')
: (e.aggregate_count > 0
? `${e.aggregate_score}${e.is_formal ? `(聚合${e.aggregate_count}次)` : `(初评${e.aggregate_count}次)`}`
: (e.final_score ?? e.raw_score ?? '-'))}
</td>
<td className="action-cell">
{e.status === 'pending' && <button className="btn-action" onClick={() => doAction('start', e.id)}></button>}
{e.status === 'pending' && <button className="btn-action" onClick={() => openEdit(e)}></button>}
{e.status === 'pending' && <button className="btn-action" style={{ borderColor: '#ef4444', color: '#ef4444' }} onClick={() => doDelete(e.id, e.title)}></button>}
{['queued', 'cloning', 'analyzing'].includes(e.status) && <button className="btn-action warn" onClick={() => doAction('cancel', e.id)}></button>}
{e.status === 'a_done' && <button className="btn-action" onClick={() => openEdit(e)}></button>}
{e.status === 'a_done' && <button className="btn-action" style={{ borderColor: '#10b981', color: '#10b981' }} onClick={() => doVerify(e.id, 'done')}></button>}
{e.status === 'a_done' && <button className="btn-action" style={{ borderColor: '#ef4444', color: '#ef4444' }} onClick={() => doVerify(e.id, 'failed')}></button>}
{e.status === 'a_done' && <button className="btn-action" onClick={() => openDetail(e.id)}></button>}
{e.status === 'verifying' && <button className="btn-action warn" onClick={() => doAction('cancel', e.id)}></button>}
{['clone_fail', 'analysis_fail', 'failed'].includes(e.status) && <button className="btn-action" onClick={() => doAction('retry', e.id)}></button>}
{['review_done', 'admin_reviewed'].includes(e.status) && <button className="btn-action" onClick={() => openDetail(e.id)}></button>}
{['review_done', 'admin_reviewed'].includes(e.status) && <button className="btn-action" onClick={() => doAction('start', e.id)}></button>}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_LIMIT && (
<div className="pagination" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 16 }}>
<button disabled={offset === 0} onClick={() => goPage(offset - PAGE_LIMIT)} className="btn-action" style={{ padding: '6px 14px' }}> </button>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{Math.floor(offset / PAGE_LIMIT) + 1} / {Math.ceil(total / PAGE_LIMIT)} {total}
</span>
<button disabled={offset + PAGE_LIMIT >= total} onClick={() => goPage(offset + PAGE_LIMIT)} className="btn-action" style={{ padding: '6px 14px' }}> </button>
</div>
)}
{detail && <DetailPanel entry={detail} projectId={projectId} onClose={() => setDetail(null)} onSave={() => { load(); openDetail(detail.id); }} />}
{editEntry && (
<div className="detail-overlay" onClick={() => setEditEntry(null)}>
<div className="detail-panel" onClick={e => e.stopPropagation()} style={{ width: 480 }}>
<div className="detail-header">
<h3></h3>
<button className="btn-close" onClick={() => setEditEntry(null)}>×</button>
</div>
<div className="flex-col gap-12">
<input value={editEntry.title} onChange={e => setEditEntry({ ...editEntry, title: e.target.value })} placeholder="标题" className="input-field" />
<input value={editEntry.repo_url} onChange={e => setEditEntry({ ...editEntry, repo_url: e.target.value })} placeholder="仓库URL" className="input-field" />
<input value={editEntry.branch} onChange={e => setEditEntry({ ...editEntry, branch: e.target.value })} placeholder="分支" className="input-field" />
<input value={editEntry.service_url || ''} onChange={e => setEditEntry({ ...editEntry, service_url: e.target.value })} placeholder="服务地址(B阶段验证用,如 http://8.8.8.8:8080/app" className="input-field" />
<select value={editEntry.build_status || ''} onChange={e => setEditEntry({ ...editEntry, build_status: e.target.value })} className="select-field">
<option value=""></option>
<option value="done"></option>
<option value="failed"></option>
</select>
<div className="flex gap-8">
<input value={editEntry.participant} onChange={e => setEditEntry({ ...editEntry, participant: e.target.value })} placeholder="参赛者" className="input-field flex-1" />
{track && <span className="badge badge-blue" style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, whiteSpace: 'nowrap' }}>{track}</span>}
</div>
{track === '赛道一' && (
<select value={editEntry.sub_type || ''} onChange={e => setEditEntry({ ...editEntry, sub_type: e.target.value })} className="select-field">
<option value=""></option>
<option value="新規"></option>
<option value="修正">/</option>
</select>
)}
{track === '人才测评' && (
<>
<select value={editEntry.question_id || ''} onChange={e => setEditEntry({ ...editEntry, question_id: e.target.value })} className="select-field">
<option value=""> *</option>
<option value="Q1">Q1 L2のみ</option>
<option value="Q2">Q2 L3可</option>
<option value="Q3">Q3 RAG检索 L3可</option>
<option value="Q4">Q4 L3可</option>
<option value="Q5">Q5 L3可</option>
<option value="Q6">Q6 L3可</option>
</select>
{editEntry.question_id && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', padding: '4px 2px' }}>
{editEntry.question_id === 'Q1' ? '仅L2评审(共通100分)' : 'L2共通100分 + L3追加评审'}
</div>
)}
</>
)}
<div className="form-actions">
<button onClick={doEditSave}></button>
<button onClick={() => setEditEntry(null)}></button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
function DetailPanel({ entry, projectId, onClose, onSave }: { entry: any; projectId: string; onClose: () => void; onSave: () => void }) {
const [dims, setDims] = useState<any[]>([]);
const [dimsAgg, setDimsAgg] = useState<any>(null);
const [overview, setOverview] = useState('');
const [overall, setOverall] = useState<any>(null);
const [saving, setSaving] = useState(false);
const [editingComment, setEditingComment] = useState<number | null>(null);
const [deliverables, setDeliverables] = useState<any[]>(() => {
try { return JSON.parse(entry.deliverables || '[]'); } catch { return []; }
});
useEffect(() => {
if (entry.ai_report) {
try {
const r = JSON.parse(entry.ai_report);
setDims(r.dimensions || []);
setOverview(r.overview || '');
setOverall(r.overall || null);
} catch { setDims(entry.dimensions || []); }
} else {
setDims(entry.dimensions || []);
}
setDimsAgg((entry as any).dimsAgg || null);
try { const d = JSON.parse(entry.deliverables || '[]'); if (d.length > 0) setDeliverables(d); } catch {}
}, [entry]);
const totalScore = dims.reduce((s: number, d: any) => s + (Number(d.score) || 0), 0);
const maxTotal = dims.reduce((s: number, d: any) => s + (d.maxScore || 0), 0);
const pct = maxTotal > 0 ? Math.round((totalScore / maxTotal) * 100) : 0;
// Old ai_report dimensions lack `group` → derive from standard_snapshot dims by name
const stdGroupMap: Record<string, string> = {};
for (const d of (entry.dimensions || [])) stdGroupMap[d.name] = d.group || 'common';
const dimGroup = (d: any) => d.group || stdGroupMap[d.name] || 'common';
const l2Dims = dims.filter((d: any) => dimGroup(d) === 'common');
const l3Dims = dims.filter((d: any) => dimGroup(d) !== 'common');
const l2Score = l2Dims.reduce((s, d) => s + (Number(d.score) || 0), 0);
const l2Max = l2Dims.reduce((s, d) => s + (d.maxScore || 0), 0);
const l3Score = l3Dims.reduce((s, d) => s + (Number(d.score) || 0), 0);
const l3Max = l3Dims.reduce((s, d) => s + (d.maxScore || 0), 0);
// 维度级聚合历史(2026-08-19):dimsAgg.perRun 含各次维度分
const dimHistory = (name: string): string => {
if (!dimsAgg?.perRun?.length) return '';
const scores = dimsAgg.perRun
.map((r: any) => r.dims?.find((d: any) => d.name === name)?.score)
.filter((s: any) => s !== undefined && s !== null);
return scores.length > 1 ? `(历次 ${scores.join(' / ')}` : '';
};
const finalLevel = entry.final_level || '';
const updateDim = (i: number, field: string, value: any) => {
const next = [...dims];
(next[i] as any)[field] = value;
setDims(next);
};
const saveDeliverables = async () => {
try {
await api.request('PUT', `/projects/${projectId}/entries/${entry.id}/deliverables`, { deliverables });
} catch {}
};
const toggleDeliverable = (idx: number) => {
const next = [...deliverables];
next[idx] = { ...next[idx], submitted: !next[idx].submitted };
setDeliverables(next);
saveDeliverables();
};
const saveReport = async () => {
setSaving(true);
try {
await api.request('PUT', `/projects/${projectId}/entries/${entry.id}/report`, { dimensions: dims });
onSave();
} catch (err: any) { alert(err.message); }
setSaving(false);
};
return (
<div className="detail-overlay" onClick={onClose}>
<div className="detail-panel" onClick={e => e.stopPropagation()}>
<div className="detail-header">
<h3>{entry.title}</h3>
<button className="btn-close" onClick={onClose}></button>
</div>
<div className="detail-meta">
<div><code>{entry.repo_url}</code></div>
<div>{entry.participant || '-'} | 线{entry.pass_line || '-'}{entry.question_id ? ` | 选题:${entry.question_id}` : ''}</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
{finalLevel && (
<span className={`badge ${finalLevel === 'L3' ? 'badge-purple' : finalLevel === 'L2' ? 'badge-green' : 'badge-red'}`}>
{finalLevel === 'L3' ? '🏆 L3合格' : finalLevel === 'L2' ? '✅ L2合格' : '❌ 不合格'}
</span>
)}
<span style={{ fontSize: 15, fontWeight: 600, color: finalLevel === 'L3' ? '#8b5cf6' : pct >= (entry.pass_line || 60) ? '#10b981' : '#ef4444' }}>
{l3Max > 0 ? `L2: ${l2Score}/${l2Max} | L3: ${l3Score}/${l3Max} | 总分: ${totalScore}/${maxTotal}${pct}%` : `得分:${totalScore}/${maxTotal}${pct}%`}
</span>
{entry.late_days > 0 && <span style={{ color: '#f59e0b' }}> {entry.late_days} </span>}
</div>
{entry.score_a != null && (entry.score_b != null || entry.status === 'a_done' || entry.status === 'verifying') && (
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
A阶段 {entry.score_a}
{entry.score_b != null && ` · B阶段 ${entry.score_b}`}
{entry.stage_b_status && entry.stage_b_status !== 'pending' && entry.stage_b_status !== 'done' && (
<span style={{ color: entry.stage_b_status === 'failed' ? '#ef4444' : '#f59e0b', marginLeft: 8 }}>
· B阶段状态{entry.stage_b_status === 'failed' ? '验证失败' : entry.stage_b_status === 'skipped' ? '已跳过' : entry.stage_b_status}
</span>
)}
</div>
)}
{entry.attempt > 1 && <div className="retake-badge"> {entry.attempt} </div>}
</div>
{(overview || overall) && (
<div className="overview-section" style={{ paddingLeft: 12 }}>
<h4 style={{ marginBottom: 8 }}></h4>
{overview && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--primary, #6aa1f7)', fontWeight: 600, marginBottom: 4 }}></div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4, lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{overview}</div>
</div>
)}
{overall && Array.isArray(overall.highlights) && overall.highlights.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--success, #2e7d32)', fontWeight: 600, marginBottom: 4 }}></div>
{overall.highlights.map((h: any, i: number) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{h.point}</span>
{h.review ? <span style={{ opacity: 0.85 }}> {h.review}</span> : null}
</div>
))}
</div>
)}
{overall && Array.isArray(overall.weaknesses) && overall.weaknesses.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--danger, #c62828)', fontWeight: 600, marginBottom: 4 }}></div>
{overall.weaknesses.map((w: any, i: number) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', marginLeft: 12, marginBottom: 4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{w.point}</span>
{w.review ? <span style={{ opacity: 0.85 }}> {w.review}</span> : null}
</div>
))}
</div>
)}
{overall && overall.verdict && <p style={{ lineHeight: 1.7, color: 'var(--text-secondary)', fontSize: 14, whiteSpace: 'pre-wrap' }}>{overall.verdict}</p>}
</div>
)}
{entry.progress_log && (() => {
try {
const logs = JSON.parse(entry.progress_log);
if (!Array.isArray(logs) || logs.length === 0) return null;
return (
<details className="history-section" style={{ marginBottom: 16 }}>
<summary>{logs.length} </summary>
{logs.map((l: any, i: number) => (
<div key={i} className="history-item">
<div style={{ fontSize: 12, color: '#666' }}>
{l.time ? new Date(l.time).toLocaleString() : ''}{l.status ? ` · ${l.status}` : ''}
</div>
{l.msg && <div style={{ fontSize: 12 }}>{l.msg}</div>}
</div>
))}
</details>
);
} catch { return null; }
})()}
{/* deliverables checklist */}
<div style={{ marginBottom: 16, padding: 16, background: 'var(--bg-subtle)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}></h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(deliverables.length > 0 ? deliverables : DEFAULT_DELIVERABLES).map((d: any, i: number) => (
<label key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={d.submitted} onChange={() => toggleDeliverable(i)} />
<span style={{ textDecoration: d.submitted ? 'line-through' : 'none', color: d.submitted ? 'var(--success)' : 'var(--text)' }}>
{d.name}
</span>
{d.required && <span style={{ color: 'var(--danger)', fontSize: 11 }}></span>}
{!d.required && <span style={{ color: 'var(--muted)', fontSize: 11 }}></span>}
</label>
))}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
{deliverables.filter((d: any) => d.submitted).length}/{deliverables.length || DEFAULT_DELIVERABLES.length}
{deliverables.filter((d: any) => d.required && !d.submitted).length > 0 && (
<span style={{ color: 'var(--danger)', marginLeft: 8 }}>
{deliverables.filter((d: any) => d.required && !d.submitted).length}
</span>
)}
</div>
</div>
{dims.length > 0 && <RadarChart dims={dims} size={360} />}
{l2Dims.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--text)' }}>L2共通评分{l2Score}/{l2Max}</h4>
<div style={{ overflowX: 'auto' }}>
<table className="detail-dims">
<thead><tr><th></th><th style={{ textAlign: 'center' }}></th><th></th><th></th></tr></thead>
<tbody>
{l2Dims.map((d, i) => {
const idx = dims.indexOf(d);
return (
<tr key={i}>
<td>
<div className="dim-name">{d.name}</div>
{dimHistory(d.name) && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.75 }}>{dimHistory(d.name)}</div>}
<DimBar score={Number(d.score) || 0} max={d.maxScore} />
</td>
<td style={{ textAlign: 'center', verticalAlign: 'middle' }}>
<input type="number" value={Number(d.score) || 0} min={0} max={d.maxScore} onChange={e => updateDim(idx, 'score', e.target.value === '' ? 0 : Number(e.target.value))} className="score-input" />
<span className="score-max"> {d.maxScore}</span>
</td>
<td>
{editingComment === idx ? (
<textarea value={d.comment || ''} onChange={e => updateDim(idx, 'comment', e.target.value)} onBlur={() => setEditingComment(null)} autoFocus rows={3} className="comment-input" placeholder="评语" />
) : (
<span className={`dim-comment-btn ${d.comment ? '' : 'is-empty'}`} onClick={() => setEditingComment(idx)} title="点击编辑评语">{d.comment || '点击填写评语'}</span>
)}
{d.verifiability?.note && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.8, marginTop: 4 }}>{d.verifiability.note}</div>}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{l3Dims.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#8b5cf6' }}>L3追加评分{l3Score}/{l3Max}</h4>
<div style={{ overflowX: 'auto' }}>
<table className="detail-dims">
<thead><tr><th></th><th style={{ textAlign: 'center' }}></th><th></th><th></th></tr></thead>
<tbody>
{l3Dims.map((d, i) => {
const idx = dims.indexOf(d);
return (
<tr key={i}>
<td>
<div className="dim-name" style={{ color: '#8b5cf6' }}>{d.name}</div>
{dimHistory(d.name) && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.75 }}>{dimHistory(d.name)}</div>}
<DimBar score={Number(d.score) || 0} max={d.maxScore} />
</td>
<td style={{ textAlign: 'center', verticalAlign: 'middle' }}>
<input type="number" value={Number(d.score) || 0} min={0} max={d.maxScore} onChange={e => updateDim(idx, 'score', e.target.value === '' ? 0 : Number(e.target.value))} className="score-input" />
<span className="score-max"> {d.maxScore}</span>
</td>
<td>
{editingComment === idx ? (
<textarea value={d.comment || ''} onChange={e => updateDim(idx, 'comment', e.target.value)} onBlur={() => setEditingComment(null)} autoFocus rows={3} className="comment-input" placeholder="评语" />
) : (
<span className={`dim-comment-btn ${d.comment ? '' : 'is-empty'}`} onClick={() => setEditingComment(idx)} title="点击编辑评语">{d.comment || '点击填写评语'}</span>
)}
{d.verifiability?.note && <div style={{ fontSize: 11, color: 'var(--text-secondary)', opacity: 0.8, marginTop: 4 }}>{d.verifiability.note}</div>}
</td>
<td><div className="suggestion-cell">{d.suggestion || '-'}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{entry.snapshots?.length > 0 && (
<details className="history-section">
<summary>{entry.snapshots.length} </summary>
{entry.snapshots.map((s: any) => (
<div key={s.id} className="history-item">
{s.attempt} · {new Date(s.created_at).toLocaleString()}
</div>
))}
</details>
)}
{entry.revisions?.length > 0 && (
<details className="history-section">
<summary>{entry.revisions.length} </summary>
{entry.revisions.map((r: any) => {
let oldScores = '', newScores = '';
try { oldScores = JSON.parse(r.comments || '[]').map((x: any) => `${x.name}:${x.score}`).join('; '); } catch {}
try { newScores = JSON.parse(r.scores || '[]').map((x: any) => `${x.name}:${x.score}`).join('; '); } catch {}
return (
<div key={r.id} className="history-item">
<div style={{ fontSize: 12, color: '#666' }}>{new Date(r.created_at).toLocaleString()}</div>
{oldScores && <div style={{ fontSize: 12 }}>{oldScores}</div>}
{newScores && <div style={{ fontSize: 12 }}>{newScores}</div>}
</div>
);
})}
</details>
)}
<div className="detail-actions">
<button onClick={() => downloadPdf(`/api/projects/${projectId}/entries/${entry.id}/report/export`).catch(() => {})} className="btn-secondary" title="下载PDF报告"></button>
<button onClick={saveReport} disabled={saving} className="btn-primary">{saving ? '保存中...' : '保存修正'}</button>
</div>
</div>
</div>
);
}
function DimBar({ score, max }: { score: number; max: number }) {
const pct = max > 0 ? Math.max(0, Math.min(100, (Number(score) / max) * 100)) : 0;
const color = pct >= 80 ? '#10b981' : pct >= 60 ? '#6366f1' : pct >= 40 ? '#f59e0b' : '#ef4444';
return (
<div className="dim-bar-row">
<div className="dim-bar">
<div className="dim-bar-fill" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="dim-pct">{Math.round(pct)}%</span>
</div>
);
}
function RadarChart({ dims, size = 260 }: { dims: any[]; size?: number }) {
const n = dims.length;
if (n === 0) return null;
const cx = size / 2, cy = size / 2, r = size * 0.38;
const labelR = r + size * 0.07;
const fontSize = size <= 280 ? 10 : 11;
const angle = (i: number) => (2 * Math.PI * i) / n - Math.PI / 2;
const pt = (i: number, radius: number) => {
const a = angle(i);
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
};
const scorePts = dims.map((d, i) => {
const pct = Math.max(0, Math.min(1, d.maxScore > 0 ? Number(d.score) / d.maxScore : 0));
const p = pt(i, r * pct);
return `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`;
}).join(' ') + ' Z';
return (
<div style={{ textAlign: 'center', margin: '16px 0' }}>
<h4 style={{ marginBottom: 8, color: '#666', fontSize: 13 }}></h4>
<svg width={size + 20} height={size + 20} viewBox={`0 0 ${size} ${size}`} style={{ maxWidth: '100%' }}>
<g>
{[25, 50, 75, 100].map(pct => {
const rr = r * pct / 100;
const pts = Array.from({ length: n }, (_, i) => pt(i, rr)).map(p => `${p.x},${p.y}`).join(' ');
return <g key={pct}>
<polygon points={pts} fill="none" stroke="#e5e7eb" strokeWidth="1" strokeDasharray="3,3" />
<text x={cx} y={cy - rr} fontSize="9" fill="#999" textAnchor="middle" dominantBaseline="middle">{pct}%</text>
</g>;
})}
{Array.from({ length: n }, (_, i) => {
const p = pt(i, r);
return <line key={`ax-${i}`} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#e5e7eb" strokeWidth="1" />;
})}
{dims.map((d, i) => {
const pct = Math.max(0, Math.min(1, d.maxScore > 0 ? Number(d.score) / d.maxScore : 0));
const p = pt(i, r * pct);
return <circle key={`dot-${i}`} cx={p.x} cy={p.y} r="3" fill="#4f46e5" />;
})}
{dims.map((d, i) => {
const p = pt(i, labelR);
const anchor = p.x > cx + 5 ? 'start' : p.x < cx - 5 ? 'end' : 'middle';
return <text key={`lb-${i}`} x={p.x} y={p.y} fontSize={fontSize} fill="#333" textAnchor={anchor} dominantBaseline="middle">{d.name}</text>;
})}
</g>
<path d={scorePts} fill="rgba(79,70,229,0.15)" stroke="#4f46e5" strokeWidth="2" />
</svg>
</div>
);
}
function BarChart({ entries, title }: { entries: any[]; title?: string }) {
if (entries.length === 0) return null;
const barH = 28, gap = 8, labelW = 160, chartW = 400;
const h = entries.length * (barH + gap) + 30;
const maxScore = Math.max(...entries.map(e => e.score || 0));
const bars = entries.map((e, i) => {
const y = 30 + i * (barH + gap);
const w = maxScore > 0 ? ((e.score || 0) / maxScore) * chartW : 0;
const color = e.passed ? '#10b981' : '#ef4444';
return (
<g key={e.id}>
<text x={labelW - 6} y={y + barH / 2} fontSize="11" fill="#333" textAnchor="end" dominantBaseline="middle">{e.title}</text>
<rect x={labelW} y={y} width={Math.max(w, 2)} height={barH} rx={4} fill={color} opacity={0.85} />
<text x={labelW + w + 4} y={y + barH / 2} fontSize="11" fill={color} dominantBaseline="middle">{e.score}</text>
</g>
);
});
return (
<div style={{ textAlign: 'center', margin: '20px 0', overflowX: 'auto' }}>
{title && <h4 style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>{title}</h4>}
<svg width={Math.min(labelW + chartW + 60, 800)} height={h} viewBox={`0 0 ${Math.min(labelW + chartW + 60, 800)} ${h}`} style={{ maxWidth: '100%' }}>
<text x={labelW} y="16" fontSize="12" fill="#999"></text>
{bars}
</svg>
</div>
);
}
const DEFAULT_DELIVERABLES = [
{ name: '源代码', required: true, submitted: false },
{ name: 'README', required: true, submitted: false },
{ name: '设计文档', required: true, submitted: false },
{ name: '测试用例与测试结果', required: true, submitted: false },
{ name: 'AGENTS.md', required: true, submitted: false },
{ name: '样本数据', required: true, submitted: false },
{ name: '演示录屏', required: false, submitted: false },
];
function DeliverablesView({ projectId }: { projectId: string }) {
const [entries, setEntries] = useState<any[]>([]);
const [deliverableMap, setDeliverableMap] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(false);
const loadAll = async () => {
setLoading(true);
try {
const r = await api.request<{ items: any[] }>('GET', `/projects/${projectId}/entries?limit=250`);
const items = r.items || [];
setEntries(items);
const map: Record<string, any[]> = {};
for (const e of items) {
let d: any[] = [];
try { d = JSON.parse(e.deliverables || '[]'); } catch {}
if (d.length === 0) {
d = DEFAULT_DELIVERABLES.map(x => ({ ...x, submitted: false }));
}
map[e.id] = d;
}
setDeliverableMap(map);
} catch {}
setLoading(false);
};
useEffect(() => { loadAll(); }, [projectId]);
// Initialize all entries with default deliverables
const initAll = async () => {
try {
const r = await fetch(`/api/projects/${projectId}/entries/deliverables/init`, {
method: 'PUT',
credentials: 'include'
});
const result = await r.json();
console.log('Initialized:', result.initialized);
} catch {}
await loadAll();
};
// Toggle a single deliverable
const toggle = async (entryId: string, idx: number) => {
const d = [...(deliverableMap[entryId] || [])];
d[idx] = { ...d[idx], submitted: !d[idx].submitted };
setDeliverableMap({ ...deliverableMap, [entryId]: d });
try {
await fetch(`/api/projects/${projectId}/entries/${entryId}/deliverables`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ deliverables: d })
});
} catch {}
};
// Calculate summary
const colNames = DEFAULT_DELIVERABLES.map(d => d.name);
const summary = DEFAULT_DELIVERABLES.map(d => {
let submitted = 0, total = 0;
for (const eid of Object.keys(deliverableMap)) {
const items = deliverableMap[eid] || [];
const found = items.find((x: any) => x.name === d.name);
if (found) { total++; if (found.submitted) submitted++; }
}
return { ...d, submitted, total };
});
const totalRequired = summary.filter(s => s.required).reduce((s, x) => s + x.total, 0);
const totalSubmitted = summary.filter(s => s.required).reduce((s, x) => s + x.submitted, 0);
const rate = totalRequired > 0 ? Math.round((totalSubmitted / totalRequired) * 100) : 0;
if (loading && entries.length === 0) return <div className="loading">...</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<h3 style={{ margin: 0 }}></h3>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
{entries.length} · {rate}%{totalSubmitted}/{totalRequired}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={initAll} className="btn-secondary"></button>
<button onClick={() => downloadPdf(`/api/projects/${projectId}/entries/deliverables/export`).catch(() => {})} className="btn-secondary">CSV</button>
</div>
</div>
{/* Summary cards */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
{summary.map((s: any) => (
<div key={s.name} style={{ padding: '8px 14px', background: 'var(--card)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ fontWeight: 600, fontSize: 12 }}>{s.name}</div>
<div style={{ color: s.submitted === s.total ? 'var(--success)' : 'var(--danger)', fontSize: 15, fontWeight: 700 }}>
{s.submitted}/{s.total}
</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{s.required ? '必须' : '可选'}</div>
</div>
))}
</div>
{/* Checklist table */}
<div className="deliverables-scroll" style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 430px)' }}>
<table className="entry-table" style={{ minWidth: 1000 }}>
<thead>
<tr>
<th style={{ position: 'sticky', left: 0, top: 0, background: 'var(--bg-subtle)', zIndex: 2, width: 130 }}></th>
<th style={{ position: 'sticky', left: 130, top: 0, background: 'var(--bg-subtle)', zIndex: 2, width: 200 }}></th>
{colNames.map((name, ci) => (
<th key={ci} style={{ position: 'sticky', top: 0, fontSize: 11, textAlign: 'center', minWidth: 80 }}>
{name.replace('与测试结果', '')}
<div style={{ fontSize: 10, color: 'var(--muted)', fontWeight: 400 }}>
{summary[ci]?.submitted}/{summary[ci]?.total}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e) => {
const d = deliverableMap[e.id] || [];
const hasDetected = d.some((x: any) => x.submitted);
return (
<tr key={e.id} style={{ opacity: d.length === 0 ? 0.5 : 1 }}>
<td style={{ position: 'sticky', left: 0, background: 'var(--card)', zIndex: 1, fontWeight: 500, width: 130 }}>
{e.participant || e.title.substring(0, 8)}
</td>
<td style={{ position: 'sticky', left: 130, background: 'var(--card)', zIndex: 1, fontSize: 12, width: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{e.title}
{hasDetected && (
<span style={{ marginLeft: 6, fontSize: 10, color: 'var(--success)', border: '1px solid var(--success)', borderRadius: 4, padding: '0 4px', flexShrink: 0 }}>
</span>
)}
</td>
{colNames.map((name, ci) => {
const item = d.find((x: any) => x.name === name);
const checked = item?.submitted || false;
const isReq = DEFAULT_DELIVERABLES[ci]?.required;
return (
<td key={ci} style={{ textAlign: 'center' }}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const idx = d.findIndex((x: any) => x.name === name);
if (idx >= 0) toggle(e.id, idx);
}}
style={{ cursor: 'pointer', width: 16, height: 16, accentColor: isReq ? 'var(--success)' : undefined }}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{entries.length === 0 && (
<div className="empty" style={{ padding: '60px 40px' }}>
<div style={{ fontSize: 32, marginBottom: 16 }}>📦</div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}></div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto', lineHeight: 1.7 }}>
<br />
</div>
</div>
)}
{entries.length > 0 && totalRequired === 0 && (
<div className="empty" style={{ padding: '40px 40px', marginTop: 16 }}>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 12 }}>
</div>
<button onClick={initAll} className="btn-primary" style={{ padding: '8px 24px' }}></button>
</div>
)}
</div>
);
}
function SummaryView({ projectId }: { projectId: string }) {
const [summary, setSummary] = useState<any>(null);
useEffect(() => { api.request('GET', `/projects/${projectId}/summary`).then(setSummary).catch(() => {}); }, [projectId]);
const isTalent = summary?.categories?.some((c: any) => /^Q\d$/.test(c.category));
if (!summary) return <div className="loading">...</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>{isTalent ? '(人才测评)' : ''}</h3>
<button onClick={() => downloadPdf(`/api/projects/${projectId}/summary/export`).catch(() => {})} className="btn-secondary">PDF</button>
</div>
{/* Category groups */}
{summary.categories?.map((cat: any) => {
const isQ = /^Q\d$/.test(cat.category);
const showLevel = isQ || cat.category === '人才测评';
return (
<div key={cat.category} className="summary-section">
<h4>
{isQ ? (
<><span className="badge badge-blue" style={{ marginRight: 6 }}>{cat.category}</span> {cat.entries.length}</>
) : (
cat.category
)}
</h4>
<table className="entry-table">
<thead><tr><th></th><th></th><th></th><th></th><th>线</th><th>{showLevel ? '认定' : '结果'}</th></tr></thead>
<tbody>
{cat.entries.map((e: any) => (
<tr key={e.id}>
<td className="rank">#{e.rank}</td>
<td>{e.title}</td>
<td>{e.participant}</td>
<td style={{ fontWeight: 600 }}>{e.score}{e.aggregate_count > 0 ? (e.is_formal ? `(聚合${e.aggregate_count}次)` : `(初评${e.aggregate_count}次)`) : ''}</td>
<td>{e.pass_line}</td>
<td>
{showLevel && e.final_level ? (
<span className={`badge ${e.final_level === 'L3' ? 'badge-purple' : e.final_level === 'L2' ? 'badge-green' : 'badge-red'}`}>
{e.final_level}
</span>
) : (
e.passed ? <span className="pass"> </span> : <span className="fail"> 线</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{cat.entries.length > 1 && <BarChart entries={cat.entries} />}
</div>
);
})}
{/* Participant summary */}
{summary.participants?.length > 0 && (
<>
<h4 style={{ marginTop: 24 }}></h4>
<table className="entry-table">
<thead><tr><th></th><th></th><th></th><th>线</th><th></th></tr></thead>
<tbody>
{summary.participants.map((p: any) => (
<tr key={p.participant}>
<td><strong>{p.participant}</strong></td>
<td>{p.entries.map((e: any) => e.title).join(', ')}</td>
<td>{p.entries.map((e: any) => e.score).join(' / ')}</td>
<td>{p.entries.map((e: any) => e.pass_line).join(' / ')}</td>
<td>{p.passed ? <span className="pass"> </span> : <span className="fail"> </span>}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../services/api';
export default function Sidebar() {
const [projects, setProjects] = useState<any[]>([]);
const [showNew, setShowNew] = useState(false);
const [name, setName] = useState('');
const [track, setTrack] = useState('');
const navigate = useNavigate();
const params = useParams();
const activeId = params.id;
const [showPwd, setShowPwd] = useState(false);
const [curPwd, setCurPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [pwdMsg, setPwdMsg] = useState('');
const load = () => api.listProjects().then(setProjects);
useEffect(() => { load(); }, []);
const [error, setError] = useState('');
const create = async () => {
if (!name.trim()) { setError('请输入项目名称'); return; }
if (!track) { setError('请选择赛道'); return; }
setError('');
try {
const p = await api.createProject({ name: name.trim(), track });
setName('');
setTrack('');
setShowNew(false);
await load();
navigate(`/project/${p.id}`);
} catch (err: any) {
setError(err.message || '创建失败,请检查服务端是否运行');
}
};
const changePwd = async () => {
setPwdMsg('');
try {
await api.changePassword(curPwd, newPwd);
navigate('/login');
} catch (err: any) {
setPwdMsg(err.message || '修改失败');
}
};
const logout = async () => {
try { await api.logout(); } catch {}
navigate('/login');
};
return (
<aside className="sidebar">
<div className="sidebar-header">
<h2 style={{ cursor: 'pointer' }} onClick={() => navigate('/')} title="返回首页">AI-Review</h2>
<div className="flex items-center gap-8">
<button className="btn-logout" title="修改管理密码" onClick={() => setShowPwd(!showPwd)}></button>
<button className="btn-logout" onClick={logout}>退</button>
</div>
</div>
{showPwd && (
<div className="new-project-form">
<input type="password" value={curPwd} onChange={e => setCurPwd(e.target.value)} placeholder="当前密码" autoFocus />
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="新密码(至少 6 位)" onKeyDown={e => e.key === 'Enter' && changePwd()} />
{pwdMsg && <div className="text-sm" style={{ color: pwdMsg === '密码已更新' ? 'var(--success)' : 'var(--danger)', marginTop: 4 }}>{pwdMsg}</div>}
<div className="new-project-actions">
<button onClick={changePwd} disabled={!curPwd || !newPwd}></button>
<button onClick={() => { setShowPwd(false); setCurPwd(''); setNewPwd(''); setPwdMsg(''); }}></button>
</div>
</div>
)}
<div className="project-list">
{projects.map(p => (
<div
key={p.id}
className={`project-item ${p.id === activeId ? 'active' : ''}`}
onClick={() => navigate(`/project/${p.id}`)}
onMouseEnter={e => { const d = e.currentTarget.querySelector('.project-del') as HTMLElement; if (d) d.style.display = 'block'; }}
onMouseLeave={e => { const d = e.currentTarget.querySelector('.project-del') as HTMLElement; if (d) d.style.display = 'none'; }}
>
<span className="project-name">{p.name}</span>
<div className="flex items-center gap-8">
{p.track && <span className="track-tag">{p.track}</span>}
<span className="project-stats">{p.reviewed}/{p.total} </span>
<button className="project-del" title="删除项目" onClick={async e => { e.stopPropagation(); if (confirm(`删除项目"${p.name}"?所有关联标准和条目将被删除`)) { try { await api.request('DELETE', `/projects/${p.id}?force=true`); load(); if (activeId === p.id) navigate('/'); } catch (err: any) { alert(err.message); } } }}>×</button>
</div>
</div>
))}
</div>
{showNew ? (
<div className="new-project-form">
<input value={name} onChange={e => setName(e.target.value)} placeholder="项目名称" autoFocus onKeyDown={e => e.key === 'Enter' && create()} />
<select value={track} onChange={e => setTrack(e.target.value)} className="select-field">
<option value=""></option>
<option value="赛道一">Agent开发实战</option>
<option value="赛道二">IDE+</option>
<option value="人才测评"></option>
</select>
{error && <div className="text-sm" style={{ color: 'var(--danger)', marginTop: 4 }}>{error}</div>}
<div className="new-project-actions">
<button onClick={create}></button>
<button onClick={() => setShowNew(false)}></button>
</div>
</div>
) : (
<button className="btn-new-project" onClick={() => setShowNew(true)}>+ </button>
)}
</aside>
);
}
@@ -0,0 +1,39 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import Dashboard from '../Dashboard';
import { api } from '../../services/api';
import { useNavigate } from 'react-router-dom';
vi.mock('../../services/api', () => ({ api: { listProjects: vi.fn() } }));
vi.mock('react-router-dom', () => ({ useNavigate: vi.fn() }));
describe('Dashboard', () => {
const navigate = vi.fn();
const mockList = api.listProjects as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
(useNavigate as ReturnType<typeof vi.fn>).mockReturnValue(navigate);
});
it('渲染统计卡:项目/条目/已完成 数字正确', async () => {
mockList.mockResolvedValue([
{ id: '1', name: 'A', total: 5, reviewed: 3, failed: 1 },
{ id: '2', name: 'B', total: 2, reviewed: 2, failed: 0 },
]);
render(<Dashboard />);
await waitFor(() => expect(screen.getByText('2')).toBeTruthy()); // 项目总数
expect(screen.getByText('项目总数')).toBeTruthy();
expect(screen.getByText('7')).toBeTruthy(); // 评审条目 5+2
expect(screen.getByText('评审条目')).toBeTruthy();
expect(screen.getByText('5')).toBeTruthy(); // 已完成 3+2
expect(screen.getByText('已完成评审')).toBeTruthy();
});
it('空数据时统计为 0', async () => {
mockList.mockResolvedValue([]);
render(<Dashboard />);
await waitFor(() => expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(3)); // 项目/条目/已完成
});
});
@@ -0,0 +1,82 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import LoginPage from '../LoginPage';
import { api } from '../../services/api';
import { useNavigate } from 'react-router-dom';
vi.mock('../../services/api', () => ({ api: { login: vi.fn() } }));
vi.mock('react-router-dom', () => ({ useNavigate: vi.fn() }));
describe('LoginPage', () => {
const navigate = vi.fn();
const mockLogin = api.login as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockLogin.mockResolvedValue({ token: 'test-token' });
(useNavigate as ReturnType<typeof vi.fn>).mockReturnValue(navigate);
localStorage.clear();
});
it('空密码时登录按钮禁用', () => {
render(<LoginPage />);
expect(screen.getByRole('button', { name: '登录' })).toBeDisabled();
});
it('输入密码后按钮可用', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText('请输入管理密码'), 'secret');
expect(screen.getByRole('button', { name: '登录' })).toBeEnabled();
});
it('登录成功跳转 /(认证走 httpOnly cookie,不再写 localStorage', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText('请输入管理密码'), 'secret');
await user.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(mockLogin).toHaveBeenCalledWith('secret'));
expect(navigate).toHaveBeenCalledWith('/');
});
it('登录中显示「登录中...」且按钮禁用', async () => {
const user = userEvent.setup();
let resolveLogin!: (v: { token: string }) => void;
mockLogin.mockImplementation(() => new Promise(r => { resolveLogin = r; }));
render(<LoginPage />);
await user.type(screen.getByPlaceholderText('请输入管理密码'), 'secret');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByRole('button', { name: '登录中...' })).toBeDisabled();
await waitFor(() => resolveLogin({ token: 't' }));
await waitFor(() => expect(navigate).toHaveBeenCalled());
});
it('登录失败显示错误信息', async () => {
const user = userEvent.setup();
mockLogin.mockRejectedValue(new Error('密码错误'));
render(<LoginPage />);
await user.type(screen.getByPlaceholderText('请输入管理密码'), 'wrong');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('密码错误')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登录' })).toBeEnabled();
});
it('登录失败后无 token 且不跳转', async () => {
const user = userEvent.setup();
mockLogin.mockRejectedValue(new Error('密码错误'));
render(<LoginPage />);
await user.type(screen.getByPlaceholderText('请输入管理密码'), 'wrong');
await user.click(screen.getByRole('button', { name: '登录' }));
await screen.findByText('密码错误');
expect(localStorage.getItem('token')).toBeNull();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,63 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import Sidebar from '../Sidebar';
import { api } from '../../services/api';
import { useNavigate, useParams } from 'react-router-dom';
vi.mock('../../services/api', () => ({
api: { listProjects: vi.fn(), createProject: vi.fn(), changePassword: vi.fn(), logout: vi.fn() },
}));
vi.mock('react-router-dom', () => ({ useNavigate: vi.fn(), useParams: vi.fn() }));
describe('Sidebar', () => {
const navigate = vi.fn();
const mockList = api.listProjects as ReturnType<typeof vi.fn>;
const mockChangePwd = api.changePassword as ReturnType<typeof vi.fn>;
const mockLogout = api.logout as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
(useNavigate as ReturnType<typeof vi.fn>).mockReturnValue(navigate);
(useParams as ReturnType<typeof vi.fn>).mockReturnValue({});
mockList.mockResolvedValue([]);
});
it('点击「改密」展开表单,保存调用 changePassword 并跳转登录(A4/K7:轮换密钥后需重登)', async () => {
mockChangePwd.mockResolvedValue({ success: true });
const user = userEvent.setup();
render(<Sidebar />);
await user.click(screen.getByTitle('修改管理密码'));
await user.type(screen.getByPlaceholderText('当前密码'), 'old-pass');
await user.type(screen.getByPlaceholderText('新密码(至少 6 位)'), 'new-pass-123');
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(mockChangePwd).toHaveBeenCalledWith('old-pass', 'new-pass-123'));
expect(navigate).toHaveBeenCalledWith('/login');
});
it('改密失败显示服务端错误信息', async () => {
mockChangePwd.mockRejectedValue(new Error('当前密码错误'));
const user = userEvent.setup();
render(<Sidebar />);
await user.click(screen.getByTitle('修改管理密码'));
await user.type(screen.getByPlaceholderText('当前密码'), 'wrong');
await user.type(screen.getByPlaceholderText('新密码(至少 6 位)'), 'new-pass-123');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByText('当前密码错误')).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
it('退出登录调用 logout 后跳转登录页(K7 cookie 认证)', async () => {
mockLogout.mockResolvedValue({ success: true });
const user = userEvent.setup();
render(<Sidebar />);
await user.click(screen.getByRole('button', { name: '退出' }));
await waitFor(() => expect(mockLogout).toHaveBeenCalled());
expect(navigate).toHaveBeenCalledWith('/login');
});
});
+1468
View File
@@ -0,0 +1,1468 @@
/* ══════════════════════════════════════════════
AI-Review Design System
══════════════════════════════════════════════ */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--sidebar: 280px;
--primary: #6aa1f7;
--primary-dark: #5692f5;
--primary-light: #1f2937;
--accent: #4fc1ff;
--success: #89d185;
--warning: #e2c08d;
--danger: #f48771;
--bg: #1e1e1e;
--bg-subtle: #252526;
--card: #252526;
--text: #d4d4d4;
--text-secondary: #9d9d9d;
--muted: #858585;
--border: #3c3c3c;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 6px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.4);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.5), 0 4px 6px rgba(0,0,0,0.4);
--radius: 8px;
--radius-lg: 12px;
--badge-blue: #5692f5;
--badge-purple: #b080f0;
--badge-green: #4e9e5f;
--badge-amber: #d19a2e;
--badge-red: #d64545;
--badge-gray: #6b7280;
}
/* ═══ Utility ═══ */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-8 { gap: 8px; }
.gap-10 { gap: 10px; }
.gap-12 { gap: 12px; }
.gap-16 { gap: 16px; }
.mb-8 { margin-bottom: 8px; }
.mb-12 { margin-bottom: 12px; }
.mb-16 { margin-bottom: 16px; }
.mt-12 { margin-top: 12px; }
.mt-16 { margin-top: 16px; }
.mt-24 { margin-top: 24px; }
.ml-8 { margin-left: 8px; }
.text-center { text-align: center; }
.text-muted { color: var(--muted); }
.text-sm { font-size: 13px; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ═══ Login Page ═══ */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.login-page::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(79,70,229,0.15) 0%, transparent 50%),
radial-gradient(circle at 70% 50%, rgba(6,182,212,0.1) 0%, transparent 50%);
animation: loginGlow 8s ease-in-out infinite alternate;
}
@keyframes loginGlow {
0% { transform: translate(0, 0); }
100% { transform: translate(-5%, 5%); }
}
.login-form {
position: relative;
background: #252526;
padding: 48px 40px;
border-radius: 16px;
box-shadow: 0 25px 50px rgba(0,0,0,0.25);
width: 400px;
animation: loginSlide 0.5s ease-out;
}
@keyframes loginSlide {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-icon {
width: 52px; height: 52px; margin: 0 auto 16px;
background: linear-gradient(135deg, var(--primary-light), #e0e7ff);
border-radius: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 28px; color: var(--primary);
}
.login-form h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 4px;
letter-spacing: -0.02em;
}
.login-desc {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 32px;
}
.login-form input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 15px;
transition: all 0.2s;
outline: none;
}
.login-form input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.15);
}
.login-form button {
width: 100%;
margin-top: 20px;
padding: 12px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.01em;
}
.login-form button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79,70,229,0.4);
}
.login-form button:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
color: var(--danger);
font-size: 13px;
margin-top: 12px;
padding: 8px 12px;
background: #fef2f2;
border-radius: 6px;
}
/* ═══ Layout ═══ */
.layout {
display: flex;
min-height: 100vh;
}
/* ═══ Sidebar ═══ */
.sidebar {
width: var(--sidebar);
background: var(--card);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-header {
padding: 20px 20px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
}
.btn-logout {
font-size: 12px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-logout:hover {
color: var(--danger);
border-color: var(--danger);
background: #fef2f2;
}
.sidebar-section-label {
padding: 16px 20px 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.project-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.project-item {
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.15s;
margin-bottom: 2px;
border-left: 3px solid transparent;
}
.project-item:hover {
background: var(--bg-subtle);
}
.project-item.active {
background: var(--primary-light);
border-left-color: var(--primary);
}
.project-name {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.project-item.active .project-name {
color: var(--primary);
}
.project-stats {
font-size: 12px;
color: var(--muted);
background: var(--bg-subtle);
padding: 2px 8px;
border-radius: 10px;
}
.project-item.active .project-stats {
background: rgba(79,70,229,0.1);
color: var(--primary);
}
.new-project-form {
padding: 8px 12px 12px;
border-top: 1px solid var(--border);
}
.new-project-form input {
width: 100%;
padding: 10px 12px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: all 0.2s;
}
.new-project-form input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.12);
}
.new-project-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.new-project-actions button {
flex: 1;
padding: 8px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.new-project-actions button:first-child {
background: var(--primary);
color: #fff;
border: none;
}
.new-project-actions button:first-child:hover {
background: var(--primary-dark);
}
.new-project-actions button:last-child {
background: var(--card);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.new-project-actions button:last-child:hover {
background: var(--bg-subtle);
}
.btn-new-project {
margin: 8px 12px 12px;
padding: 10px;
border: 2px dashed var(--border);
border-radius: 10px;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.btn-new-project:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(79,70,229,0.03);
}
/* ═══ Main Content ═══ */
.main-content {
flex: 1;
margin-left: var(--sidebar);
padding: 32px;
overflow-y: auto;
min-height: 100vh;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 70vh;
color: var(--muted);
text-align: center;
}
.empty-state .empty-icon {
width: 80px;
height: 80px;
background: var(--primary-light);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
font-size: 36px;
}
.empty-state h2 {
font-size: 22px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.empty-state p {
font-size: 14px;
color: var(--text-secondary);
max-width: 300px;
line-height: 1.6;
}
.loading {
padding: 60px;
text-align: center;
color: var(--muted);
}
.loading::after {
content: '';
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ═══ Dashboard ═══ */
.dashboard {
max-width: 1200px;
}
.dashboard h1 {
font-size: 26px;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: -0.02em;
}
.dashboard-subtitle {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 28px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: all 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card .stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 16px;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.stat-card .stat-label {
font-size: 13px;
color: var(--text-secondary);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.dashboard-card {
background: var(--card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.dashboard-card h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text);
}
.activity-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.activity-item:last-child {
border-bottom: none;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.activity-text { color: var(--text); }
.activity-time { color: var(--muted); font-size: 12px; margin-left: auto; }
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.quick-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
color: var(--text-secondary);
font-family: inherit;
}
.quick-action-btn:hover {
border-color: var(--primary);
background: var(--primary-light);
color: var(--primary);
}
.quick-action-btn .qa-icon {
font-size: 24px;
}
/* ═══ Project View ═══ */
.project-view {
max-width: 1200px;
}
.project-header {
margin-bottom: 24px;
background: var(--card);
border-radius: var(--radius-lg);
padding: 24px 28px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.project-header h2 {
font-size: 22px;
font-weight: 700;
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.project-meta {
display: flex;
gap: 24px;
font-size: 13px;
color: var(--text-secondary);
}
.project-meta span {
display: flex;
align-items: center;
gap: 6px;
}
.project-meta .meta-icon {
font-size: 16px;
}
/* ═══ Tabs ═══ */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 24px;
}
.tab {
padding: 12px 24px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
position: relative;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
/* ═══ Standards ═══ */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h3 {
font-size: 16px;
font-weight: 600;
}
.section-header button {
padding: 8px 18px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.section-header button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79,70,229,0.3);
}
.standard-form {
background: var(--card);
padding: 24px;
border-radius: var(--radius-lg);
margin-bottom: 20px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.standard-form input, .standard-form textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
margin-bottom: 12px;
font-family: inherit;
outline: none;
transition: all 0.2s;
}
.standard-form input:focus, .standard-form textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
}
.form-hint {
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.form-actions {
display: flex;
gap: 10px;
}
.form-actions button {
padding: 8px 18px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.form-actions button:first-child {
background: var(--primary);
color: #fff;
border: none;
}
.form-actions button:first-child:hover {
background: var(--primary-dark);
}
.form-actions button:last-child {
background: var(--card);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.form-actions button:last-child:hover {
background: var(--bg-subtle);
}
.standard-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
text-align: center;
padding: 60px 40px;
color: var(--muted);
font-size: 14px;
}
.standard-card {
background: var(--card);
padding: 16px 20px;
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
transition: all 0.15s;
}
.standard-card:hover {
box-shadow: var(--shadow);
}
.standard-card strong {
font-size: 14px;
}
.tag {
background: var(--primary-light);
color: var(--primary);
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
margin-left: 8px;
}
.dims {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
}
.btn-danger {
padding: 5px 12px;
border: 1px solid var(--danger);
color: var(--danger);
background: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.btn-danger:hover {
background: #fef2f2;
}
.btn-danger-outline {
padding: 8px 16px;
border: 1px solid var(--danger);
color: var(--danger);
background: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.btn-danger-outline:hover { background: #fef2f2; }
/* ═══ Entry Table ═══ */
.btn-primary {
padding: 8px 18px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79,70,229,0.3);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 8px 18px;
background: var(--card);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--bg-subtle);
}
.filter-select {
padding: 8px 12px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
outline: none;
background: var(--card);
cursor: pointer;
transition: border-color 0.2s;
}
.filter-select:focus {
border-color: var(--primary);
}
.search-input {
padding: 8px 12px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
width: 200px;
outline: none;
transition: all 0.2s;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
}
.import-panel {
background: var(--card);
padding: 20px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
animation: slideDown 0.2s ease-out;
}
.import-panel textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
margin-bottom: 12px;
outline: none;
transition: border-color 0.2s;
background: var(--bg-subtle);
}
.import-panel textarea:focus {
border-color: var(--primary);
}
.import-result {
font-size: 13px;
margin-top: 12px;
padding: 12px 16px;
background: #f0fdf4;
border-radius: var(--radius);
border: 1px solid #bbf7d0;
}
.error-row {
color: var(--danger);
font-size: 12px;
padding: 4px 0;
}
.entry-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
background: var(--card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.entry-table th {
background: var(--bg-subtle);
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.entry-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.entry-table tr:last-child td {
border-bottom: none;
}
.entry-table tr:hover td {
background: var(--bg-subtle);
}
/* 成果物表格滚动容器:暗色滚动条常驻可见 */
.deliverables-scroll {
scrollbar-width: thin;
scrollbar-color: #4a4a4a transparent;
}
.deliverables-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.deliverables-scroll::-webkit-scrollbar-track {
background: transparent;
}
.deliverables-scroll::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 5px;
border: 2px solid var(--bg);
}
.deliverables-scroll::-webkit-scrollbar-thumb:hover {
background: #5f5f5f;
}
.deliverables-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.active-row td {
background: var(--primary-light) !important;
}
.title-cell {
cursor: pointer;
font-weight: 500;
color: var(--primary);
transition: color 0.15s;
}
.title-cell:hover {
color: var(--primary-dark);
text-decoration: underline;
}
.empty-row {
text-align: center;
color: var(--muted);
padding: 40px !important;
}
.action-cell {
white-space: nowrap;
}
.btn-action {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.btn-action:hover {
background: var(--bg-subtle);
border-color: var(--muted);
}
.btn-action.warn {
border-color: var(--warning);
color: var(--warning);
}
.btn-action.warn:hover {
background: #fffbeb;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-blue { background: var(--badge-blue); }
.badge-purple { background: var(--badge-purple); }
.badge-green { background: var(--badge-green); }
.badge-amber { background: var(--badge-amber); }
.badge-red { background: var(--badge-red); }
.badge-gray { background: var(--badge-gray); }
/* Status badges */
.status-pending { color: var(--muted); }
.status-queued { color: var(--warning); }
.status-active { color: var(--primary); font-weight: 600; position: relative; }
.status-active::after {
content: ''; display: inline-block; width: 6px; height: 6px;
background: var(--primary); border-radius: 50%; margin-left: 4px;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.status-done { color: var(--success); font-weight: 600; }
.status-fail { color: var(--danger); }
.status-cancelled { color: var(--muted); text-decoration: line-through; }
/* Button presets */
.btn-icon {
background: none; border: none; cursor: pointer; color: var(--muted);
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
border-radius: 6px; transition: all 0.15s; font-size: 16px;
}
.btn-icon:hover { background: var(--bg-subtle); color: var(--text); }
/* Sidebar project delete always visible */
.project-del {
background: none; border: none; cursor: pointer; color: var(--danger) !important;
font-size: 14px; padding: 2px 6px; border-radius: 4px; opacity: 0.6;
transition: all 0.15s;
}
.project-del:hover { opacity: 1; background: #fef2f2; }
/* Track badge in sidebar */
.track-tag {
font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: #e0e7ff; color: #4338ca;
}
/* Entry score cell */
.score-cell { font-weight: 600; }
.score-ok { color: var(--success); }
.score-low { color: var(--danger); }
.score-warn { color: var(--warning); }
.score-empty { color: var(--muted); }
/* Form elements unified */
.input-field {
padding: 10px 14px; border: 2px solid var(--border); border-radius: var(--radius);
font-size: 14px; outline: none; transition: all 0.2s;
}
.input-field:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
.select-field {
padding: 10px 14px; border: 2px solid var(--border); border-radius: var(--radius);
font-size: 14px; background: var(--card); outline: none; cursor: pointer;
transition: border-color 0.2s;
}
.select-field:focus { border-color: var(--primary); }
/* ═══ Detail Panel ═══ */
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(15,23,42,0.4);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
justify-content: flex-end;
animation: overlayFade 0.2s ease-out;
}
@keyframes overlayFade {
from { opacity: 0; }
to { opacity: 1; }
}
.detail-panel {
background: var(--card);
width: 720px;
max-width: 90vw;
height: 100vh;
overflow-y: auto;
padding: 28px 32px;
box-shadow: -8px 0 30px rgba(0,0,0,0.12);
animation: panelSlide 0.25s ease-out;
}
@keyframes panelSlide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.detail-header h3 {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
}
.btn-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--muted);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.15s;
}
.btn-close:hover {
background: var(--bg-subtle);
color: var(--text);
}
.detail-meta {
font-size: 13px;
color: var(--text-secondary);
line-height: 2;
margin-bottom: 20px;
padding: 16px;
background: var(--bg-subtle);
border-radius: var(--radius);
}
.detail-meta code {
font-size: 12px;
background: var(--card);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
.late-warning {
color: var(--danger);
font-weight: 600;
}
.retake-badge {
color: var(--warning);
font-weight: 600;
}
.detail-dims {
width: 100%;
min-width: 900px;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
background: var(--card);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
table-layout: fixed;
}
.detail-dims th {
background: var(--bg-subtle);
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 12px;
color: var(--text-secondary);
}
.detail-dims td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.detail-dims tbody tr {
transition: background 0.15s;
}
.detail-dims tbody tr:hover {
background: rgba(79, 70, 229, 0.04);
}
.detail-dims tr:last-child td {
border-bottom: none;
}
.detail-dims .dim-name {
font-weight: 600;
font-size: 13px;
line-height: 1.45;
padding-right: 8px;
}
.detail-dims .dim-bar-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.detail-dims .dim-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-subtle);
overflow: hidden;
}
.detail-dims .dim-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.detail-dims .dim-pct {
font-size: 11px;
color: var(--text-secondary);
min-width: 34px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.score-input {
width: 84px;
padding: 8px 10px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 16px;
font-weight: 700;
text-align: center;
outline: none;
transition: all 0.2s;
background: var(--card);
font-variant-numeric: tabular-nums;
}
.score-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.12);
}
.score-input:hover {
border-color: var(--primary);
}
.score-max {
display: block;
font-size: 11px;
color: var(--text-secondary);
text-align: center;
margin-top: 3px;
font-variant-numeric: tabular-nums;
}
.comment-input {
width: 100%;
padding: 8px 12px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
transition: all 0.2s;
resize: vertical;
line-height: 1.6;
background: var(--card);
}
.comment-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
}
.suggestion-cell {
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
padding: 4px 0;
}
.dim-comment-btn {
cursor: pointer;
display: block;
min-height: 40px;
line-height: 1.6;
font-size: 13px;
color: var(--text);
background: var(--bg-subtle);
border-radius: 6px;
padding: 8px 12px;
border: 1px dashed transparent;
transition: all 0.15s;
}
.dim-comment-btn:hover {
border-color: var(--primary);
background: rgba(79, 70, 229, 0.05);
}
.dim-comment-btn.is-empty {
color: var(--text-secondary);
font-style: italic;
}
.detail-dims thead th:nth-child(1) { width: 24%; }
.detail-dims thead th:nth-child(2) { width: 84px; }
.detail-dims thead th:nth-child(3) { width: 84px; }
.detail-dims thead th:nth-child(4) { width: 27%; }
.detail-dims thead th:nth-child(5) { width: 27%; }
.history-section {
margin-top: 20px;
font-size: 13px;
}
.history-section summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--bg-subtle);
border-radius: var(--radius);
transition: all 0.15s;
}
.history-section summary:hover {
background: var(--border);
}
.history-item {
font-size: 12px;
color: var(--text-secondary);
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.detail-actions {
margin-top: 24px;
display: flex;
gap: 10px;
}
/* ═══ Summary ═══ */
.summary-section {
margin-top: 24px;
background: var(--card);
border-radius: var(--radius-lg);
padding: 24px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.summary-section h4 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text);
}
.rank {
font-weight: 700;
color: var(--primary);
}
.pass {
color: var(--success);
font-weight: 600;
}
.fail {
color: var(--danger);
font-weight: 600;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+47
View File
@@ -0,0 +1,47 @@
const BASE = '/api';
async function request<T>(method: string, path: string, body?: any): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const res = await fetch(`${BASE}${path}`, { method, headers, credentials: 'include', body: body ? JSON.stringify(body) : undefined });
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/me') {
window.location.href = '/login';
throw new Error('\u672A\u767B\u5F55');
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || '\u8BF7\u6C42\u5931\u8D25');
return data;
}
export const api = {
request: <T>(method: string, path: string, body?: any): Promise<T> => request<T>(method, path, body),
login: (password: string) => request<{ token: string }>('POST', '/auth/login', { password }),
logout: () => request<{ success: boolean }>('POST', '/auth/logout'),
getMe: () => request<{ role: string }>('GET', '/auth/me'),
changePassword: (currentPassword: string, newPassword: string) => request<{ success: boolean }>('POST', '/auth/password', { currentPassword, newPassword }),
getHealth: () => request<{ status: string }>('GET', '/health'),
listProjects: () => request<any[]>('GET', '/projects'),
createProject: (data: any) => request<any>('POST', '/projects', data),
getProject: (id: string) => request<any>('GET', `/projects/${id}`),
updateProject: (id: string, data: any) => request<any>('PUT', `/projects/${id}`, data),
deleteProject: (id: string, force?: boolean) => request<any>('DELETE', `/projects/${id}${force ? `?force=true` : ''}`),
listStandards: (pid: string) => request<any[]>('GET', `/projects/${pid}/standards`),
getStandard: (pid: string, sid: string) => request<any>('GET', `/projects/${pid}/standards/${sid}`),
createStandard: (pid: string, data: any) => request<any>('POST', `/projects/${pid}/standards`, data),
updateStandard: (pid: string, sid: string, data: any) => request<any>('PUT', `/projects/${pid}/standards/${sid}`, data),
deleteStandard: (pid: string, sid: string) => request<any>('DELETE', `/projects/${pid}/standards/${sid}`),
listEntries: (pid: string, params?: any) => {
const q = new URLSearchParams(params || {}).toString();
return request<{ items: any[]; total: number }>('GET', `/projects/${pid}/entries${q ? `?${q}` : ''}`);
},
getEntry: (pid: string, eid: string) => request<any>('GET', `/projects/${pid}/entries/${eid}`),
createEntry: (pid: string, data: any) => request<any>('POST', `/projects/${pid}/entries`, data),
batchImport: (pid: string, entries: any[]) => request<any>('POST', `/projects/${pid}/entries/batch`, { entries }),
updateEntry: (pid: string, eid: string, data: any) => request<any>('PUT', `/projects/${pid}/entries/${eid}`, data),
deleteEntry: (pid: string, eid: string) => request<any>('DELETE', `/projects/${pid}/entries/${eid}`),
getGiteaTokenStatus: () => request<any>('GET', '/config/gitea-token/status'),
updateGiteaToken: (token: string) => request<any>('PUT', '/config/gitea-token', { token }),
};
+16
View File
@@ -0,0 +1,16 @@
import '@testing-library/jest-dom/vitest'
if (!localStorage || typeof localStorage.clear !== 'function') {
const store = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: (i: number) => [...store.keys()][i] ?? null,
get length() { return store.size },
},
configurable: true,
})
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 14001,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
}
}
}
})
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
},
})