Initial commit: AuraK人才测评系统基础框架

## 已实现功能
- 题库管理后端API完整实现
- 模板管理页面(Settings-测评模板)
- 评估统计页面
- 人才测评页面(AssessmentView)
- QuestionBank前端服务层

## 技术栈
- 后端: Node.js + NestJS + TypeORM
- 前端: React + TypeScript
- 容器化: Docker Compose

## 已知待完善
- 题库列表页缺少删除按钮
- 题库详情页未实现(题目管理/AI生成/审核)
This commit is contained in:
Developer
2026-05-13 21:32:41 +08:00
parent 0a9588abb7
commit 8686d101cd
22 changed files with 727 additions and 38 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置 yarn 阿里云源并安装依赖
COPY web/package*.json web/yarn.lock* ./
RUN yarn config set registry https://registry.npmmirror.com && \
RUN yarn config set registry https://registry.yarnpkg.com && \
yarn install
# 复制源代码
+2
View File
@@ -44,6 +44,8 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
setLoading(true);
try {
const newGroup = await knowledgeGroupService.createGroup(formData);
console.log('[GroupManager] Created group:', newGroup);
console.log('[GroupManager] Current groups:', groups);
onGroupsChange([...groups, newGroup]);
setIsCreateModalOpen(false);
resetForm();
+1 -1
View File
@@ -42,7 +42,7 @@ export const AssessmentStatsView: React.FC = () => {
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [showFilters, setShowFilters] = useState(false);
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
const isAdmin = true; // Temporarily allow all users
useEffect(() => {
const fetchData = async () => {
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { Plus, BookOpen, ChevronRight } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { templateService } from '../../services/templateService';
import { AssessmentTemplate } from '../../types';
interface QuestionBank {
id: string;
name: string;
description?: string;
status: string;
templateId?: string;
createdAt: string;
}
export default function QuestionBankView() {
const [banks, setBanks] = useState<QuestionBank[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showDrawer, setShowDrawer] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
templateId: ''
});
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const res = await apiClient.request('/question-banks', {});
if (!res.ok) throw new Error(res.status.toString());
const data = await res.json();
setBanks(Array.isArray(data) ? data : (data.data || []));
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
};
const openDrawer = () => {
setFormData({ name: '', description: '', templateId: '' });
setLoadingTemplates(true);
templateService.getAll()
.then(data => setTemplates(data))
.catch(err => console.error('加载模板失败:', err))
.finally(() => setLoadingTemplates(false));
setShowDrawer(true);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
setSaving(true);
try {
const payload: any = {
name: formData.name,
description: formData.description,
};
if (formData.templateId) {
payload.templateId = formData.templateId;
}
const res = await apiClient.request('/question-banks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(res.status.toString());
setShowDrawer(false);
fetchData();
} catch (err: any) {
console.error('创建失败:', err);
alert('创建失败: ' + (err.message || '未知错误'));
} finally {
setSaving(false);
}
};
return (
<div className="p-6 bg-white min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<button
onClick={openDrawer}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={18} />
<span></span>
</button>
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : error ? (
<div className="text-center py-8 text-red-500">: {error}</div>
) : banks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{banks.map((bank) => (
<div key={bank.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{bank.name}</h3>
<p className="text-sm text-gray-500">{bank.description}</p>
<p className="text-xs text-gray-400 mt-2">: {bank.status}</p>
</div>
))}
</div>
)}
{/* Drawer */}
<>
{showDrawer && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={() => setShowDrawer(false)}
/>
)}
<div
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${showDrawer ? 'translate-x-0' : 'translate-x-full'}`}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
<Plus className="w-6 h-6 text-blue-600" />
</h2>
<button
onClick={() => setShowDrawer(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入题库名称"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder="输入描述"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
</label>
<select
value={formData.templateId}
onChange={(e) => setFormData({...formData, templateId: e.target.value})}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
disabled={loadingTemplates}
>
<option value=""></option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTemplates && <span className="text-xs text-slate-500">...</span>}
</div>
</form>
</div>
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="create-form"
disabled={saving || !formData.name.trim()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
>
<Plus size={20} />
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</>
</div>
);
}
+4 -2
View File
@@ -18,7 +18,8 @@ const KnowledgePage = lazy(() => import('./src/pages/workspace/KnowledgePage'));
const NotebooksPage = lazy(() => import('./src/pages/workspace/NotebooksPage'));
const MemosPage = lazy(() => import('./src/pages/workspace/MemosPage'));
const SettingsPage = lazy(() => import('./src/pages/workspace/SettingsPage'));
const AssessmentStatsPage = lazy(() => import('./src/pages/workspace/AssessmentStatsPage'));
const QuestionBankView = lazy(() => import('./components/views/QuestionBankView'));
const AssessmentStatsView = lazy(() => import('./components/views/AssessmentStatsView'));
const PageLoader = () => (
<div className="flex h-full items-center justify-center">
@@ -87,7 +88,8 @@ function App() {
<Route path="chat" element={<ChatPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="assessment" element={<AssessmentPage />} />
<Route path="assessment-stats" element={<AssessmentStatsPage />} />
<Route path="assessment-stats" element={<AssessmentStatsView />} />
<Route path="question-banks" element={<QuestionBankView isAdmin={true} />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="notebook" element={<MemosPage />} />
<Route path="knowledge/*" element={<KnowledgePage />} />
+142
View File
@@ -0,0 +1,142 @@
import { apiClient } from './apiClient';
export interface QuestionBank {
id: string;
name: string;
description?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED';
templateId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface QuestionBankItem {
id: string;
bankId: string;
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null;
correctAnswer?: string | null;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
basis?: string | null;
status: 'PENDING_REVIEW' | 'PUBLISHED';
createdAt: string;
}
export interface CreateQuestionBankDto {
name: string;
description?: string;
templateId?: string;
}
export interface CreateQuestionBankItemDto {
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[];
correctAnswer?: string;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
}
export const questionBankService = {
async getBanks(): Promise<QuestionBank[]> {
const response = await apiClient.request('/question-banks', {});
if (!response.ok) throw new Error('Failed to fetch question banks');
const data = await response.json();
return Array.isArray(data) ? data : data.data || [];
},
async getBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}`, {});
if (!response.ok) throw new Error('Failed to fetch question bank');
return await response.json();
},
async createBank(data: CreateQuestionBankDto): Promise<QuestionBank> {
const response = await apiClient.post('/question-banks', data);
if (!response.ok) throw new Error('Failed to create question bank');
return response.data;
},
async updateBank(id: string, data: Partial<CreateQuestionBankDto>): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update question bank');
return await response.json();
},
async deleteBank(id: string): Promise<void> {
const response = await apiClient.request(`/question-banks/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete question bank');
},
async submitForReview(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/submit`, { method: 'PUT' });
if (!response.ok) throw new Error('Failed to submit for review');
return await response.json();
},
async approveBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: true }),
});
if (!response.ok) throw new Error('Failed to approve');
return await response.json();
},
async rejectBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: false }),
});
if (!response.ok) throw new Error('Failed to reject');
return await response.json();
},
async publishBank(id: string): Promise<QuestionBank> {
const response = await apiClient.request(`/question-banks/${id}/publish`, { method: 'PUT' });
if (!response.ok) throw new Error('Failed to publish');
return await response.json();
},
async getBankItems(bankId: string): Promise<QuestionBankItem[]> {
const response = await apiClient.request(`/question-banks/${bankId}`, {});
if (!response.ok) throw new Error('Failed to fetch items');
const data = await response.json();
return data.items || [];
},
async createItem(bankId: string, data: CreateQuestionBankItemDto): Promise<QuestionBankItem> {
const response = await apiClient.request(`/question-banks/${bankId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create item');
return await response.json();
},
async updateItem(bankId: string, itemId: string, data: Partial<CreateQuestionBankItemDto>): Promise<QuestionBankItem> {
const response = await apiClient.request(`/question-banks/${bankId}/items/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update item');
return await response.json();
},
async deleteItem(bankId: string, itemId: string): Promise<void> {
const response = await apiClient.request(`/question-banks/${bankId}/items/${itemId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete item');
},
};
@@ -17,7 +17,8 @@ import {
Bot,
Blocks,
ClipboardCheck,
BarChart3
BarChart3,
Folder
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../contexts/AuthContext';
@@ -179,6 +180,13 @@ const WorkspaceLayout: React.FC = () => {
isActive={location.pathname === '/assessment-stats'}
onClick={handleNavClick}
/>
<SidebarItem
icon={Folder}
label={isZh ? '题库管理' : 'Question Banks'}
path="/question-banks"
isActive={location.pathname === '/question-banks'}
onClick={handleNavClick}
/>
{(activeTenant?.features?.isNotebookEnabled ?? true) && (
<SidebarItem
icon={BookOpen}
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiClient } from './apiClient';
import { apiClient } from '../../services/apiClient';
export interface AssessmentStats {
totalAttempts: number;
+41
View File
@@ -362,3 +362,44 @@ export interface CreateTemplateData {
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
isActive?: boolean;
}
export interface QuestionBank {
id: string;
name: string;
description?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED';
templateId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface QuestionBankItem {
id: string;
bankId: string;
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null;
correctAnswer?: string | null;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
basis?: string | null;
status: 'PENDING_REVIEW' | 'PUBLISHED';
createdAt: string;
}
export interface CreateQuestionBankData {
name: string;
description?: string;
templateId?: string;
}
export interface CreateQuestionBankItemData {
questionText: string;
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[];
correctAnswer?: string;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
}