初始提交:ai-review 项目当前版本(含赛道一/二提交规范修订与时间节点文档)
This commit is contained in:
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '## A(100分)\n说明\n文件关键词: a\n## B(60分)\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('启动条目');
|
||||
});
|
||||
});
|
||||
@@ -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('新規');
|
||||
});
|
||||
});
|
||||
@@ -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. 成果物 Tab(DeliverablesView)
|
||||
// ══════════════════════════════════════
|
||||
|
||||
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. 评审进度时间线(DetailPanel,D7)
|
||||
// ══════════════════════════════════════
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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('登录尝试过多');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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.
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user