修改前端图标以及修改oss存储桶位置
This commit is contained in:
177
front/src/components/ui/FileTypeIcon.tsx
Normal file
177
front/src/components/ui/FileTypeIcon.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
AppWindow,
|
||||
BookOpenText,
|
||||
Database,
|
||||
FileArchive,
|
||||
FileAudio2,
|
||||
FileBadge2,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideoCamera,
|
||||
Folder,
|
||||
Presentation,
|
||||
SwatchBook,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { FileTypeKind } from '@/src/lib/file-type';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
type FileTypeIconSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface FileTypeTheme {
|
||||
badgeClassName: string;
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
surfaceClassName: string;
|
||||
}
|
||||
|
||||
const FILE_TYPE_THEMES: Record<FileTypeKind, FileTypeTheme> = {
|
||||
folder: {
|
||||
icon: Folder,
|
||||
iconClassName: 'text-[#78A1FF]',
|
||||
surfaceClassName: 'border border-[#336EFF]/25 bg-[linear-gradient(135deg,rgba(51,110,255,0.24),rgba(15,23,42,0.2))] shadow-[0_16px_30px_-22px_rgba(51,110,255,0.95)]',
|
||||
badgeClassName: 'border border-[#336EFF]/20 bg-[#336EFF]/10 text-[#93B4FF]',
|
||||
},
|
||||
image: {
|
||||
icon: FileImage,
|
||||
iconClassName: 'text-cyan-300',
|
||||
surfaceClassName: 'border border-cyan-400/20 bg-[linear-gradient(135deg,rgba(34,211,238,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(34,211,238,0.8)]',
|
||||
badgeClassName: 'border border-cyan-400/15 bg-cyan-400/10 text-cyan-200',
|
||||
},
|
||||
pdf: {
|
||||
icon: FileBadge2,
|
||||
iconClassName: 'text-rose-300',
|
||||
surfaceClassName: 'border border-rose-400/20 bg-[linear-gradient(135deg,rgba(251,113,133,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,113,133,0.78)]',
|
||||
badgeClassName: 'border border-rose-400/15 bg-rose-400/10 text-rose-200',
|
||||
},
|
||||
word: {
|
||||
icon: FileText,
|
||||
iconClassName: 'text-sky-300',
|
||||
surfaceClassName: 'border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(56,189,248,0.8)]',
|
||||
badgeClassName: 'border border-sky-400/15 bg-sky-400/10 text-sky-200',
|
||||
},
|
||||
spreadsheet: {
|
||||
icon: FileSpreadsheet,
|
||||
iconClassName: 'text-emerald-300',
|
||||
surfaceClassName: 'border border-emerald-400/20 bg-[linear-gradient(135deg,rgba(52,211,153,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(52,211,153,0.82)]',
|
||||
badgeClassName: 'border border-emerald-400/15 bg-emerald-400/10 text-emerald-200',
|
||||
},
|
||||
presentation: {
|
||||
icon: Presentation,
|
||||
iconClassName: 'text-amber-300',
|
||||
surfaceClassName: 'border border-amber-400/20 bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,191,36,0.82)]',
|
||||
badgeClassName: 'border border-amber-400/15 bg-amber-400/10 text-amber-100',
|
||||
},
|
||||
archive: {
|
||||
icon: FileArchive,
|
||||
iconClassName: 'text-orange-300',
|
||||
surfaceClassName: 'border border-orange-400/20 bg-[linear-gradient(135deg,rgba(251,146,60,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,146,60,0.8)]',
|
||||
badgeClassName: 'border border-orange-400/15 bg-orange-400/10 text-orange-100',
|
||||
},
|
||||
video: {
|
||||
icon: FileVideoCamera,
|
||||
iconClassName: 'text-fuchsia-300',
|
||||
surfaceClassName: 'border border-fuchsia-400/20 bg-[linear-gradient(135deg,rgba(232,121,249,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(232,121,249,0.78)]',
|
||||
badgeClassName: 'border border-fuchsia-400/15 bg-fuchsia-400/10 text-fuchsia-100',
|
||||
},
|
||||
audio: {
|
||||
icon: FileAudio2,
|
||||
iconClassName: 'text-teal-300',
|
||||
surfaceClassName: 'border border-teal-400/20 bg-[linear-gradient(135deg,rgba(45,212,191,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(45,212,191,0.8)]',
|
||||
badgeClassName: 'border border-teal-400/15 bg-teal-400/10 text-teal-100',
|
||||
},
|
||||
design: {
|
||||
icon: SwatchBook,
|
||||
iconClassName: 'text-pink-300',
|
||||
surfaceClassName: 'border border-pink-400/20 bg-[linear-gradient(135deg,rgba(244,114,182,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(244,114,182,0.8)]',
|
||||
badgeClassName: 'border border-pink-400/15 bg-pink-400/10 text-pink-100',
|
||||
},
|
||||
font: {
|
||||
icon: Type,
|
||||
iconClassName: 'text-lime-300',
|
||||
surfaceClassName: 'border border-lime-400/20 bg-[linear-gradient(135deg,rgba(163,230,53,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(163,230,53,0.8)]',
|
||||
badgeClassName: 'border border-lime-400/15 bg-lime-400/10 text-lime-100',
|
||||
},
|
||||
application: {
|
||||
icon: AppWindow,
|
||||
iconClassName: 'text-violet-300',
|
||||
surfaceClassName: 'border border-violet-400/20 bg-[linear-gradient(135deg,rgba(167,139,250,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(167,139,250,0.82)]',
|
||||
badgeClassName: 'border border-violet-400/15 bg-violet-400/10 text-violet-100',
|
||||
},
|
||||
ebook: {
|
||||
icon: BookOpenText,
|
||||
iconClassName: 'text-yellow-200',
|
||||
surfaceClassName: 'border border-yellow-300/20 bg-[linear-gradient(135deg,rgba(253,224,71,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(253,224,71,0.7)]',
|
||||
badgeClassName: 'border border-yellow-300/15 bg-yellow-300/10 text-yellow-100',
|
||||
},
|
||||
code: {
|
||||
icon: FileCode2,
|
||||
iconClassName: 'text-cyan-200',
|
||||
surfaceClassName: 'border border-cyan-300/20 bg-[linear-gradient(135deg,rgba(103,232,249,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(103,232,249,0.72)]',
|
||||
badgeClassName: 'border border-cyan-300/15 bg-cyan-300/10 text-cyan-100',
|
||||
},
|
||||
text: {
|
||||
icon: FileText,
|
||||
iconClassName: 'text-slate-200',
|
||||
surfaceClassName: 'border border-slate-400/20 bg-[linear-gradient(135deg,rgba(148,163,184,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(148,163,184,0.55)]',
|
||||
badgeClassName: 'border border-slate-400/15 bg-slate-400/10 text-slate-200',
|
||||
},
|
||||
data: {
|
||||
icon: Database,
|
||||
iconClassName: 'text-indigo-300',
|
||||
surfaceClassName: 'border border-indigo-400/20 bg-[linear-gradient(135deg,rgba(129,140,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(129,140,248,0.8)]',
|
||||
badgeClassName: 'border border-indigo-400/15 bg-indigo-400/10 text-indigo-100',
|
||||
},
|
||||
document: {
|
||||
icon: FileText,
|
||||
iconClassName: 'text-slate-100',
|
||||
surfaceClassName: 'border border-white/10 bg-[linear-gradient(135deg,rgba(148,163,184,0.14),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(15,23,42,0.9)]',
|
||||
badgeClassName: 'border border-white/10 bg-white/10 text-slate-200',
|
||||
},
|
||||
};
|
||||
|
||||
const CONTAINER_SIZES: Record<FileTypeIconSize, string> = {
|
||||
sm: 'h-10 w-10 rounded-xl',
|
||||
md: 'h-12 w-12 rounded-2xl',
|
||||
lg: 'h-16 w-16 rounded-[1.35rem]',
|
||||
};
|
||||
|
||||
const ICON_SIZES: Record<FileTypeIconSize, string> = {
|
||||
sm: 'h-[18px] w-[18px]',
|
||||
md: 'h-[22px] w-[22px]',
|
||||
lg: 'h-8 w-8',
|
||||
};
|
||||
|
||||
export function getFileTypeTheme(type: FileTypeKind): FileTypeTheme {
|
||||
return FILE_TYPE_THEMES[type] ?? FILE_TYPE_THEMES.document;
|
||||
}
|
||||
|
||||
export function FileTypeIcon({
|
||||
type,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
type: FileTypeKind;
|
||||
size?: FileTypeIconSize;
|
||||
className?: string;
|
||||
}) {
|
||||
const theme = getFileTypeTheme(type);
|
||||
const Icon = theme.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center backdrop-blur-sm',
|
||||
CONTAINER_SIZES[size],
|
||||
theme.surfaceClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(ICON_SIZES[size], theme.iconClassName)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
front/src/lib/file-type.test.ts
Normal file
120
front/src/lib/file-type.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveFileType, resolveStoredFileType } from './file-type';
|
||||
|
||||
test('resolveFileType maps common extensions and content types to richer groups', () => {
|
||||
assert.deepEqual(
|
||||
resolveFileType({
|
||||
fileName: '预算.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
{
|
||||
extension: 'xlsx',
|
||||
kind: 'spreadsheet',
|
||||
label: '表格',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveFileType({
|
||||
fileName: '发布会.key',
|
||||
contentType: 'application/vnd.apple.keynote',
|
||||
}),
|
||||
{
|
||||
extension: 'key',
|
||||
kind: 'presentation',
|
||||
label: '演示文稿',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveFileType({
|
||||
fileName: '首页.fig',
|
||||
contentType: 'application/octet-stream',
|
||||
}),
|
||||
{
|
||||
extension: 'fig',
|
||||
kind: 'design',
|
||||
label: '设计稿',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveFileType({
|
||||
fileName: 'Brand.woff2',
|
||||
contentType: 'font/woff2',
|
||||
}),
|
||||
{
|
||||
extension: 'woff2',
|
||||
kind: 'font',
|
||||
label: '字体',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveFileType({
|
||||
fileName: 'README',
|
||||
contentType: 'text/plain',
|
||||
}),
|
||||
{
|
||||
extension: '',
|
||||
kind: 'text',
|
||||
label: '文本',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveStoredFileType prioritizes folders and content types for listed files', () => {
|
||||
assert.deepEqual(
|
||||
resolveStoredFileType({
|
||||
filename: '素材库',
|
||||
contentType: null,
|
||||
directory: true,
|
||||
}),
|
||||
{
|
||||
extension: '',
|
||||
kind: 'folder',
|
||||
label: '文件夹',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveStoredFileType({
|
||||
filename: '封面',
|
||||
contentType: 'image/webp',
|
||||
directory: false,
|
||||
}),
|
||||
{
|
||||
extension: '',
|
||||
kind: 'image',
|
||||
label: '图片',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveStoredFileType({
|
||||
filename: 'episode.mkv',
|
||||
contentType: 'video/x-matroska',
|
||||
directory: false,
|
||||
}),
|
||||
{
|
||||
extension: 'mkv',
|
||||
kind: 'video',
|
||||
label: '视频',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveStoredFileType({
|
||||
filename: 'manual.epub',
|
||||
contentType: 'application/epub+zip',
|
||||
directory: false,
|
||||
}),
|
||||
{
|
||||
extension: 'epub',
|
||||
kind: 'ebook',
|
||||
label: '电子书',
|
||||
},
|
||||
);
|
||||
});
|
||||
324
front/src/lib/file-type.ts
Normal file
324
front/src/lib/file-type.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
export type FileTypeKind =
|
||||
| 'folder'
|
||||
| 'image'
|
||||
| 'pdf'
|
||||
| 'word'
|
||||
| 'spreadsheet'
|
||||
| 'presentation'
|
||||
| 'archive'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'design'
|
||||
| 'font'
|
||||
| 'application'
|
||||
| 'ebook'
|
||||
| 'code'
|
||||
| 'text'
|
||||
| 'data'
|
||||
| 'document';
|
||||
|
||||
export interface FileTypeDescriptor {
|
||||
extension: string;
|
||||
kind: FileTypeKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ResolveFileTypeOptions {
|
||||
fileName: string;
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
interface ResolveStoredFileTypeOptions {
|
||||
filename: string;
|
||||
contentType?: string | null;
|
||||
directory: boolean;
|
||||
}
|
||||
|
||||
const FILE_TYPE_LABELS: Record<FileTypeKind, string> = {
|
||||
folder: '文件夹',
|
||||
image: '图片',
|
||||
pdf: 'PDF',
|
||||
word: '文档',
|
||||
spreadsheet: '表格',
|
||||
presentation: '演示文稿',
|
||||
archive: '压缩包',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
design: '设计稿',
|
||||
font: '字体',
|
||||
application: '应用包',
|
||||
ebook: '电子书',
|
||||
code: '代码',
|
||||
text: '文本',
|
||||
data: '数据',
|
||||
document: '文件',
|
||||
};
|
||||
|
||||
const WORD_EXTENSIONS = new Set(['doc', 'docx', 'odt', 'pages', 'rtf']);
|
||||
const SPREADSHEET_EXTENSIONS = new Set(['csv', 'numbers', 'ods', 'tsv', 'xls', 'xlsx']);
|
||||
const PRESENTATION_EXTENSIONS = new Set(['key', 'odp', 'ppt', 'pptx']);
|
||||
const IMAGE_EXTENSIONS = new Set(['avif', 'bmp', 'dng', 'gif', 'heic', 'heif', 'ico', 'jpeg', 'jpg', 'png', 'raw', 'svg', 'tif', 'tiff', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['avi', 'flv', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'rm', 'rmvb', 'ts', 'webm', 'wmv']);
|
||||
const AUDIO_EXTENSIONS = new Set(['aac', 'aiff', 'amr', 'flac', 'm4a', 'mp3', 'ogg', 'opus', 'wav', 'wma']);
|
||||
const ARCHIVE_EXTENSIONS = new Set(['7z', 'bz2', 'dmg', 'gz', 'iso', 'rar', 'tar', 'tgz', 'xz', 'zip', 'zst']);
|
||||
const DESIGN_EXTENSIONS = new Set(['ai', 'eps', 'fig', 'indd', 'psd', 'sketch', 'xd']);
|
||||
const FONT_EXTENSIONS = new Set(['eot', 'otf', 'ttf', 'woff', 'woff2']);
|
||||
const APPLICATION_EXTENSIONS = new Set(['apk', 'appimage', 'crx', 'deb', 'exe', 'ipa', 'jar', 'msi', 'pkg', 'rpm', 'war']);
|
||||
const EBOOK_EXTENSIONS = new Set(['azw', 'azw3', 'chm', 'epub', 'mobi']);
|
||||
const DATA_EXTENSIONS = new Set(['arrow', 'db', 'db3', 'feather', 'parquet', 'sqlite', 'sqlite3']);
|
||||
const TEXT_EXTENSIONS = new Set(['md', 'markdown', 'rst', 'txt']);
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'bash',
|
||||
'bat',
|
||||
'c',
|
||||
'cc',
|
||||
'cmd',
|
||||
'conf',
|
||||
'cpp',
|
||||
'css',
|
||||
'cxx',
|
||||
'env',
|
||||
'fish',
|
||||
'gitignore',
|
||||
'go',
|
||||
'h',
|
||||
'hpp',
|
||||
'htm',
|
||||
'html',
|
||||
'ini',
|
||||
'java',
|
||||
'js',
|
||||
'json',
|
||||
'jsonc',
|
||||
'jsx',
|
||||
'kt',
|
||||
'kts',
|
||||
'less',
|
||||
'log',
|
||||
'lua',
|
||||
'm',
|
||||
'mm',
|
||||
'php',
|
||||
'ps1',
|
||||
'py',
|
||||
'rb',
|
||||
'rs',
|
||||
'sass',
|
||||
'scss',
|
||||
'sh',
|
||||
'sql',
|
||||
'svelte',
|
||||
'swift',
|
||||
'toml',
|
||||
'ts',
|
||||
'tsx',
|
||||
'vue',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'zsh',
|
||||
]);
|
||||
|
||||
function getFileExtension(fileName: string) {
|
||||
const normalized = fileName.trim().split('/').at(-1) ?? '';
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lastDotIndex = normalized.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < normalized.length - 1) {
|
||||
return normalized.slice(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
if (lastDotIndex === 0 && normalized.length > 1) {
|
||||
return normalized.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildDescriptor(extension: string, kind: FileTypeKind): FileTypeDescriptor {
|
||||
return {
|
||||
extension,
|
||||
kind,
|
||||
label: FILE_TYPE_LABELS[kind],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveKindFromContentType(contentType: string | null | undefined, extension: string): FileTypeKind | null {
|
||||
if (!contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = contentType.toLowerCase();
|
||||
|
||||
if (normalized.includes('pdf')) {
|
||||
return 'pdf';
|
||||
}
|
||||
if (
|
||||
normalized.includes('photoshop')
|
||||
|| normalized.includes('illustrator')
|
||||
|| normalized.includes('figma')
|
||||
|| normalized.includes('indesign')
|
||||
|| normalized.includes('postscript')
|
||||
) {
|
||||
return 'design';
|
||||
}
|
||||
if (
|
||||
normalized.includes('wordprocessingml')
|
||||
|| normalized.includes('msword')
|
||||
|| normalized.includes('opendocument.text')
|
||||
|| normalized.includes('rtf')
|
||||
) {
|
||||
return 'word';
|
||||
}
|
||||
if (
|
||||
normalized.includes('spreadsheetml')
|
||||
|| normalized.includes('ms-excel')
|
||||
|| normalized.includes('opendocument.spreadsheet')
|
||||
|| normalized.includes('/csv')
|
||||
|| normalized.includes('comma-separated-values')
|
||||
) {
|
||||
return 'spreadsheet';
|
||||
}
|
||||
if (
|
||||
normalized.includes('presentationml')
|
||||
|| normalized.includes('ms-powerpoint')
|
||||
|| normalized.includes('opendocument.presentation')
|
||||
|| normalized.includes('keynote')
|
||||
) {
|
||||
return 'presentation';
|
||||
}
|
||||
if (normalized.includes('epub') || normalized.includes('mobipocket') || normalized.includes('kindle')) {
|
||||
return 'ebook';
|
||||
}
|
||||
if (normalized.startsWith('font/')) {
|
||||
return 'font';
|
||||
}
|
||||
if (
|
||||
normalized.includes('android.package-archive')
|
||||
|| normalized.includes('portable-executable')
|
||||
|| normalized.includes('java-archive')
|
||||
|| normalized.includes('msi')
|
||||
|| normalized.includes('appimage')
|
||||
|| normalized.includes('x-debian-package')
|
||||
|| normalized.includes('x-rpm')
|
||||
) {
|
||||
return 'application';
|
||||
}
|
||||
if (
|
||||
normalized.includes('zip')
|
||||
|| normalized.includes('compressed')
|
||||
|| normalized.includes('archive')
|
||||
|| normalized.includes('tar')
|
||||
|| normalized.includes('rar')
|
||||
|| normalized.includes('7z')
|
||||
|| normalized.includes('gzip')
|
||||
|| normalized.includes('bzip2')
|
||||
|| normalized.includes('xz')
|
||||
|| normalized.includes('diskimage')
|
||||
|| normalized.includes('iso9660')
|
||||
) {
|
||||
return 'archive';
|
||||
}
|
||||
if (normalized.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
if (normalized.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (normalized.startsWith('image/')) {
|
||||
return DESIGN_EXTENSIONS.has(extension) ? 'design' : 'image';
|
||||
}
|
||||
if (normalized.includes('sqlite') || normalized.includes('database') || normalized.includes('parquet') || normalized.includes('arrow')) {
|
||||
return 'data';
|
||||
}
|
||||
if (
|
||||
normalized.includes('json')
|
||||
|| normalized.includes('javascript')
|
||||
|| normalized.includes('typescript')
|
||||
|| normalized.includes('xml')
|
||||
|| normalized.includes('yaml')
|
||||
|| normalized.includes('toml')
|
||||
|| normalized.includes('x-sh')
|
||||
|| normalized.includes('shellscript')
|
||||
) {
|
||||
return 'code';
|
||||
}
|
||||
if (normalized.startsWith('text/')) {
|
||||
return CODE_EXTENSIONS.has(extension) ? 'code' : 'text';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveKindFromExtension(extension: string): FileTypeKind {
|
||||
if (!extension) {
|
||||
return 'document';
|
||||
}
|
||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
if (extension === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
if (WORD_EXTENSIONS.has(extension)) {
|
||||
return 'word';
|
||||
}
|
||||
if (SPREADSHEET_EXTENSIONS.has(extension)) {
|
||||
return 'spreadsheet';
|
||||
}
|
||||
if (PRESENTATION_EXTENSIONS.has(extension)) {
|
||||
return 'presentation';
|
||||
}
|
||||
if (ARCHIVE_EXTENSIONS.has(extension)) {
|
||||
return 'archive';
|
||||
}
|
||||
if (VIDEO_EXTENSIONS.has(extension)) {
|
||||
return 'video';
|
||||
}
|
||||
if (AUDIO_EXTENSIONS.has(extension)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (DESIGN_EXTENSIONS.has(extension)) {
|
||||
return 'design';
|
||||
}
|
||||
if (FONT_EXTENSIONS.has(extension)) {
|
||||
return 'font';
|
||||
}
|
||||
if (APPLICATION_EXTENSIONS.has(extension)) {
|
||||
return 'application';
|
||||
}
|
||||
if (EBOOK_EXTENSIONS.has(extension)) {
|
||||
return 'ebook';
|
||||
}
|
||||
if (DATA_EXTENSIONS.has(extension)) {
|
||||
return 'data';
|
||||
}
|
||||
if (CODE_EXTENSIONS.has(extension)) {
|
||||
return 'code';
|
||||
}
|
||||
if (TEXT_EXTENSIONS.has(extension)) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function resolveFileType({ fileName, contentType }: ResolveFileTypeOptions): FileTypeDescriptor {
|
||||
const extension = getFileExtension(fileName);
|
||||
const kind = resolveKindFromContentType(contentType, extension) ?? resolveKindFromExtension(extension);
|
||||
|
||||
return buildDescriptor(extension, kind);
|
||||
}
|
||||
|
||||
export function resolveStoredFileType({ filename, contentType, directory }: ResolveStoredFileTypeOptions): FileTypeDescriptor {
|
||||
if (directory) {
|
||||
return buildDescriptor('', 'folder');
|
||||
}
|
||||
|
||||
return resolveFileType({
|
||||
fileName: filename,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
@@ -28,10 +26,12 @@ import {
|
||||
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
||||
import { Input } from '@/src/components/ui/input';
|
||||
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
|
||||
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
|
||||
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
|
||||
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
|
||||
@@ -147,27 +147,31 @@ function formatDateTime(value: string) {
|
||||
}
|
||||
|
||||
function toUiFile(file: FileMetadata) {
|
||||
const extension = file.filename.includes('.') ? file.filename.split('.').pop()?.toLowerCase() : '';
|
||||
let type = extension || 'document';
|
||||
|
||||
if (file.directory) {
|
||||
type = 'folder';
|
||||
} else if (file.contentType?.startsWith('image/')) {
|
||||
type = 'image';
|
||||
} else if (file.contentType?.includes('pdf')) {
|
||||
type = 'pdf';
|
||||
}
|
||||
const resolvedType = resolveStoredFileType({
|
||||
filename: file.filename,
|
||||
contentType: file.contentType,
|
||||
directory: file.directory,
|
||||
});
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.filename,
|
||||
type,
|
||||
type: resolvedType.kind,
|
||||
typeLabel: resolvedType.label,
|
||||
size: file.directory ? '—' : formatFileSize(file.size),
|
||||
modified: formatDateTime(file.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
type UiFile = ReturnType<typeof toUiFile>;
|
||||
interface UiFile {
|
||||
id: FileMetadata['id'];
|
||||
modified: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: FileTypeKind;
|
||||
typeLabel: string;
|
||||
}
|
||||
|
||||
type NetdiskTargetAction = 'move' | 'copy';
|
||||
|
||||
export default function Files() {
|
||||
@@ -854,20 +858,23 @@ export default function Files() {
|
||||
>
|
||||
<td className="py-3 pl-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{file.type === 'folder' ? (
|
||||
<Folder className="w-5 h-5 text-[#336EFF]" />
|
||||
) : file.type === 'image' ? (
|
||||
<ImageIcon className="w-5 h-5 text-purple-400" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
)}
|
||||
<FileTypeIcon type={file.type} size="sm" />
|
||||
<span className={cn('text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-sm text-slate-400 hidden md:table-cell">{file.modified}</td>
|
||||
<td className="py-3 text-sm text-slate-400 hidden lg:table-cell uppercase">{file.type}</td>
|
||||
<td className="py-3 text-sm text-slate-400 hidden lg:table-cell">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium tracking-wide',
|
||||
getFileTypeTheme(file.type).badgeClassName,
|
||||
)}
|
||||
>
|
||||
{file.typeLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<FileActionMenu
|
||||
@@ -916,20 +923,15 @@ export default function Files() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5 transition-colors group-hover:bg-white/10">
|
||||
{file.type === 'folder' ? (
|
||||
<Folder className="w-8 h-8 text-[#336EFF]" />
|
||||
) : file.type === 'image' ? (
|
||||
<ImageIcon className="w-8 h-8 text-purple-400" />
|
||||
) : (
|
||||
<FileText className="w-8 h-8 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<FileTypeIcon type={file.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
|
||||
|
||||
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-slate-500">
|
||||
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>
|
||||
{file.typeLabel}
|
||||
</span>
|
||||
<span className="mt-2 text-xs text-slate-500">
|
||||
{file.type === 'folder' ? file.modified : file.size}
|
||||
</span>
|
||||
</div>
|
||||
@@ -967,15 +969,7 @@ export default function Files() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#336EFF]/10 flex items-center justify-center">
|
||||
{selectedFile.type === 'folder' ? (
|
||||
<Folder className="w-8 h-8 text-[#336EFF]" />
|
||||
) : selectedFile.type === 'image' ? (
|
||||
<ImageIcon className="w-8 h-8 text-purple-400" />
|
||||
) : (
|
||||
<FileText className="w-8 h-8 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<FileTypeIcon type={selectedFile.type} size="lg" />
|
||||
<h3 className="text-sm font-medium text-white break-all">{selectedFile.name}</h3>
|
||||
</div>
|
||||
|
||||
@@ -983,7 +977,7 @@ export default function Files() {
|
||||
<DetailItem label="位置" value={`网盘 > ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
|
||||
<DetailItem label="大小" value={selectedFile.size} />
|
||||
<DetailItem label="修改时间" value={selectedFile.modified} />
|
||||
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
|
||||
<DetailItem label="类型" value={selectedFile.typeLabel} />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-3 border-t border-white/10">
|
||||
@@ -1090,18 +1084,26 @@ export default function Files() {
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
) : task.status === 'error' ? (
|
||||
<TriangleAlert className="h-5 w-5 text-rose-400" />
|
||||
) : (
|
||||
<FileUp className="h-5 w-5 animate-pulse text-[#336EFF]" />
|
||||
)}
|
||||
</div>
|
||||
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-200">{task.fileName}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">上传至: {task.destination}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="truncate text-sm font-medium text-slate-200">{task.fileName}</p>
|
||||
<div className="shrink-0">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
|
||||
) : task.status === 'error' ? (
|
||||
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
|
||||
) : (
|
||||
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
|
||||
{task.typeLabel}
|
||||
</span>
|
||||
<span className="truncate text-slate-500">上传至: {task.destination}</span>
|
||||
</div>
|
||||
{task.noticeMessage && (
|
||||
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
|
||||
)}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
||||
import { apiDownload, apiRequest } from '@/src/lib/api';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { resolveStoredFileType } from '@/src/lib/file-type';
|
||||
import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
||||
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
||||
@@ -249,22 +251,39 @@ export default function Overview() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{recentFiles.slice(0, 3).map((file, index) => (
|
||||
<div
|
||||
key={`${file.id}-${index}`}
|
||||
className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group"
|
||||
onClick={() => navigate('/files')}
|
||||
>
|
||||
<div className="flex items-center gap-4 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#336EFF]/10 flex items-center justify-center shrink-0 group-hover:bg-[#336EFF]/20 transition-colors">
|
||||
<FileText className="w-5 h-5 text-[#336EFF]" />
|
||||
(() => {
|
||||
const fileType = resolveStoredFileType({
|
||||
filename: file.filename,
|
||||
contentType: file.contentType,
|
||||
directory: file.directory,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${file.id}-${index}`}
|
||||
className="flex items-center justify-between rounded-xl p-3 transition-colors cursor-pointer group hover:bg-white/5"
|
||||
onClick={() => navigate('/files')}
|
||||
>
|
||||
<div className="flex items-center gap-4 overflow-hidden">
|
||||
<FileTypeIcon type={fileType.kind} size="sm" className="group-hover:scale-[1.03] transition-transform duration-200" />
|
||||
<div className="min-w-0 truncate">
|
||||
<p className="truncate text-sm font-medium text-white">{file.filename}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-[11px] font-medium ${getFileTypeTheme(fileType.kind).badgeClassName}`}
|
||||
>
|
||||
{fileType.label}
|
||||
</span>
|
||||
<p className="text-xs text-slate-400">{formatRecentTime(file.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-4 shrink-0 text-xs font-mono text-slate-500">
|
||||
{file.directory ? '文件夹' : formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="text-sm font-medium text-white truncate">{file.filename}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{formatRecentTime(file.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{recentFiles.length === 0 ? (
|
||||
<div className="p-3 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
|
||||
|
||||
@@ -24,6 +24,70 @@ test('createUploadTask uses current path as upload destination', () => {
|
||||
assert.equal(task.speed, '等待上传...');
|
||||
});
|
||||
|
||||
test('createUploadTask classifies common file families beyond images and office basics', () => {
|
||||
const spreadsheetTask = createUploadTask(
|
||||
new File(['sheet'], '预算.xlsx', {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),
|
||||
[],
|
||||
'task-sheet',
|
||||
);
|
||||
const presentationTask = createUploadTask(
|
||||
new File(['slides'], '发布会.pptx', {type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'}),
|
||||
[],
|
||||
'task-slides',
|
||||
);
|
||||
const archiveTask = createUploadTask(
|
||||
new File(['archive'], '素材包.zip', {type: 'application/zip'}),
|
||||
[],
|
||||
'task-archive',
|
||||
);
|
||||
const videoTask = createUploadTask(
|
||||
new File(['video'], '演示.mp4', {type: 'video/mp4'}),
|
||||
[],
|
||||
'task-video',
|
||||
);
|
||||
const audioTask = createUploadTask(
|
||||
new File(['audio'], '片头.mp3', {type: 'audio/mpeg'}),
|
||||
[],
|
||||
'task-audio',
|
||||
);
|
||||
const designTask = createUploadTask(
|
||||
new File(['design'], '首页.fig', {type: 'application/octet-stream'}),
|
||||
[],
|
||||
'task-design',
|
||||
);
|
||||
const fontTask = createUploadTask(
|
||||
new File(['font'], 'Brand.woff2', {type: 'font/woff2'}),
|
||||
[],
|
||||
'task-font',
|
||||
);
|
||||
const appTask = createUploadTask(
|
||||
new File(['binary'], 'installer.exe', {type: 'application/vnd.microsoft.portable-executable'}),
|
||||
[],
|
||||
'task-app',
|
||||
);
|
||||
const ebookTask = createUploadTask(
|
||||
new File(['ebook'], '小说.epub', {type: 'application/epub+zip'}),
|
||||
[],
|
||||
'task-ebook',
|
||||
);
|
||||
const codeTask = createUploadTask(
|
||||
new File(['json'], 'manifest.json', {type: 'application/json'}),
|
||||
[],
|
||||
'task-code',
|
||||
);
|
||||
|
||||
assert.equal(spreadsheetTask.type, 'spreadsheet');
|
||||
assert.equal(presentationTask.type, 'presentation');
|
||||
assert.equal(archiveTask.type, 'archive');
|
||||
assert.equal(videoTask.type, 'video');
|
||||
assert.equal(audioTask.type, 'audio');
|
||||
assert.equal(designTask.type, 'design');
|
||||
assert.equal(fontTask.type, 'font');
|
||||
assert.equal(appTask.type, 'application');
|
||||
assert.equal(ebookTask.type, 'ebook');
|
||||
assert.equal(codeTask.type, 'code');
|
||||
});
|
||||
|
||||
test('formatTransferSpeed chooses a readable unit', () => {
|
||||
assert.equal(formatTransferSpeed(800), '800 B/s');
|
||||
assert.equal(formatTransferSpeed(2048), '2.0 KB/s');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getNextAvailableName } from './files-state';
|
||||
import { resolveFileType, type FileTypeKind } from '@/src/lib/file-type';
|
||||
|
||||
export type UploadTaskStatus = 'uploading' | 'completed' | 'error';
|
||||
|
||||
@@ -9,7 +10,8 @@ export interface UploadTask {
|
||||
speed: string;
|
||||
destination: string;
|
||||
status: UploadTaskStatus;
|
||||
type: string;
|
||||
type: FileTypeKind;
|
||||
typeLabel: string;
|
||||
errorMessage?: string;
|
||||
noticeMessage?: string;
|
||||
}
|
||||
@@ -28,22 +30,10 @@ export interface PendingUploadEntry {
|
||||
}
|
||||
|
||||
function getUploadType(file: File) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : '';
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (file.type.includes('pdf') || extension === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
if (extension === 'doc' || extension === 'docx') {
|
||||
return 'word';
|
||||
}
|
||||
if (extension === 'xls' || extension === 'xlsx' || extension === 'csv') {
|
||||
return 'excel';
|
||||
}
|
||||
|
||||
return extension || 'document';
|
||||
return resolveFileType({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
});
|
||||
}
|
||||
|
||||
function createTaskId() {
|
||||
@@ -174,6 +164,8 @@ export function createUploadTask(
|
||||
taskId: string = createTaskId(),
|
||||
noticeMessage?: string,
|
||||
): UploadTask {
|
||||
const fileType = getUploadType(file);
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
fileName: file.name,
|
||||
@@ -181,7 +173,8 @@ export function createUploadTask(
|
||||
speed: '等待上传...',
|
||||
destination: getUploadDestination(pathParts),
|
||||
status: 'uploading',
|
||||
type: getUploadType(file),
|
||||
type: fileType.kind,
|
||||
typeLabel: fileType.label,
|
||||
noticeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user