From 00f902f475e80030f03be833912ff9b0bd892079 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Tue, 24 Mar 2026 11:18:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E4=BB=A5=E5=8F=8A=E4=BF=AE=E6=94=B9oss=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=A1=B6=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 11 + .../2026-03-24-file-icon-theme-expansion.md | 82 +++++ front/src/components/ui/FileTypeIcon.tsx | 177 ++++++++++ front/src/lib/file-type.test.ts | 120 +++++++ front/src/lib/file-type.ts | 324 ++++++++++++++++++ front/src/pages/Files.tsx | 108 +++--- front/src/pages/Overview.tsx | 49 ++- front/src/pages/files-upload.test.ts | 64 ++++ front/src/pages/files-upload.ts | 29 +- memory.md | 6 + 10 files changed, 884 insertions(+), 86 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-24-file-icon-theme-expansion.md create mode 100644 front/src/components/ui/FileTypeIcon.tsx create mode 100644 front/src/lib/file-type.test.ts create mode 100644 front/src/lib/file-type.ts diff --git a/docs/architecture.md b/docs/architecture.md index 6f0012e..df95d89 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,6 +130,7 @@ - 文件元数据在数据库 - 文件内容走存储层抽象 - 支持本地磁盘和 OSS +- 当前线上网盘文件存储已切到阿里云 OSS 成都地域桶 `yoyuzh-files2` - 前端会缓存目录列表和最后访问路径 ### 3.3 快传模块 @@ -296,6 +297,13 @@ - 让文件元数据逻辑与底层存储解耦 - 上传、下载、复制、移动都通过统一抽象收口 +当前线上状态: + +- 生产环境文件桶已从东京地域迁到成都地域 `yoyuzh-files2` +- 生产后端当前使用 `https://oss-cn-chengdu.aliyuncs.com` 作为 OSS endpoint +- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连 OSS 下载”的主链路 +- 2026-03-24 已对抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶执行 HEAD 校验并返回 200 + ## 8. 部署架构 ### 8.1 前端 @@ -314,6 +322,9 @@ - 服务名:`my-site-api.service` - 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar` +- 额外配置文件:`/opt/yoyuzh/application-prod.yml` +- 环境变量文件:`/opt/yoyuzh/app.env` +- 2026-03-24 已把生产后端 OSS 配置切换到成都新桶 `yoyuzh-files2` ## 9. 开发注意事项 diff --git a/docs/superpowers/plans/2026-03-24-file-icon-theme-expansion.md b/docs/superpowers/plans/2026-03-24-file-icon-theme-expansion.md new file mode 100644 index 0000000..97cb1ed --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-file-icon-theme-expansion.md @@ -0,0 +1,82 @@ +# File Icon Theme Expansion Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 扩展网盘文件图标体系,让常见文件类型都有稳定映射,并把新的图标主题发布到前端 OSS。 + +**Architecture:** 把当前散落在 `front/src/pages/Files.tsx` 和 `front/src/pages/files-upload.ts` 里的类型判断收口到共享模块,再通过一个小型 UI 组件统一渲染图标、配色和类型文案。这样列表、网格、详情栏和上传进度面板都能复用同一套规则,避免后续继续分叉。 + +**Tech Stack:** React 19、TypeScript、Vite 6、lucide-react、Node test runner、OSS 发布脚本 + +--- + +### Task 1: 锁定文件类型映射行为 + +**Files:** +- Modify: `front/src/pages/files-upload.test.ts` + +- [ ] **Step 1: 写失败测试** + 覆盖 `xlsx`、`pptx`、`zip`、`mp4`、`mp3`、`fig`、`ttf`、`exe`、`epub`、`json` 等常见类型。 + +- [ ] **Step 2: 运行测试确认红灯** + +Run: `npm run test -- src/pages/files-upload.test.ts` + +Expected: 现有上传类型识别无法正确区分上述文件类型。 + +### Task 2: 收口共享文件类型模块 + +**Files:** +- Create: `front/src/lib/file-type.ts` +- Create: `front/src/lib/file-type.test.ts` + +- [ ] **Step 1: 写失败测试** + 覆盖网盘文件元数据在 `contentType + extension` 组合下的分组、标签和优先级。 + +- [ ] **Step 2: 运行测试确认红灯** + +Run: `npm run test -- src/lib/file-type.test.ts` + +Expected: 共享模块尚不存在或无法满足测试期望。 + +- [ ] **Step 3: 写最小实现** + 提供统一的 `resolveFileType` / `resolveStoredFileType` 能力,并产出主题所需的标签与类型标识。 + +- [ ] **Step 4: 运行测试确认绿灯** + +Run: `npm run test -- src/lib/file-type.test.ts src/pages/files-upload.test.ts` + +Expected: 两组测试全部通过。 + +### Task 3: 统一前端图标主题与页面使用 + +**Files:** +- Create: `front/src/components/ui/FileTypeIcon.tsx` +- Modify: `front/src/pages/Files.tsx` +- Modify: `front/src/pages/files-upload.ts` + +- [ ] **Step 1: 最小接入** + 让文件列表、网格卡片、详情栏、上传面板都使用统一图标主题组件或配置。 + +- [ ] **Step 2: 本地验证** + +Run: `npm run lint` + +Expected: TypeScript 校验通过。 + +- [ ] **Step 3: 构建验证** + +Run: `npm run build` + +Expected: Vite 生产构建成功。 + +### Task 4: 发布前端到 OSS + +**Files:** +- Use existing script: `scripts/deploy-front-oss.mjs` + +- [ ] **Step 1: 执行正式发布** + +Run: `node scripts/deploy-front-oss.mjs` + +Expected: `front/dist` 上传到配置好的 OSS 前缀并完成 SPA 别名文件更新。 diff --git a/front/src/components/ui/FileTypeIcon.tsx b/front/src/components/ui/FileTypeIcon.tsx new file mode 100644 index 0000000..453b061 --- /dev/null +++ b/front/src/components/ui/FileTypeIcon.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+ +
+ ); +} diff --git a/front/src/lib/file-type.test.ts b/front/src/lib/file-type.test.ts new file mode 100644 index 0000000..35545db --- /dev/null +++ b/front/src/lib/file-type.test.ts @@ -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: '电子书', + }, + ); +}); diff --git a/front/src/lib/file-type.ts b/front/src/lib/file-type.ts new file mode 100644 index 0000000..24ba5e8 --- /dev/null +++ b/front/src/lib/file-type.ts @@ -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 = { + 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, + }); +} diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index ab58e5f..a11b475 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -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; +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() { >
- {file.type === 'folder' ? ( - - ) : file.type === 'image' ? ( - - ) : ( - - )} + {file.name}
{file.modified} - {file.type} + + + {file.typeLabel} + + {file.size} -
- {file.type === 'folder' ? ( - - ) : file.type === 'image' ? ( - - ) : ( - - )} -
+ {file.name} - + + {file.typeLabel} + + {file.type === 'folder' ? file.modified : file.size} @@ -967,15 +969,7 @@ export default function Files() {
-
- {selectedFile.type === 'folder' ? ( - - ) : selectedFile.type === 'image' ? ( - - ) : ( - - )} -
+

{selectedFile.name}

@@ -983,7 +977,7 @@ export default function Files() { ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} /> - +
@@ -1090,18 +1084,26 @@ export default function Files() { )}
-
- {task.status === 'completed' ? ( - - ) : task.status === 'error' ? ( - - ) : ( - - )} -
+
-

{task.fileName}

-

上传至: {task.destination}

+
+

{task.fileName}

+
+ {task.status === 'completed' ? ( + + ) : task.status === 'error' ? ( + + ) : ( + + )} +
+
+
+ + {task.typeLabel} + + 上传至: {task.destination} +
{task.noticeMessage && (

{task.noticeMessage}

)} diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index a234d95..c82ddb4 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -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() {
{recentFiles.slice(0, 3).map((file, index) => ( -
navigate('/files')} - > -
-
- + (() => { + const fileType = resolveStoredFileType({ + filename: file.filename, + contentType: file.contentType, + directory: file.directory, + }); + + return ( +
navigate('/files')} + > +
+ +
+

{file.filename}

+
+ + {fileType.label} + +

{formatRecentTime(file.createdAt)}

+
+
+
+ + {file.directory ? '文件夹' : formatFileSize(file.size)} +
-
-

{file.filename}

-

{formatRecentTime(file.createdAt)}

-
-
- {formatFileSize(file.size)} -
+ ); + })() ))} {recentFiles.length === 0 ? (
diff --git a/front/src/pages/files-upload.test.ts b/front/src/pages/files-upload.test.ts index 2ebe315..78ea143 100644 --- a/front/src/pages/files-upload.test.ts +++ b/front/src/pages/files-upload.test.ts @@ -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'); diff --git a/front/src/pages/files-upload.ts b/front/src/pages/files-upload.ts index 9eeb6d8..8a5433c 100644 --- a/front/src/pages/files-upload.ts +++ b/front/src/pages/files-upload.ts @@ -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, }; } diff --git a/memory.md b/memory.md index 66875b0..9ef19fd 100644 --- a/memory.md +++ b/memory.md @@ -9,6 +9,7 @@ - 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制 - 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效 - 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`,并已重新发布 + - 线上后端文件存储已从旧东京 OSS 桶切换到成都新桶 `yoyuzh-files2`,并已完成对象级存在性验证 - 根目录 README 已重写为中文公开版 GitHub 风格 - VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor - 进行中: @@ -30,6 +31,7 @@ | 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 | | 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 | | 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 | +| 线上网盘文件桶切到成都 `yoyuzh-files2` | 现有普通文件下载主链路是浏览器直连 OSS,主要性能瓶颈在对象存储地域与公网链路 | 继续使用东京桶: 中国内地用户下载链路更长,难以直接改善速度 | ## 待解决问题 - [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误 @@ -47,8 +49,12 @@ - 已知线上后端服务名是 `my-site-api.service` - 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar` - 已知新服务器公网 IP 是 `1.14.49.201` +- 已知线上后端额外配置文件是 `/opt/yoyuzh/application-prod.yml`,环境变量文件是 `/opt/yoyuzh/app.env` +- 2026-03-24 已将线上 OSS 文件存储切换到 `https://oss-cn-chengdu.aliyuncs.com` + `yoyuzh-files2` +- 2026-03-24 已为线上配置文件创建备份:`/opt/yoyuzh/app.env.bak-before-chengdu`、`/opt/yoyuzh/application-prod.yml.bak-before-chengdu` - 2026-03-23 排障确认:`api.yoyuzh.xyz` 在部分网络下存在 TLS/SNI 握手异常,但后端服务与 nginx 正常,且 IP 直连加 `Host: api.yoyuzh.xyz` 时可正常返回 - 2026-03-23 实时日志确认:Mac 端 `202.202.9.243` 登录链路 `OPTIONS /api/auth/login -> POST /api/auth/login -> 后续 /api/*` 全部返回 200;手机失败时并不总能在服务端日志中看到对应登录请求 +- 2026-03-24 线上 smoke 验证:`https://api.yoyuzh.xyz/swagger-ui.html` 返回 302,`my-site-api.service` 重启后为 active;抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶 HEAD 返回 200 - 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出 ## 参考资料