feat: 更新配置和文件上传逻辑, 迁移数据库结构(前端也得改

- 移除不再使用的 vite-plugin-monaco-editor
- 更新 package.json 和 vite.config.mts 文件
- 修改用户配置 API 逻辑,支持上传和下载配置
- 添加对文件上传的支持,优化文件处理逻辑
- 更新多个组件以支持新文件上传功能
- 删除不必要的 VTsuruTypes.ts 文件,整合到 VTsuruConfigTypes.ts 中
This commit is contained in:
2025-05-03 06:18:32 +08:00
parent 4ac793f155
commit 1f47703a8b
25 changed files with 1468 additions and 532 deletions

View File

@@ -1,3 +1,4 @@
import { UploadFileResponse } from '@/api/api-models';
import { VNode, h } from 'vue'; // 导入 Vue 的 VNode 类型和 h 函数(用于示例)
// --- 基础和通用类型 ---
@@ -31,6 +32,37 @@ type DataAccessor<T, V> = {
set: (config: T, value: V) => void;
};
// 添加辅助函数,用于从配置对象中安全获取数据
export function getConfigValue<T, K extends keyof T>(config: T, key: K): T[K] {
return config[key];
}
// 添加辅助函数,用于设置配置对象的数据
export function setConfigValue<T, K extends keyof T>(config: T, key: K, value: T[K]): void {
config[key] = value;
}
// 创建一个默认的RGBA颜色对象
export function createDefaultRGBA(r = 0, g = 0, b = 0, a = 1): RGBAColor {
return { r, g, b, a };
}
// 添加类型守卫函数,用于检查上传文件信息
export function isUploadFileInfo(obj: any): obj is UploadFileResponse {
return (
obj &&
typeof obj === 'object' &&
'id' in obj &&
typeof obj.id === 'number' &&
'path' in obj &&
typeof obj.path === 'string' &&
'name' in obj &&
typeof obj.name === 'string' &&
'hash' in obj &&
typeof obj.hash === 'string'
);
}
/**
* @description 'V'
* @template T - ( unknown)
@@ -117,22 +149,21 @@ export type TemplateConfigBooleanItem<T = unknown> = TemplateConfigItemWithType<
description?: string; // 可选的描述
};
// 修改 TemplateConfigImageItem 以支持单个或多个图片,并返回完整 URL
export type TemplateConfigImageItem<T = unknown> = TemplateConfigItemWithType<T, string[]> & {
type: 'image';
imageLimit: number; // 图片数量限制
// onUploaded 的 data 现在是 string[]
onUploaded?: (data: string[], config: T) => void;
};
// 将文件类型统一为数组不再根据fileLimit区分
export type TemplateConfigFileItem<T = unknown> =
TemplateConfigItemWithType<T, UploadFileResponse[]> & {
type: 'file';
fileLimit?: number; // 变为可选参数仅用于UI限制不影响类型
fileType?: string[];
onUploaded?: (data: UploadFileResponse[], config: T) => void;
};
// --- 新增:装饰性图片配置 ---
/**
* @description
*/
export interface DecorativeImageProperties {
id: string; // 唯一标识符 (例如 UUID 或时间戳)
src: string; // 图片 URL
export interface DecorativeImageProperties extends UploadFileResponse {
x: number; // X 坐标 (%)
y: number; // Y 坐标 (%)
width: number; // 宽度 (%)
@@ -208,16 +239,16 @@ export interface TemplateConfigRenderItem<T = unknown> extends TemplateConfigBas
/**
* @description
* 使 `<unknown>` T
* 使 `<any>` T
*/
export type ConfigItemDefinition =
| TemplateConfigStringItem<any>
| TemplateConfigNumberItem<any>
| TemplateConfigStringArrayItem<any>
| TemplateConfigNumberArrayItem<any>
| TemplateConfigImageItem<any>
| TemplateConfigRenderItem<any> // 包含优化后的 render/onUploaded 方法
| TemplateConfigDecorativeImagesItem<any> // 新增装饰图片类型
| TemplateConfigFileItem<any>
| TemplateConfigRenderItem<any>
| TemplateConfigDecorativeImagesItem<any>
| TemplateConfigSliderNumberItem<any>
| TemplateConfigBooleanItem<any>
| TemplateConfigColorItem<any>;
@@ -240,9 +271,10 @@ export type ExtractConfigData<
// 如果没有 default则根据 'type' 属性确定类型
: ItemWithKeyK extends { type: 'string'; } ? string
: ItemWithKeyK extends { type: 'stringArray'; } ? string[]
: ItemWithKeyK extends { type: 'number' | 'sliderNumber' | 'color'; } ? number
: ItemWithKeyK extends { type: 'number' | 'sliderNumber' ; } ? number
: ItemWithKeyK extends { type: 'numberArray'; } ? number[]
: ItemWithKeyK extends { type: 'image'; } ? string[]
// 文件类型统一处理为数组
: ItemWithKeyK extends { type: 'file'; } ? UploadFileResponse[]
: ItemWithKeyK extends { type: 'boolean'; } ? boolean
: ItemWithKeyK extends { type: 'color'; } ? RGBAColor
: ItemWithKeyK extends { type: 'decorativeImages'; } ? DecorativeImageProperties[]
@@ -253,22 +285,140 @@ export type ExtractConfigData<
: never // 如果 K 正确派生,则不应发生
};
// --- Key 约束辅助类型 ---
/**
* @description 'type' 'key'
* - type: 'file''decorativeImages'UploadFileInfo的类型的key必须以'File'
* - type的key禁止以'File'
* @template Item -
*/
type ConstrainedKeyItem<Item extends ConfigItemDefinition> =
// 所有包含UploadFileInfo的类型必须以'File'结尾
Item extends { type: 'file' } | { type: 'decorativeImages' }
// 强制key以'File'结尾
? Omit<Item, 'key'> & { key: `${string}File` }
: Item extends { key: infer K extends string }
// 对于其它类型检查key是否以'File'结尾
? K extends `${string}File`
// 如果以'File'结尾,则类型无效(never)导致TypeScript报错
? never
// 如果不以'File'结尾,则类型有效
: Item
// 如果Item没有key属性(理论上不应发生),保持原样
: Item;
/**
* @description
* 使 `const Items` `as const` 使
* key 'file' key 'File'
* @template Items - ConstrainedKeyItem
* @param items
* @returns
* @returns
*/
export function defineTemplateConfig<
const Items extends readonly ConfigItemDefinition[] // 使用 'const' 泛型进行推断
// 应用 ConstrainedKeyItem 约束到数组的每个元素
const Items extends readonly ConstrainedKeyItem<ConfigItemDefinition>[]
>(items: Items): Items {
// 如果需要,可以在此处添加基本的运行时验证。
// 类型检查主要由 TypeScript 根据约束完成。
return items;
// 可选的运行时验证,用于在浏览器控制台提供更友好的错误提示
items.forEach(item => {
// 类型守卫确保 item 有 key 和 type 属性
if ('key' in item && typeof item.key === 'string' && 'type' in item && typeof item.type === 'string') {
// 检查是否是需要File后缀的类型
const requiresFileSuffix = item.type === 'file' || item.type === 'decorativeImages';
if (requiresFileSuffix) {
if (!item.key.endsWith('File')) {
console.error(`类型错误: 配置项 "${item.key}" 类型为 '${item.type}' 但 key 未以 'File' 结尾。`);
}
} else {
if (item.key.endsWith('File')) {
console.error(`类型错误: 配置项 "${item.key}" 类型为 '${item.type}' 但 key 以 'File' 结尾。`);
}
}
}
});
return items;
}
// 帮助函数:将 RGBA 对象转换为 CSS 字符串
export function rgbaToString(color: RGBAColor | undefined): string {
if (!color) return 'rgba(0,0,0,0)'; // 或者一个默认颜色
return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
// --- 增强型工具类型 ---
/**
*
*/
export type NumericRange<Min extends number, Max extends number> =
number extends Min ? number :
number extends Max ? number :
Min | Max | Exclude<number, Min | Max>;
/**
*
*/
export type NonEmptyArray<T> = [T, ...T[]];
// --- 改进 rgbaToString 函数,添加更严格的类型检查 ---
export function rgbaToString(color: RGBAColor | undefined | null): string {
if (!color) return 'rgba(0,0,0,0)';
// 额外的类型安全检查
const r = Math.min(255, Math.max(0, Math.round(color.r)));
const g = Math.min(255, Math.max(0, Math.round(color.g)));
const b = Math.min(255, Math.max(0, Math.round(color.b)));
const a = Math.min(1, Math.max(0, color.a));
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
*
* @param items
* @returns
*/
export function createTemplateConfigFactory<const Items extends readonly ConstrainedKeyItem<ConfigItemDefinition>[]>(
items: Items
) {
// 返回一个工厂函数,用于创建初始化的配置对象
return (): ExtractConfigData<Items> => {
const config = {} as ExtractConfigData<Items>;
// 使用项定义中的默认值初始化配置对象
for (const item of items) {
if ('default' in item && item.default !== undefined) {
if (typeof config === 'object' && item.key) {
// @ts-ignore - 动态赋值
config[item.key] = item.default;
}
}
}
return config;
};
}
/**
*
*/
export type TemplateConfigValidator<T> = (config: T) => { valid: boolean; message?: string };
/**
*
* @param validator
* @returns
*/
export function createConfigValidator<T>(validator: TemplateConfigValidator<T>) {
return validator;
}
/**
* RGBA颜色
*/
export function isValidRGBAColor(obj: any): obj is RGBAColor {
return (
obj &&
typeof obj === 'object' &&
'r' in obj && typeof obj.r === 'number' &&
'g' in obj && typeof obj.g === 'number' &&
'b' in obj && typeof obj.b === 'number' &&
'a' in obj && typeof obj.a === 'number'
);
}

View File

@@ -63,7 +63,8 @@ export const FORUM_API_URL = BASE_API_URL + 'forum/';
export const USER_INDEX_API_URL = BASE_API_URL + 'user-index/';
export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/';
export const USER_CONFIG_API_URL = BASE_API_URL + 'user-config/';
export const FILE_API_URL = BASE_API_URL + 'files/';
export type TemplateMapType = {
[key: string]: {
name: string;
@@ -102,6 +103,14 @@ export const SongListTemplateMap: TemplateMapType = {
() => import('@/views/view/songListTemplate/DefaultSongListTemplate.vue')
))
},
traditional: {
name: '列表 (较推荐',
settingName: 'Template.SongList.Traditional',
component: markRaw(defineAsyncComponent(
() =>
import('@/views/view/songListTemplate/TraditionalSongListTemplate.vue')
))
},
simple: {
name: '简单',
//settingName: 'Template.SongList.Simple',
@@ -109,14 +118,6 @@ export const SongListTemplateMap: TemplateMapType = {
() => import('@/views/view/songListTemplate/SimpleSongListTemplate.vue')
))
},
traditional: {
name: '列表',
settingName: 'Template.SongList.Traditional',
component: markRaw(defineAsyncComponent(
() =>
import('@/views/view/songListTemplate/TraditionalSongListTemplate.vue')
))
}
};
export const IndexTemplateMap: TemplateMapType = {

92
src/data/fileUpload.ts Normal file
View File

@@ -0,0 +1,92 @@
import { UploadFileResponse, UserFileLocation, UserFileTypes } from '@/api/api-models';
import { QueryPostAPI } from '@/api/query';
import { FILE_API_URL } from '@/data/constants';
/**
* 文件上传阶段
*/
export enum UploadStage {
Preparing = "准备上传",
Uploading = "上传中",
Success = "上传成功",
Failed = "上传失败"
}
/**
* 上传文件
* @param files 要上传的文件列表
* @param type 文件类型,可选,不指定时自动判断
* @param location 存储位置,默认本地
* @param onProgress 上传进度回调,返回上传阶段名称
* @returns 上传结果列表
*/
export async function uploadFiles(
files: File | File[],
type?: UserFileTypes,
location: UserFileLocation = UserFileLocation.Local,
onProgress?: (stage: string) => void
): Promise<UploadFileResponse[]> {
try {
onProgress?.(UploadStage.Preparing);
const formData = new FormData();
// 支持单个文件或文件数组
if (Array.isArray(files)) {
files.forEach(file => {
formData.append('files', file);
});
} else {
formData.append('files', files);
}
if (type !== undefined) {
formData.append('type', type.toString());
}
formData.append('location', location.toString());
onProgress?.(UploadStage.Uploading);
const result = await QueryPostAPI<UploadFileResponse[]>(FILE_API_URL + 'upload', formData);
if (result.code === 200) {
onProgress?.(UploadStage.Success);
return result.data;
} else {
onProgress?.(UploadStage.Failed);
throw new Error(result.message || '上传失败');
}
} catch (error) {
onProgress?.(UploadStage.Failed);
console.error('文件上传错误:', error);
throw error;
}
}
/**
* 上传单个文件 (保留用于兼容性)
* @deprecated 请使用 uploadFiles 代替
*/
export async function uploadFile(
file: File,
type?: UserFileTypes,
location: UserFileLocation = UserFileLocation.Local,
onProgress?: (stage: string) => void
): Promise<UploadFileResponse> {
const results = await uploadFiles(file, type, location, onProgress);
return results[0]; // 返回第一个结果
}
/**
* 上传多个文件 (保留用于兼容性)
* @deprecated 请使用 uploadFiles 代替
*/
export async function uploadMultipleFiles(
files: File[],
type?: UserFileTypes,
location: UserFileLocation = UserFileLocation.Local,
onProgress?: (stage: string) => void
): Promise<UploadFileResponse[]> {
return uploadFiles(files, type, location, onProgress);
}