F1-F10: audit fixes (dimension normalize, passingScore scale, DB defaults, onDelete, item status filter, timeout event type, userId privacy) + generator.node.ts strict prompt rules (anti-hallucination)

This commit is contained in:
Developer
2026-05-20 11:13:37 +08:00
parent 29bac74b58
commit 83483d8117
13 changed files with 205 additions and 202 deletions
+11 -5
View File
@@ -137,6 +137,13 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
return result;
}
private normalizeDimension(dim: string): string {
const lower = dim.toLowerCase();
if (lower === 'dev_pattern') return 'devPattern';
if (lower === 'work_capability') return 'workCapability';
return lower;
}
private calculateScores(
questions: any[],
scores: Record<string, number>,
@@ -157,7 +164,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
};
questions.forEach((q: any, idx: number) => {
const dimension = q.dimension || 'workCapability';
const dimension = this.normalizeDimension(q.dimension || 'workCapability');
const score = scores[q.id || idx.toString()] || 0;
if (dimensionScoresMap[dimension]) {
dimensionScoresMap[dimension].push(score);
@@ -729,7 +736,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores;
const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -820,7 +827,7 @@ const initialState: Partial<EvaluationState> = {
let finalResult: any = null;
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
// Resume from the last interrupt (typically after interviewer)
const stream = await this.graph.stream(null, {
@@ -965,6 +972,7 @@ const initialState: Partial<EvaluationState> = {
await this.sessionRepository.save(session);
this.logger.log(`[submitAnswerStream] Session ${sessionId} auto-ended due to timeout`);
observer.next({
type: 'final',
assessmentSessionId: sessionId,
status: 'COMPLETED',
timeout: true,
@@ -1713,7 +1721,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: number;
passed: boolean;
issuedAt: Date;
userId: string;
};
message?: string;
}> {
@@ -1734,7 +1741,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: certificate.totalScore,
passed: certificate.passed,
issuedAt: certificate.issuedAt,
userId: certificate.userId,
},
};
}
@@ -22,6 +22,7 @@ import {
ReviewDto,
} from '../services/question-bank.service';
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
import { KnowledgeGroupService } from '../../knowledge-group/knowledge-group.service';
@Controller('question-banks')
@UseGuards(CombinedAuthGuard)
@@ -29,12 +30,20 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
export class QuestionBankController {
private readonly logger = new Logger(QuestionBankController.name);
constructor(private readonly questionBankService: QuestionBankService) {}
constructor(
private readonly questionBankService: QuestionBankService,
private readonly groupService: KnowledgeGroupService,
) {}
@Post()
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
this.logger.log(`Creating question bank: ${createDto.name}`);
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
async create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
try {
this.logger.log(`Creating question bank: ${createDto.name}, user: ${req.user?.id}, tenant: ${req.user?.tenantId}`);
return await this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
} catch (err: any) {
this.logger.error(`[create] Failed: ${err.message}`, err.stack);
throw err;
}
}
@Get()
@@ -125,11 +134,32 @@ export class QuestionBankController {
@Body() body: { count: number; knowledgeBaseContent?: string },
@Req() req: any,
) {
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}`);
let content = body.knowledgeBaseContent || '';
if (!content || content.trim().length < 10) {
try {
const bank = await this.questionBankService.findOne(bankId);
if (bank?.template?.knowledgeGroupId) {
const files = await this.groupService.getGroupFiles(
bank.template.knowledgeGroupId,
req.user.id,
req.user.tenantId,
);
content = files
.filter((f: any) => f.content && f.content.trim().length > 0)
.map((f: any) => `--- ${f.title || f.originalName || 'Document'} ---\n${f.content}`)
.join('\n\n');
this.logger.log(`[generate] Auto-loaded ${files.length} files, content length: ${content.length}`);
}
} catch (err: any) {
this.logger.warn(`[generate] Auto-load failed: ${err.message}`);
}
}
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}, content length: ${content.length}`);
return this.questionBankService.generateQuestions(
bankId,
body.count,
body.knowledgeBaseContent || '',
content,
req.user.tenantId,
);
}
@@ -56,6 +56,7 @@ export class QuestionBankItem {
@Column({
type: 'simple-enum',
enum: QuestionType,
default: QuestionType.SHORT_ANSWER,
})
questionType: QuestionType;
@@ -71,12 +72,14 @@ export class QuestionBankItem {
@Column({
type: 'simple-enum',
enum: QuestionDifficulty,
default: QuestionDifficulty.STANDARD,
})
difficulty: QuestionDifficulty;
@Column({
type: 'simple-enum',
enum: QuestionDimension,
default: QuestionDimension.PROMPT,
})
dimension: QuestionDimension;
@@ -89,6 +92,7 @@ export class QuestionBankItem {
@Column({
type: 'simple-enum',
enum: QuestionBankItemStatus,
default: QuestionBankItemStatus.PENDING_REVIEW,
})
status: QuestionBankItemStatus;
@@ -37,7 +37,7 @@ export class QuestionBank {
@Column({ name: 'template_id', nullable: true })
templateId: string | null;
@OneToOne(() => AssessmentTemplate, { nullable: true })
@OneToOne(() => AssessmentTemplate, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'template_id' })
template: AssessmentTemplate;
@@ -89,12 +89,18 @@ export const questionGeneratorNode = async (
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
.join('\n');
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
const systemPromptZh = `你是一位严格的知识评估专家。你必须**仅基于**下方提供的知识库内容来生成测试题目。
### 核心铁律(违反将导致题目无效):
1. **所有题目必须直接来源于提供的知识库内容**,每个题目必须能找到对应的原文依据
2. **绝对禁止**编造知识库内容中未提及的概念、术语、流程或数据
3. **绝对禁止**使用你自身知识库中的内容来编造题目
4. 如果知识库内容不足以出题,诚实地报告而不是编造
### 强制性语言规则:
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
**必须使用中文 (Simplified Chinese) 进行回复**。
### 强制性多样性规则:
### 多样性规则:
${rulesZh}
### 禁止重复列表(已出过):
@@ -111,15 +117,21 @@ ${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style
"key_points": ["点1", "点2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用原文..."
"basis": "【必须填写】从知识库中引用与此题相关的原文内容,用引号标注来源段落"
}
]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。
const systemPromptJa = `あなたは厳格な知識評価の専門家です。提供されたナレッジベースの内容**のみ**に基づいて問題を作成してください。
### 核心鉄則(違反した問題は無効):
1. **すべての問題は提供されたナレッジベースから直接導出**し、各問題に原文の根拠が必要
2. **絶対禁止**:ナレッジベースに記載されていない概念、用語、プロセス、データを作り出すこと
3. **絶対禁止**:自身の知識ベースの内容を問題として使用すること
4. 内容が不十分な場合は、正直に報告し、捏造しないこと
### 言語ルール(最重要):
**必ず日本語で作成してください**。提供されたナレッジベースが英語や中国語、その他の言語であっても、質問文(question_text)およびキーポイント(key_points)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください。
**必ず日本語で作成してください**。中国語が混ざらないように厳格に注意してください。
### 多様性ルール:
${rulesJa}
@@ -138,11 +150,17 @@ ${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイ
"key_points": ["ポイント1", "ポイント2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用箇所..."
"basis": "【必須】ナレッジベースから関連する原文を引用し、出典段落を明記"
}
]`;
]`;
const systemPromptEn = `You are an expert examiner. Generate 1 UNIQUE question based on the provided context.
const systemPromptEn = `You are a strict knowledge assessment expert. You MUST generate questions **ONLY** from the provided knowledge base content below.
### Core Rules (violations invalidate the question):
1. **All questions MUST directly derive from the provided content**, each question requires a verifiable source reference
2. **ABSOLUTELY FORBIDDEN**: inventing concepts, terminology, processes, or data not in the provided content
3. **ABSOLUTELY FORBIDDEN**: using your own knowledge to fabricate questions
4. If content is insufficient, honestly report rather than fabricate
### Language Rule:
**You MUST generate the question and key points in English.**
@@ -160,7 +178,7 @@ Return 1 question as a JSON array with format:
"key_points": ["point1", "point2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] citation..."
"basis": "【REQUIRED】Cite the specific source text from the knowledge base, noting the source paragraph"
}
]`;
@@ -172,10 +190,10 @@ Return 1 question as a JSON array with format:
? systemPromptJa
: systemPromptEn;
const humanMsg = isZh
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
? `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上内容生成题目。`
: isJa
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
? `【ナレッジベース内容 - 以下は出題の唯一の根拠です】\n\n--- ナレッジベース開始 ---\n${knowledgeBaseContent}\n--- ナレッジベース終了 ---\n\n上記の内容のみに基づいて問題を作成してください。`
: `【Knowledge Base Content - Your ONLY source for questions】\n\n--- KB START ---\n${knowledgeBaseContent}\n--- KB END ---\n\nGenerate questions strictly from the above content only.`;
try {
const response = await model.invoke([
@@ -92,7 +92,7 @@ export class QuestionBankService {
}
if (createDto.templateId) {
const existing = await this.bankRepository.findOne({
where: { templateId: createDto.templateId, tenantId: tenantId as any },
where: { templateId: createDto.templateId },
});
if (existing) {
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
@@ -295,35 +295,45 @@ export class QuestionBankService {
const model = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama',
modelName: modelConfig.modelId,
temperature: 0.7,
temperature: 0.3,
configuration: {
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
},
});
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。
const systemPrompt = `你是一位严格的知识评估专家。你必须**仅基于**下方 Human 消息中提供的知识库内容】来生成题目。
### 强制性语言规则
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
### 核心铁律(违反将导致题目无效)
1. **所有题目必须直接来源于提供的知识库内容**,每个题目必须能找到对应的原文依据
2. **绝对禁止**编造知识库内容中未提及的概念、术语、流程或数据
3. **绝对禁止**使用你自身知识库中的内容来编造题目
4. 如果知识库内容不足以生成 ${count} 道有意义的题目,可以生成少于 ${count} 道,但题目质量优先
### 多样性规则
1. 禁止重复:绝对禁止生成相似的题目
2. 深度挖掘:从不同的角度出题,如流程、限制、优缺点、具体参数等
3. 随机扰动:从不同的逻辑链条出发
### 任务:
请以 JSON 数组格式返回 ${count} 个问题:
### 格式要求
请以 JSON 数组格式返回题目
[
{
"question_text": "问题内容",
"key_points": ["要点1", "要点2"],
"question_text": "基于知识库内容的实际问题",
"key_points": ["评分要点1", "评分要点2"],
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
"dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "[n] 引用原文..."
"basis": "【必须填写】从知识库中引用与此题相关的原文内容,用引号标注来源段落"
}
]`;
]
const humanMsg = `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`;
### 维度说明(根据题目内容归类):
- prompt: 关于提示词设计、AI交互优化
- llm: 关于大语言模型原理、架构、参数
- ide: 关于开发工具使用、协作效率
- devPattern: 关于开发方法论、工程范式
- workCapability: 关于工作能力、综合素养
### 出题规范:
1. 每个题目必须标注 basis,引用知识库中的具体原文作为依据
2. 题目难度分布合理,覆盖 STANDARD/ADVANCED/SPECIALIST
3. 不同维度各出一部分,不要集中在一个维度`;
const humanMsg = `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上知识库内容生成题目。`;
try {
const response = await model.invoke([
@@ -395,7 +405,7 @@ export class QuestionBankService {
}
const allItems = await this.itemRepository.find({
where: { bankId },
where: { bankId, status: QuestionBankItemStatus.PUBLISHED },
});
if (allItems.length === 0) {
@@ -260,7 +260,6 @@ export class KnowledgeGroupService {
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
}
// Check permission using TenantService
const hasAccess = await this.tenantService.canAccessTenant(
userId,
group.tenantId,
@@ -272,7 +271,31 @@ export class KnowledgeGroupService {
);
}
return group.knowledgeBases;
const allGroups = await this.groupRepository.find({
where: tenantId === null ? {} : { tenantId },
relations: ['knowledgeBases'],
});
const childIds = new Set<string>();
const collectDescendantIds = (parentId: string) => {
for (const g of allGroups) {
if (g.parentId === parentId) {
childIds.add(g.id);
collectDescendantIds(g.id);
}
}
};
collectDescendantIds(groupId);
const result = [...(group.knowledgeBases || [])];
for (const childId of childIds) {
const childGroup = allGroups.find(g => g.id === childId);
if (childGroup?.knowledgeBases) {
result.push(...childGroup.knowledgeBases);
}
}
return result;
}
async addFilesToGroup(
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\server
node --enable-source-maps dist/main
pause
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\web
npx vite --port 13001
pause
+1 -1
View File
@@ -34,7 +34,7 @@ export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
appMode={appMode}
onSwitchMode={onSwitchMode}
/>
<div className="flex-1 overflow-hidden relative">
<div className="flex-1 overflow-auto relative">
{children}
</div>
</div>
+60 -154
View File
@@ -108,6 +108,11 @@ export default function QuestionBankDetailView() {
}
};
const openGenerateModal = () => {
setShowGenerate(true);
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
};
const dimensionOptions = template?.dimensions?.map(d => ({ value: d.name || d.label, label: d.label || d.name }))
|| [
{ value: 'PROMPT', label: 'Prompt' },
@@ -122,18 +127,12 @@ export default function QuestionBankDetailView() {
if (!itemForm.questionText.trim()) return;
setSaving(true);
try {
await questionBankService.createItem(bankId, {
...itemForm,
keyPoints: keyPointsInput.split('\n').filter(k => k.trim()),
});
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
showSuccess(t('questionAdded'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
} finally {
setSaving(false);
}
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); }
};
const handleUpdateItem = async (e: React.FormEvent) => {
@@ -141,57 +140,38 @@ export default function QuestionBankDetailView() {
if (!editingItem || !itemForm.questionText.trim()) return;
setSaving(true);
try {
await questionBankService.updateItem(bankId, editingItem.id, {
...itemForm,
keyPoints: keyPointsInput.split('\n').filter(k => k.trim()),
});
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
showSuccess(t('questionUpdated'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
} finally {
setSaving(false);
}
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); }
};
const handleDeleteItem = async (itemId: string) => {
const ok = await confirm({ message: t('confirmDeleteQuestion'), confirmLabel: t('delete'), cancelLabel: t('cancel') });
if (!ok) return;
try {
await questionBankService.deleteItem(bankId, itemId);
showSuccess(t('questionDeleted'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
}
try { await questionBankService.deleteItem(bankId, itemId); showSuccess(t('questionDeleted')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleGenerate = async () => {
setGenerating(true);
try {
const result = await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
setShowGenerate(false);
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
showSuccess(t('generatedQuestions').replace('$1', String(generateForm.count)));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
} finally {
setGenerating(false);
}
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setGenerating(false); }
};
const handleSubmitForReview = async () => {
const ok = await confirm({ message: t('confirmSubmitReview'), confirmLabel: t('submitForReview'), cancelLabel: t('cancel') });
if (!ok) return;
try {
await questionBankService.submitForReview(bankId);
showSuccess(t('bankSubmittedForReview'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
}
try { await questionBankService.submitForReview(bankId); showSuccess(t('bankSubmittedForReview')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handlePublish = async () => {
@@ -201,48 +181,26 @@ export default function QuestionBankDetailView() {
const ok = await confirm({ message: msg, confirmLabel: label, cancelLabel: t('cancel') });
if (!ok) return;
try {
if (isPendingReview) {
await questionBankService.approveBank(bankId);
} else {
await questionBankService.publishBank(bankId);
}
if (isPendingReview) await questionBankService.approveBank(bankId);
else await questionBankService.publishBank(bankId);
showSuccess(isPendingReview ? t('bankApproved') : t('bankRepublished'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
}
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleApproveItem = async (itemId: string) => {
try {
await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any);
showSuccess(t('questionApproved'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
}
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); showSuccess(t('questionApproved')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleRejectItem = async (itemId: string) => {
try {
await questionBankService.batchReviewItems(bankId, [itemId], false);
showSuccess(t('questionReturned'));
fetchData();
} catch (err: any) {
showError(err.message || t('actionFailed'));
}
try { await questionBankService.batchReviewItems(bankId, [itemId], false); showSuccess(t('questionReturned')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const openEditItem = (item: QuestionBankItem) => {
setEditingItem(item);
setItemForm({
questionText: item.questionText,
questionType: item.questionType,
options: item.options || [],
keyPoints: item.keyPoints,
difficulty: item.difficulty,
dimension: item.dimension,
});
setItemForm({ questionText: item.questionText, questionType: item.questionType, options: item.options || [], keyPoints: item.keyPoints, difficulty: item.difficulty, dimension: item.dimension });
setKeyPointsInput(item.keyPoints.join('\n'));
setShowAddItem(true);
};
@@ -264,8 +222,7 @@ export default function QuestionBankDetailView() {
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</button>
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
<AlertCircle size={20} />
<span className="text-sm font-bold">{error}</span>
<AlertCircle size={20} /><span className="text-sm font-bold">{error}</span>
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
</div>
</div>
@@ -291,7 +248,7 @@ export default function QuestionBankDetailView() {
];
return (
<div className="space-y-6">
<div className="space-y-6 overflow-y-auto h-full">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</button>
@@ -326,7 +283,7 @@ export default function QuestionBankDetailView() {
<Check size={16} /> {bank?.status === 'PENDING_REVIEW' ? t('approve') : t('republish')}
</button>
)}
<button onClick={() => setShowGenerate(true)} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
<button onClick={openGenerateModal} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
<Sparkles size={16} /> {t('aiGenerate')}
</button>
</div>
@@ -355,9 +312,7 @@ export default function QuestionBankDetailView() {
{items.length === 0 ? (
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<FileText size={28} className="text-slate-300" />
</div>
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4"><FileText size={28} className="text-slate-300" /></div>
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-1">{t('noQuestions')}</p>
<p className="text-slate-300 text-xs">{t('noQuestionsDesc')}</p>
</div>
@@ -374,21 +329,10 @@ export default function QuestionBankDetailView() {
<div className="relative z-10 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">
{typeIcons[item.questionType]}
{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100">
<Hash size={10} />
{t(DIFFICULTIES.find(d => d.value === item.difficulty)?.labelKey || 'standard')}
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100">
<Brain size={10} />
{dimensionOptions.find(d => d.value === item.dimension)?.label || item.dimension}
</span>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${itemStat.bg} ${itemStat.text} ${itemStat.border}`}>
{itemStat.icon}{itemStat.label}
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{t(DIFFICULTIES.find(d => d.value === item.difficulty)?.labelKey || 'standard')}</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{dimensionOptions.find(d => d.value === item.dimension)?.label || item.dimension}</span>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${itemStat.bg} ${itemStat.text} ${itemStat.border}`}>{itemStat.icon}{itemStat.label}</span>
</div>
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
{item.keyPoints.length > 0 && (
@@ -398,18 +342,14 @@ export default function QuestionBankDetailView() {
</div>
)}
{item.basis && (
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400">
<FileText size={10} /><span className="font-medium">{t('basis')}</span><span>{item.basis}</span>
</div>
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium">{t('basis')}</span><span>{item.basis}</span></div>
)}
</div>
<div className="flex items-center gap-1 ml-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{item.status === 'PENDING_REVIEW' && (
<>
<button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title={t('approve')}><Check size={15} /></button>
<button onClick={() => handleRejectItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('rejected')}><X size={15} /></button>
</>
)}
{item.status === 'PENDING_REVIEW' && (<>
<button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title={t('approve')}><Check size={15} /></button>
<button onClick={() => handleRejectItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('rejected')}><X size={15} /></button>
</>)}
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}><Edit2 size={15} /></button>
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('delete')}><Trash2 size={15} /></button>
</div>
@@ -429,60 +369,37 @@ export default function QuestionBankDetailView() {
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
<h3 className="text-xl font-black text-slate-900">{editingItem ? t('editQuestion') : t('addQuestionTitle')}</h3>
</div>
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
<h3 className="text-xl font-black text-slate-900">{editingItem ? t('editQuestion') : t('addQuestionTitle')}</h3></div>
<button onClick={closeItemForm} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
</div>
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> {t('questionContent')} <span className="text-red-500">*</span></label>
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({ ...itemForm, questionText: e.target.value })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
placeholder={t('questionContent')} rows={3} required />
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> {t('questionContent')} <span className="text-red-500">*</span></label>
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={t('questionContent')} rows={3} required />
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> {t('questionType')}</label>
<select value={itemForm.questionType} onChange={(e) => setItemForm({ ...itemForm, questionType: e.target.value as any })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">
{QUESTION_TYPES.map(qt => <option key={qt.value} value={qt.value}>{t(qt.labelKey)}</option>)}
</select>
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> {t('questionType')}</label>
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ QUESTION_TYPES.map(qt => <option key={qt.value} value={qt.value}>{t(qt.labelKey)}</option>) }</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-blue-500" /> {t('difficultyDistribution')}</label>
<select value={itemForm.difficulty} onChange={(e) => setItemForm({ ...itemForm, difficulty: e.target.value as any })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
</select>
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-blue-500" /> {t('difficultyDistribution')}</label>
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>) }</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> {t('dimension')}</label>
<select value={itemForm.dimension} onChange={(e) => setItemForm({ ...itemForm, dimension: e.target.value as any })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">
{dimensionOptions.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> {t('dimension')}</label>
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ dimensionOptions.map(d => <option key={d.value} value={d.value}>{d.label}</option>) }</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> {t('gradingPoints')}</label>
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
placeholder={'1\n2\n3'} rows={4} />
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> {t('gradingPoints')}</label>
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={'1\n2\n3'} rows={4} />
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
<button type="submit" form="item-form" disabled={saving}
className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">
{saving && <Loader2 size={16} className="animate-spin" />}{saving ? t('saving') : (editingItem ? t('save') : t('addQuestion'))}</button>
<button type="submit" form="item-form" disabled={saving} className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">{saving && <Loader2 size={16} className="animate-spin" />}{saving ? t('saving') : (editingItem ? t('save') : t('addQuestion'))}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
</AnimatePresence>, document.body
)}
{createPortal(
@@ -493,36 +410,25 @@ export default function QuestionBankDetailView() {
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
<h3 className="text-xl font-black text-slate-900">{t('aiGenerateTitle')}</h3>
</div>
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
<h3 className="text-xl font-black text-slate-900">{t('aiGenerateTitle')}</h3></div>
<button onClick={() => setShowGenerate(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="p-8 space-y-5">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-purple-500" /> {t('generateCount')}</label>
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({ ...generateForm, count: parseInt(e.target.value) || 5 })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-purple-500" /> {t('knowledgeBaseContentOptional')}</label>
<textarea value={generateForm.knowledgeBaseContent} onChange={(e) => setGenerateForm({ ...generateForm, knowledgeBaseContent: e.target.value })}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all placeholder:text-slate-300"
placeholder={t('knowledgeBaseContentOptional')} rows={4} />
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-purple-500" /> {t('generateCount')}</label>
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
</div>
<p className="text-[10px] text-slate-400 px-1"></p>
<div className="flex gap-3 pt-4">
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
<button onClick={handleGenerate} disabled={generating}
className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
<button onClick={handleGenerate} disabled={generating} className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
{generating ? <><Loader2 size={16} className="animate-spin" /> {t('generating')}</> : <><Sparkles size={16} /> {t('generate')}</>}</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
</AnimatePresence>, document.body
)}
</div>
);
+1 -1
View File
@@ -155,7 +155,7 @@ export default function QuestionBankView({ isAdmin: _isAdmin }: QuestionBankView
};
return (
<div className="space-y-6">
<div className="space-y-6 overflow-y-auto h-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-slate-900">{t('questionBankManagement')}</h1>
+1 -1
View File
@@ -33,7 +33,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${token}`;
}
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null' && activeTenantId !== 'default') {
headers['x-tenant-id'] = activeTenantId;
}