Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
pnpm run test:question-generation
pnpm run test:interview-capacity
pnpm run test:interview-entry
pnpm run test:knowledge-base-upload

- name: Build frontend
run: pnpm run build
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"test:interview-history": "node --test src/pages/interviewHistoryStats.test.ts",
"test:question-generation": "node --test src/pages/questionGenerationStatus.test.ts",
"test:interview-capacity": "node --test src/components/knowledgebaseInterview/interviewCapacity.test.ts",
"test:interview-entry": "node --test src/pages/interviewEntry.test.ts"
"test:interview-entry": "node --test src/pages/interviewEntry.test.ts",
"test:knowledge-base-upload": "node --test src/pages/knowledgeBaseBatchUpload.test.ts"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
Expand Down
8 changes: 1 addition & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate, useOu
import Layout from './components/Layout';
import { useEffect, useState, Suspense, lazy } from 'react';
import { historyApi, type InterviewDetail } from './api/history';
import type { UploadKnowledgeBaseResponse } from './api/knowledgebase';
import type { Difficulty } from './components/UnifiedInterviewModal';
import type { CategoryDTO } from './api/skill';
import { Loader2 } from 'lucide-react';
Expand Down Expand Up @@ -426,16 +425,11 @@ function KnowledgeBaseQueryPageWrapper() {
function KnowledgeBaseUploadPageWrapper() {
const navigate = useNavigate();

const handleUploadComplete = (_result: UploadKnowledgeBaseResponse) => {
// 上传完成后返回管理页面
navigate('/knowledgebase');
};

const handleBack = () => {
navigate('/knowledgebase');
};

return <KnowledgeBaseUploadPage onUploadComplete={handleUploadComplete} onBack={handleBack} />;
return <KnowledgeBaseUploadPage onBack={handleBack} />;
}

// 语音面试页面包装器
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState, type ChangeEvent, type DragEvent } from 'react';
import { AlertCircle, Upload } from 'lucide-react';

interface KnowledgeBaseUploadDropzoneProps {
full: boolean;
notice: string;
onFilesSelected: (files: FileList) => void;
}

export default function KnowledgeBaseUploadDropzone({
full,
notice,
onFilesSelected,
}: KnowledgeBaseUploadDropzoneProps) {
const [dragOver, setDragOver] = useState(false);

const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
onFilesSelected(event.target.files);
}
event.target.value = '';
};

const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setDragOver(false);
if (!full) {
onFilesSelected(event.dataTransfer.files);
}
};

