Ver Fonte

追加评测结果删除功能

anhuiqiang há 1 semana atrás
pai
commit
a310ab3f27

+ 14 - 0
AGENTS.md

@@ -8,12 +8,18 @@ Simple Knowledge Base is a full-stack RAG (Retrieval-Augmented Generation) Q&A s
 
 **Key Features:**
 - Multi-model support (OpenAI-compatible APIs + Google Gemini native SDK)
+
 - Dual processing modes: Fast (Tika text-only) and High-precision (Vision pipeline)
+
 - User isolation with JWT authentication and per-user knowledge bases
+
 - Hybrid search (vector + keyword) with Elasticsearch
+
 - Multi-language interface (Japanese, Chinese, English)
+
 - Streaming responses via Server-Sent Events (SSE)
 
+
 ## Build Commands
 
 ### Root Workspace
@@ -135,6 +141,14 @@ try {
 - Use descriptive test names (describe blocks and it blocks)
 - Mock external dependencies (APIs, databases)
 
+## UX & UI Guidelines
+
+### Notifications & Confirmations
+
+- **No Native Dialogs**: Do not use native `alert()`, `confirm()`, or `prompt()`.
+- **Toast Notifications**: Use the `ToastContext` (`useToast` hook) for all notifications (success, error, info).
+- **Toast Confirmations**: Use `ConfirmContext` (`useConfirm` hook) for user confirmations. Confirmations should follow modern "toast-style" aesthetics (non-blocking, highly rounded, simplified UI) and be positioned centrally or as toasts depending on the context for a smooth experience.
+
 ## Project Architecture
 
 ### Directory Structure

+ 9 - 1
server/src/assessment/assessment.controller.ts

@@ -1,4 +1,4 @@
-import { Controller, Post, Body, Get, Param, UseGuards, Request, Sse, MessageEvent, Query } from '@nestjs/common';
+import { Controller, Post, Body, Get, Param, UseGuards, Request, Sse, MessageEvent, Query, Delete } from '@nestjs/common';
 import { map } from 'rxjs/operators';
 import { AssessmentService } from './assessment.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -70,4 +70,12 @@ export class AssessmentController {
         console.log(`[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`);
         return this.assessmentService.getHistory(userId, tenantId);
     }
+
+    @Delete(':id')
+    @ApiOperation({ summary: 'Delete an assessment session' })
+    async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
+        const { id: userId } = req.user;
+        console.log(`[AssessmentController] deleteSession: user=${userId}, session=${sessionId}`);
+        return this.assessmentService.deleteSession(sessionId, userId);
+    }
 }

+ 55 - 1
server/src/assessment/assessment.service.spec.ts

@@ -1,18 +1,72 @@
 import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
 import { AssessmentService } from './assessment.service';
