修改前端图标以及修改oss存储桶位置

This commit is contained in:
yoyuzh
2026-03-24 11:18:51 +08:00
parent b9ab1a7640
commit 00f902f475
10 changed files with 884 additions and 86 deletions

View File

@@ -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. 开发注意事项

View File

@@ -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 别名文件更新。

View 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>
);
}

View 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
View 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,
});
}

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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');

View File

@@ -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,
};
}

View File

@@ -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`,不要把内容写进文档或对外输出
## 参考资料