return (
<>
<label
htmlFor="knowledge-base-files"
className={`block rounded-2xl border-2 border-dashed p-10 text-center transition-colors ${
full
? 'cursor-not-allowed border-slate-200 bg-slate-50 opacity-70 dark:border-slate-700 dark:bg-slate-800/60'
: dragOver
? 'cursor-pointer border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'cursor-pointer border-slate-300 bg-white hover:border-primary-400 dark:border-slate-600 dark:bg-slate-800'
}`}
onDragOver={(event) => {
event.preventDefault();
if (!full) setDragOver(true);
}}
onDragLeave={(event) => {
event.preventDefault();
setDragOver(false);
}}
onDrop={handleDrop}
>
<input
id="knowledge-base-files"
className="hidden"
type="file"
accept=".pdf,.doc,.docx,.txt,.md"
multiple
disabled={full}
onChange={handleFileChange}
/>
<Upload className={`mx-auto mb-4 h-12 w-12 ${dragOver ? 'text-primary-500' : 'text-slate-400'}`} />
<p className="mb-2 text-lg font-semibold text-slate-800 dark:text-slate-100">
点击选择或拖拽多个文件到这里
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
支持 PDF、DOCX、DOC、TXT、MD,单个文件最大 50MB;上传期间仍可继续添加
</p>
</label>

{notice && (
<div className="mt-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="break-words">{notice}</span>
</div>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {
AlertCircle,
CheckCircle2,
Clock3,
FileText,
Loader2,
RefreshCw,
X,
} from 'lucide-react';

import {
MAX_BATCH_FILES,
type BatchUploadItem,
type BatchUploadStatus,
} from '../../pages/knowledgeBaseBatchUpload';

interface KnowledgeBaseUploadListProps {
items: BatchUploadItem[];
completedCount: number;
failedCount: number;
hasUploadActivity: boolean;
revectorizingId: number | null;
onClear: () => void;
onNameChange: (clientId: string, customName: string) => void;
onRemove: (clientId: string) => void;
onRetryUpload: (clientId: string) => void;
onRevectorize: (clientId: string) => void;
}

function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function StatusBadge({ status }: { status: BatchUploadStatus }) {
const styles = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium';

switch (status) {
case 'READY':
return (
<span className={`${styles} bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300`}>
<Clock3 className="h-3.5 w-3.5" />待提交
</span>
);
case 'UPLOADING':
return (
<span className={`${styles} bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300`}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />上传中
</span>
);
case 'PENDING':
return (
<span className={`${styles} bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300`}>
<Clock3 className="h-3.5 w-3.5" />等待向量化
</span>
);
case 'PROCESSING':
return (
<span className={`${styles} bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300`}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />向量化中
</span>
);
case 'COMPLETED':
return (
<span className={`${styles} bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300`}>
<CheckCircle2 className="h-3.5 w-3.5" />已完成
</span>
);
case 'UPLOAD_FAILED':
return (
<span className={`${styles} bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-300`}>
<AlertCircle className="h-3.5 w-3.5" />上传失败
</span>
);
case 'VECTOR_FAILED':
return (
<span className={`${styles} bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-300`}>
<AlertCircle className="h-3.5 w-3.5" />向量化失败
</span>
);
case 'QUEUED':
return (
<span className={`${styles} bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300`}>
<Clock3 className="h-3.5 w-3.5" />等待上传
</span>
);
}
}

export default function KnowledgeBaseUploadList({
items,
completedCount,
failedCount,
hasUploadActivity,
revectorizingId,
onClear,
onNameChange,
onRemove,
onRetryUpload,
onRevectorize,
}: KnowledgeBaseUploadListProps) {
if (items.length === 0) return null;

return (
<section className="mt-6 overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-slate-800 dark:shadow-slate-900/50">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-6 py-4 dark:border-slate-700">
<div>
<h2 className="font-semibold text-slate-900 dark:text-white">
文件列表({items.length}/{MAX_BATCH_FILES})
</h2>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" aria-live="polite">
已完成 {completedCount} 个{failedCount > 0 ? `,失败 ${failedCount} 个` : ''}
</p>
</div>
<button
type="button"
disabled={hasUploadActivity}
onClick={onClear}
className="text-sm text-slate-500 transition-colors hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400"
>
清空列表
</button>
</div>

<div className="divide-y divide-slate-100 dark:divide-slate-700">
{items.map(item => {
const canEdit = item.status === 'READY' || item.status === 'UPLOAD_FAILED';
const retryCoolingDown = Boolean(item.retryAvailableAt);
return (
<div key={item.clientId} className="p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start">
<div className="flex min-w-0 flex-1 items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-500 dark:bg-primary-900/30">
<FileText className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="max-w-full truncate font-medium text-slate-900 dark:text-white">
{item.file.name}
</p>
{item.duplicate && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500 dark:bg-slate-700 dark:text-slate-300">
已存在
</span>
)}
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{formatFileSize(item.file.size)}
</p>
<input
type="text"
value={item.customName}
disabled={!canEdit}
onChange={event => onNameChange(item.clientId, event.target.value)}
placeholder="知识库名称(可选,默认使用文件名)"
className="mt-3 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 disabled:bg-slate-50 disabled:text-slate-500 dark:border-slate-600 dark:bg-slate-700 dark:text-white dark:focus:ring-primary-900/40 dark:disabled:bg-slate-800"
/>
</div>
</div>

<div className="flex shrink-0 items-center justify-between gap-2 md:justify-end">
<StatusBadge status={item.status} />
{item.status === 'UPLOAD_FAILED' && (
<button
type="button"
disabled={retryCoolingDown}
onClick={() => onRetryUpload(item.clientId)}
className="rounded-lg p-2 text-slate-400 transition hover:bg-primary-50 hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-primary-900/30"
title={retryCoolingDown ? '2 秒后可重试' : '重新上传'}
>
<RefreshCw className="h-4 w-4" />
</button>
)}
{item.status === 'VECTOR_FAILED' && item.knowledgeBaseId && (
<button
type="button"
disabled={revectorizingId !== null}
onClick={() => onRevectorize(item.clientId)}
className="rounded-lg p-2 text-slate-400 transition hover:bg-primary-50 hover:text-primary-500 disabled:opacity-50 dark:hover:bg-primary-900/30"
title="重新向量化"
>
<RefreshCw className={`h-4 w-4 ${
revectorizingId === item.knowledgeBaseId ? 'animate-spin' : ''
}`} />
</button>
)}
{canEdit && (
<button
type="button"
onClick={() => onRemove(item.clientId)}
className="rounded-lg p-2 text-slate-400 transition hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/30"
title="移除文件"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>

{item.error && (
<div className="ml-0 mt-3 flex items-start gap-2 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-300 md:ml-[52px]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="break-words">{item.error}</span>
</div>
)}
</div>
);
})}
</div>
</section>
);
}
Loading