+import { AssessmentSession } from './entities/assessment-session.entity';
+import { AssessmentQuestion } from './entities/assessment-question.entity';
+import { AssessmentAnswer } from './entities/assessment-answer.entity';
+import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
+import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import { ModelConfigService } from '../model-config/model-config.service';
+import { ConfigService } from '@nestjs/config';
+import { TemplateService } from './services/template.service';
+import { ContentFilterService } from './services/content-filter.service';
+import { RagService } from '../rag/rag.service';
+import { ChatService } from '../chat/chat.service';
+import { I18nService } from '../i18n/i18n.service';
+import { TenantService } from '../tenant/tenant.service';
+import { NotFoundException } from '@nestjs/common';
 
 describe('AssessmentService', () => {
   let service: AssessmentService;
+  let sessionRepository: any;
+
+  const mockRepository = () => ({
+    delete: jest.fn(),
+    find: jest.fn(),
+    findOne: jest.fn(),
+    save: jest.fn(),
+  });
+
+  const mockService = () => ({});
 
   beforeEach(async () => {
     const module: TestingModule = await Test.createTestingModule({
-      providers: [AssessmentService],
+      providers: [
+        AssessmentService,
+        { provide: getRepositoryToken(AssessmentSession), useFactory: mockRepository },
+        { provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
+        { provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
+        { provide: KnowledgeBaseService, useFactory: mockService },
+        { provide: KnowledgeGroupService, useFactory: mockService },
+        { provide: ModelConfigService, useFactory: mockService },
+        { provide: ConfigService, useFactory: mockService },
+        { provide: TemplateService, useFactory: mockService },
+        { provide: ContentFilterService, useFactory: mockService },
+        { provide: RagService, useFactory: mockService },
+        { provide: ChatService, useFactory: mockService },
+        { provide: I18nService, useFactory: mockService },
+        { provide: TenantService, useFactory: mockService },
+      ],
     }).compile();
 
     service = module.get<AssessmentService>(AssessmentService);
+    sessionRepository = module.get(getRepositoryToken(AssessmentSession));
   });
 
   it('should be defined', () => {
     expect(service).toBeDefined();
   });
+
+  describe('deleteSession', () => {
+    it('should delete a session if it exists and belongs to the user', async () => {
+      sessionRepository.delete.mockResolvedValue({ affected: 1 });
+      await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
+      expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
+    });
+
+    it('should throw NotFoundException if no session was affected', async () => {
+      sessionRepository.delete.mockResolvedValue({ affected: 0 });
+      await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
+    });
+  });
 });

+ 11 - 0
server/src/assessment/assessment.service.ts

@@ -593,6 +593,17 @@ export class AssessmentService {
         return mappedHistory;
     }
 
+    /**
+     * Deletes an assessment session.
+     */
+    async deleteSession(sessionId: string, userId: string): Promise<void> {
+        this.logger.log(`Deleting session ${sessionId} for user ${userId}`);
+        const result = await this.sessionRepository.delete({ id: sessionId, userId });
+        if (result.affected === 0) {
+            throw new NotFoundException('Session not found or you do not have permission to delete it');
+        }
+    }
+
     /**
      * Ensures the graph checkpointer has the state for the given session.
      * Useful for lazy initialization and recovery after server restarts.

+ 15 - 0
server/test_output.txt

@@ -0,0 +1,15 @@
+yarn run v1.22.22
+$ D:\aura\AuraK\node_modules\.bin\jest src/assessment/assessment.service.spec.ts --no-cache
+PASS src/assessment/assessment.service.spec.ts (5.812 s)
+  AssessmentService
+    √ should be defined (15 ms)
+    deleteSession
+      √ should delete a session if it exists and belongs to the user (4 ms)
+      √ should throw NotFoundException if no session was affected (14 ms)
+
+Test Suites: 1 passed, 1 total
+Tests:       3 passed, 3 total
+Snapshots:   0 total
+Time:        5.985 s
+Ran all test suites matching src/assessment/assessment.service.spec.ts.
+Done in 6.62s.

+ 14 - 12
web/components/ConfirmDialog.tsx

@@ -26,35 +26,37 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
     if (!isOpen) return null;
 
     return (
-        <div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
-            <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200">
-                <div className="flex justify-between items-center px-6 py-4 border-b">
-                    <h3 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
-                        <AlertCircle className="w-5 h-5 text-amber-500" />
+        <div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/10 backdrop-blur-[2px] animate-in fade-in duration-200">
+            <div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-sm overflow-hidden animate-in zoom-in duration-300 pointer-events-auto border border-white/40 ring-1 ring-black/5">
+                <div className="flex justify-between items-center px-10 pt-10 pb-4">
+                    <h3 className="text-base font-black text-slate-900 flex items-center gap-3">
+                        <div className="w-10 h-10 rounded-2xl bg-amber-50 flex items-center justify-center">
+                            <AlertCircle className="w-5 h-5 text-amber-500" />
+                        </div>
                         {title || t('confirmTitle') || 'Confirm'}
                     </h3>
                     <button
                         onClick={onCancel}
-                        className="text-slate-400 hover:text-slate-600 transition-colors"
+                        className="w-8 h-8 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-all"
                     >
-                        <X size={20} />
+                        <X size={18} />
                     </button>
                 </div>
 
-                <div className="px-6 py-8">
-                    <p className="text-slate-600 whitespace-pre-wrap">{message}</p>
+                <div className="px-10 py-6">
+                    <p className="text-sm font-bold text-slate-500 leading-relaxed whitespace-pre-wrap">{message}</p>
                 </div>
 
-                <div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
+                <div className="px-10 pb-10 pt-2 flex justify-end gap-3">
                     <button
                         onClick={onCancel}
-                        className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-xl transition-colors font-medium"
+                        className="flex-1 h-12 text-sm text-slate-600 hover:bg-slate-100 rounded-2xl transition-all font-black uppercase tracking-widest"
                     >
                         {cancelLabel || t('cancel') || 'Cancel'}
                     </button>
                     <button
                         onClick={onConfirm}
-                        className="px-6 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-colors font-medium shadow-md shadow-red-600/20"
+                        className="flex-1 h-12 text-sm bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all font-black shadow-lg shadow-indigo-600/20 active:scale-95 uppercase tracking-widest"
                     >
                         {confirmLabel || t('confirm') || 'Confirm'}
                     </button>

+ 65 - 31
web/components/views/AssessmentView.tsx

@@ -12,10 +12,12 @@ import {
     FileText,
     Star,
     Award,
-    Trophy
+    Trophy,
+    Trash2
 } from 'lucide-react';
 import { motion, AnimatePresence } from 'framer-motion';
 import { useLanguage } from '../../contexts/LanguageContext';
+import { useConfirm } from '../../contexts/ConfirmContext';
 import { assessmentService, AssessmentSession, AssessmentState } from '../../services/assessmentService';
 import { knowledgeGroupService } from '../../services/knowledgeGroupService';
 import { templateService } from '../../services/templateService';
@@ -34,6 +36,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
     isAdmin
 }) => {
     const { language, t } = useLanguage();
+    const { confirm } = useConfirm();
     const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
     const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
     const [session, setSession] = useState<AssessmentSession | null>(null);
@@ -119,6 +122,24 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
         }
     };
 
+    const handleDeleteHistory = async (e: React.MouseEvent, histId: string) => {
+        e.stopPropagation();
+        const confirmed = await confirm(t('confirmDeleteAssessment'));
+        if (!confirmed) return;
+
+        try {
+            await assessmentService.deleteSession(histId);
+            setHistory(prev => prev.filter(h => h.id !== histId));
+            if (session?.id === histId) {
+                setSession(null);
+                setState(null);
+            }
+        } catch (err: any) {
+            console.error('Failed to delete history:', err);
+            setError(t('deleteAssessmentFailed'));
+        }
+    };
+
     const handleStartAssessment = async () => {
         if (!selectedTemplate) return;
 
@@ -391,42 +412,55 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
                     </h3>
                     <div className="space-y-3 custom-scrollbar">
                         {history.map(hist => (
-                            <button
+                            <div
                                 key={hist.id}
-                                type="button"
-                                onClick={() => handleSelectHistory(hist)}
-                                disabled={isLoading}
-                                className={cn(
-                                    "w-full text-left p-4 rounded-2xl bg-slate-50 border border-slate-100 transition-all flex items-center justify-between group",
-                                    isLoading ? "opacity-50 cursor-not-allowed" : "hover:border-indigo-200 hover:bg-indigo-50/30 cursor-pointer"
-                                )}
+                                className="w-full text-left p-4 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between group"
                             >
-                                <div className="flex flex-col">
-                                    <span className="text-sm font-bold text-slate-800 truncate max-w-[180px]">
-                                        {hist.knowledgeBase?.name || hist.knowledgeGroup?.name || t('assessmentTitle')}
-                                    </span>
-                                    <div className="flex items-center gap-2 mt-1">
-                                        <span className="text-[10px] font-black text-indigo-400 px-1.5 py-0.5 bg-indigo-50 rounded">
-                                            {hist.finalScore !== null && hist.finalScore !== undefined ? `${Math.round(hist.finalScore * 10) / 10}/10` : t('inProgress')}
-                                        </span>
-                                        <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
-                                            {new Date(hist.createdAt).toLocaleDateString()}
+                                    <div className="flex flex-col">
+                                        <span className="text-sm font-bold text-slate-800 truncate max-w-[180px]">
+                                            {hist.knowledgeBase?.name || hist.knowledgeGroup?.name || t('assessmentTitle')}
                                         </span>
+                                        <div className="flex items-center gap-2 mt-1">
+                                            <span className="text-[10px] font-black text-indigo-400 px-1.5 py-0.5 bg-indigo-50 rounded">
+                                                {hist.finalScore !== null && hist.finalScore !== undefined ? `${Math.round(hist.finalScore * 10) / 10}/10` : t('inProgress')}
+                                            </span>
+                                            <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
+                                                {new Date(hist.createdAt).toLocaleDateString()}
+                                            </span>
+                                        </div>
+                                    </div>
+                                    <div className="flex items-center gap-2">
+                                        <button
+                                            type="button"
+                                            onClick={(e) => handleDeleteHistory(e, hist.id)}
+                                            className="w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center text-slate-400 hover:text-rose-600 hover:border-rose-100 transition-all opacity-0 group-hover:opacity-100"
+                                            title={t('delete')}
+                                        >
+                                            <Trash2 size={14} />
+                                        </button>
+                                        <button
+                                            type="button"
+                                            onClick={() => !isLoading && handleSelectHistory(hist)}
+                                            disabled={isLoading}
+                                            className={cn(
+                                                "w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center transition-all shrink-0",
+                                                isLoading ? "opacity-50 cursor-not-allowed" : "hover:bg-indigo-600 hover:text-white"
+                                            )}
+                                            title={t('view')}
+                                        >
+                                            {loadingHistoryId === hist.id ? (
+                                                <Loader2 size={14} className="animate-spin text-indigo-600 group-hover:text-white" />
+                                            ) : (
+                                                <FileText size={14} />
+                                            )}
+                                        </button>
                                     </div>
                                 </div>
-                                <div className="w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center transition-all group-hover:bg-indigo-600 group-hover:text-white shrink-0">
-                                    {loadingHistoryId === hist.id ? (
-                                        <Loader2 size={14} className="animate-spin text-indigo-600 group-hover:text-white" />
-                                    ) : (
-                                        <FileText size={14} />
-                                    )}
-                                </div>
-                            </button>
-                        ))}
+                            ))}
+                        </div>
                     </div>
-                </div>
-            )}
-        </div>
+                )}
+            </div>
     );
 
     const renderAssessment = () => {

+ 6 - 1
web/contexts/ToastContext.tsx

@@ -1,5 +1,6 @@
-import React, { createContext, useContext, useState, ReactNode } from 'react';
+import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
 import Toast, { ToastType } from '../components/Toast';
+import { registerToastHandler } from '../src/utils/toast';
 
 interface ToastItem {
   id: string;
@@ -45,6 +46,10 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
     });
   };
 
+  useEffect(() => {
+    registerToastHandler({ showToast });
+  }, []);
+
   const removeToast = (id: string) => {
     setToasts(prev => prev.filter(toast => toast.id !== id));
   };

+ 4 - 0
web/services/assessmentService.ts

@@ -47,6 +47,10 @@ export class AssessmentService {
         return data;
     }
 
+    async deleteSession(sessionId: string): Promise<void> {
+        await apiClient.delete(`/assessment/${sessionId}`);
+    }
+
     async *startSessionStream(sessionId: string, templateId?: string): AsyncIterableIterator<any> {
         const query = templateId ? `?templateId=${templateId}` : '';
         const response = await apiClient.request(`/assessment/${sessionId}/start-stream${query}`, {

+ 25 - 6
web/src/utils/toast.ts

@@ -1,12 +1,31 @@
 
+type ToastType = 'success' | 'error' | 'warning' | 'info';
+
+interface ToastHandler {
+  showToast: (type: ToastType, message: string, title?: string) => void;
+}
+
+let handler: ToastHandler | null = null;
+
+export const registerToastHandler = (h: ToastHandler) => {
+  handler = h;
+};
+
 export const toast = {
-  success: (message: string) => {
-    alert(`✅ ${message}`);
+  success: (message: string, title?: string) => {
+    if (handler) handler.showToast('success', message, title);
+    else console.log(`✅ ${message}`);
+  },
+  error: (message: string, title?: string) => {
+    if (handler) handler.showToast('error', message, title);
+    else console.log(`❌ ${message}`);
   },
-  error: (message: string) => {
-    alert(`❌ ${message}`);
+  info: (message: string, title?: string) => {
+    if (handler) handler.showToast('info', message, title);
+    else console.log(`ℹ️ ${message}`);
   },
-  info: (message: string) => {
-    alert(`ℹ️ ${message}`);
+  warning: (message: string, title?: string) => {
+    if (handler) handler.showToast('warning', message, title);
+    else console.log(`⚠️ ${message}`);
   }
 };

+ 16 - 0
web/utils/translations.ts

@@ -824,6 +824,10 @@ export const translations = {
     statusGeneratingReport: "正在生成最终报告...",
     statusProcessing: "正在处理...",
     filesAvailable: "文件可用",
+    confirmDeleteAssessment: "确定要删除这条评测记录吗?",
+    deleteAssessmentSuccess: "评测记录已成功删除",
+    deleteAssessmentFailed: '删除评估记录失败',
+    view: '查看',
 
     // Plugins
     pluginTitle: "插件中心",
@@ -1740,12 +1744,20 @@ export const translations = {
     statusGeneratingReport: "Generating final report...",
     statusProcessing: "Processing...",
     filesAvailable: "files available",
+    confirmDeleteAssessment: "Are you sure you want to delete this assessment record?",
+    deleteAssessmentSuccess: "Assessment record deleted successfully",
+    deleteAssessmentFailed: 'Failed to delete assessment record',
+    view: 'View',
 
     // Plugins
     pluginTitle: "Plugin Store",
     pluginDesc: "Extend the functionality of your knowledge base with external tools and services.",
     searchPlugin: "Search plugins...",
     installPlugin: "Install",
+    installedPlugin: "Installed",
+    updatePlugin: "Update Available",
+    pluginOfficial: "Official",
+    pluginCommunity: "Community",
     pluginBy: "By ",
     pluginConfig: "Plugin Config",
     pluginViewTitle: "Plugins",
@@ -2645,6 +2657,10 @@ export const translations = {
     statusGeneratingReport: "最終レポートを生成中...",
     statusProcessing: "処理中...",
     filesAvailable: "ファイル利用可能",
+    confirmDeleteAssessment: "この評価記録を削除してもよろしいですか?",
+    deleteAssessmentSuccess: "評価記録が正常に削除されました",
+    deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
+    view: '表示',
 
     // Plugins
     pluginTitle: "プラグインストア",