Compare commits

..

3 Commits

Author SHA1 Message Date
fe5b420d49 feat: 更新QuestionItem和QuestionBoxView组件
- 在QuestionItem.vue中移除垂直间距,替换为NDivider以优化布局
- 在QuestionBoxView.vue中修改问题输入框的占位符,更新提示区域逻辑,调整问题内容卡片样式
2025-05-03 07:14:14 +08:00
5ec2babc44 feat: 优化ViewerLayout和QuestionBoxView组件
- 在ViewerLayout.vue中添加过渡模式以增强动画效果
- 更新QuestionBoxView.vue,调整问题头部间距,优化图片展示逻辑,修改上传图片网格样式,提升用户体验
2025-05-03 07:08:20 +08:00
1f47703a8b feat: 更新配置和文件上传逻辑, 迁移数据库结构(前端也得改
- 移除不再使用的 vite-plugin-monaco-editor
- 更新 package.json 和 vite.config.mts 文件
- 修改用户配置 API 逻辑,支持上传和下载配置
- 添加对文件上传的支持,优化文件处理逻辑
- 更新多个组件以支持新文件上传功能
- 删除不必要的 VTsuruTypes.ts 文件,整合到 VTsuruConfigTypes.ts 中
2025-05-03 06:18:32 +08:00
26 changed files with 1431 additions and 561 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -73,7 +73,6 @@
"unplugin-vue-markdown": "^28.3.1", "unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "6.3.3", "vite": "6.3.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-oxlint": "^1.3.1", "vite-plugin-oxlint": "^1.3.1",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "3.5.13", "vue": "3.5.13",

View File

@@ -1,5 +1,5 @@
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query'; import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query';
import { ACCOUNT_API_URL, VTSURU_API_URL } from '@/data/constants'; import { ACCOUNT_API_URL, USER_CONFIG_API_URL, VTSURU_API_URL } from '@/data/constants';
import { isSameDay } from 'date-fns'; import { isSameDay } from 'date-fns';
import { createDiscreteApi } from 'naive-ui'; import { createDiscreteApi } from 'naive-ui';
import { ref } from 'vue'; import { ref } from 'vue';
@@ -184,7 +184,7 @@ export async function DelBlackList(id: number): Promise<APIRoot<unknown>> {
}); });
} }
export function downloadConfigDirect(name: string) { export function downloadConfigDirect(name: string) {
return QueryGetAPI<string>(VTSURU_API_URL + 'get-config', { return QueryGetAPI<string>(USER_CONFIG_API_URL + 'get', {
name: name name: name
}); });
} }
@@ -202,7 +202,7 @@ export async function DownloadConfig<T>(name: string, id?: number): Promise<
} }
> { > {
try { try {
const resp = await QueryGetAPI<string>(VTSURU_API_URL + (id ? 'get-user-config' : 'get-config'), { const resp = await QueryGetAPI<string>(USER_CONFIG_API_URL + (id ? 'user-get' : 'get'), {
name: name, name: name,
id: id id: id
}); });
@@ -237,11 +237,12 @@ export async function DownloadConfig<T>(name: string, id?: number): Promise<
}; };
} }
} }
export async function UploadConfig(name: string, data: unknown) { export async function UploadConfig(name: string, data: unknown, isPublic: boolean = false) {
try { try {
const resp = await QueryPostAPI(VTSURU_API_URL + 'set-config', { const resp = await QueryPostAPI(USER_CONFIG_API_URL + 'set', {
name: name, name: name,
json: JSON.stringify(data) json: JSON.stringify(data),
public: isPublic
}); });
if (resp.code == 200) { if (resp.code == 200) {
console.log('已保存配置文件至服务器:' + name); console.log('已保存配置文件至服务器:' + name);
@@ -256,7 +257,7 @@ export async function UploadConfig(name: string, data: unknown) {
} }
export async function GetConfigHash(name: string) { export async function GetConfigHash(name: string) {
try { try {
const resp = await QueryGetAPI<string>(VTSURU_API_URL + 'get-config-hash', { const resp = await QueryGetAPI<string>(USER_CONFIG_API_URL + 'hash', {
name: name name: name
}); });
if (resp.code == 200) { if (resp.code == 200) {

View File

@@ -41,6 +41,7 @@ export interface UserInfo extends UserBasicInfo {
templateTypes: { [key: string]: string } templateTypes: { [key: string]: string }
streamerInfo?: StreamerModel streamerInfo?: StreamerModel
allowCheckInRanking?: boolean // 是否允许查看签到排行 allowCheckInRanking?: boolean // 是否允许查看签到排行
allowQuestionBoxUploadImage?: boolean // 是否允许问题箱上传图片
} }
} }
export interface EventFetcherStateModel { export interface EventFetcherStateModel {
@@ -119,8 +120,8 @@ export enum SaftyLevels {
} }
export interface Setting_QuestionBox { export interface Setting_QuestionBox {
allowUnregistedUser: boolean allowUnregistedUser: boolean
saftyLevel: SaftyLevels saftyLevel: SaftyLevels
allowImageUpload: boolean
} }
export interface UserSetting { export interface UserSetting {
sendEmail: Setting_SendEmail sendEmail: Setting_SendEmail
@@ -371,8 +372,8 @@ export interface QAInfo {
id: number id: number
sender: UserBasicInfo sender: UserBasicInfo
target: UserBasicInfo target: UserBasicInfo
question: { message: string; image?: string } question: { message: string; }
answer?: { message: string; image?: string, createdAt: number } answer?: { message: string; createdAt: number }
isReaded?: boolean isReaded?: boolean
isSenderRegisted: boolean isSenderRegisted: boolean
isPublic: boolean isPublic: boolean
@@ -380,6 +381,9 @@ export interface QAInfo {
sendAt: number sendAt: number
isAnonymous: boolean isAnonymous: boolean
answerImages?: UploadFileResponse[]
questionImages?: UploadFileResponse[]
tag?: string tag?: string
reviewResult?: QAReviewInfo reviewResult?: QAReviewInfo
} }
@@ -683,7 +687,7 @@ export interface ResponsePointGoodModel {
count?: number count?: number
price: number price: number
tags: string[] tags: string[]
cover?: string cover?: UploadFileResponse
images: string[] images: string[]
status: GoodsStatus status: GoodsStatus
type: GoodsTypes type: GoodsTypes
@@ -702,17 +706,13 @@ export interface ResponsePointGoodModel {
keySelectionMode?: KeySelectionMode keySelectionMode?: KeySelectionMode
currentKeyIndex?: number currentKeyIndex?: number
} }
export interface ImageUploadModel {
existImages: string[]
newImagesBase64: string[]
}
export interface UploadPointGoodsModel { export interface UploadPointGoodsModel {
id?: number id?: number
name: string name: string
count?: number count?: number
price: number price: number
tags: string[] tags: string[]
cover?: ImageUploadModel cover?: UploadFileResponse
status: GoodsStatus status: GoodsStatus
type: GoodsTypes type: GoodsTypes
collectUrl?: string collectUrl?: string
@@ -844,3 +844,42 @@ export interface CheckInResult {
consecutiveDays: number consecutiveDays: number
todayRank: number todayRank: number
} }
/**
* 文件类型枚举
*/
export enum UserFileTypes {
Image = 0,
Audio = 1,
Video = 2,
Document = 3,
Other = 4
}
/**
* 文件存储位置枚举
*/
export enum UserFileLocation {
Local = 0
}
/**
* 文件上传响应接口
*/
export interface UploadFileResponse {
id: number;
path: string;
name: string;
hash: string;
}
/**
* 扩展的文件信息接口,用于文件上传组件
*/
export interface ExtendedUploadFileInfo {
id: string; // 文件唯一标识符
name: string; // 文件名称
status: 'uploading' | 'finished' | 'error' | 'removed'; // 上传状态
thumbnailUrl?: string; // 缩略图URL
file?: File; // 可选的文件对象
}

View File

@@ -7,13 +7,14 @@ import { cookie } from './account';
export async function QueryPostAPI<T>( export async function QueryPostAPI<T>(
urlString: string, urlString: string,
body?: unknown, body?: unknown,
headers?: [string, string][] headers?: [string, string][],
contentType?: string
): Promise<APIRoot<T>> { ): Promise<APIRoot<T>> {
return await QueryPostAPIWithParams<T>( return await QueryPostAPIWithParams<T>(
urlString, urlString,
undefined, undefined,
body, body,
'application/json', contentType || 'application/json',
headers headers
) )
} }
@@ -59,11 +60,15 @@ async function QueryPostAPIWithParamsInternal<T>(
}) })
if (cookie.value.cookie) h['Authorization'] = `Bearer ${cookie.value.cookie}` if (cookie.value.cookie) h['Authorization'] = `Bearer ${cookie.value.cookie}`
h['Content-Type'] = contentType // 当使用FormData时不手动设置Content-Type让浏览器自动添加boundary
if (!(body instanceof FormData)) {
h['Content-Type'] = contentType
}
return await QueryAPIInternal<T>(url, { return await QueryAPIInternal<T>(url, {
method: 'post', method: 'post',
headers: h, headers: h,
body: typeof body === 'string' ? body : JSON.stringify(body) body: body instanceof FormData ? body : typeof body === 'string' ? body : JSON.stringify(body)
}) })
} }
async function QueryAPIInternal<T>(url: URL, init: RequestInit) { async function QueryAPIInternal<T>(url: URL, init: RequestInit) {

1
src/components.d.ts vendored
View File

@@ -34,6 +34,7 @@ declare module 'vue' {
NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime'] NTime: typeof import('naive-ui')['NTime']

View File

@@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { getImageUploadModel } from '@/Utils'; import { UploadConfig } from '@/api/account';
import { QueryPostAPI } from '@/api/query'; import { UploadFileResponse, UserFileLocation } from '@/api/api-models';
import { ConfigItemDefinition, DecorativeImageProperties, TemplateConfigImageItem, RGBAColor, rgbaToString } from '@/data/VTsuruTypes'; import { ConfigItemDefinition, DecorativeImageProperties, RGBAColor, rgbaToString } from '@/data/VTsuruConfigTypes';
import { FILE_BASE_URL, VTSURU_API_URL } from '@/data/constants'; import { uploadFiles, UploadStage } from '@/data/fileUpload';
import { ArrowDown20Filled, ArrowUp20Filled, Delete20Filled } from '@vicons/fluent'; import { ArrowDown20Filled, ArrowUp20Filled, Delete20Filled, Info24Filled } from '@vicons/fluent';
import { Info24Filled } from '@vicons/fluent'; import { NButton, NCard, NCheckbox, NColorPicker, NEmpty, NFlex, NForm, NGrid, NIcon, NInput, NInputNumber, NModal, NProgress, NScrollbar, NSlider, NSpace, NText, NTooltip, NUpload, UploadFileInfo, useMessage } from 'naive-ui';
import { NButton, NCard, NCheckbox, NColorPicker, NEmpty, NFlex, NForm, NGrid, NIcon, NInput, NInputNumber, NScrollbar, NSlider, NSpace, NTooltip, NUpload, UploadFileInfo, useMessage } from 'naive-ui'; import { h, onMounted, ref } from 'vue';
import { h } from 'vue';
import { onMounted, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
const message = useMessage(); const message = useMessage();
@@ -19,7 +16,19 @@ import { v4 as uuidv4 } from 'uuid';
}>(); }>();
const fileList = ref<{ [key: string]: UploadFileInfo[]; }>({}); const fileList = ref<{ [key: string]: UploadFileInfo[]; }>({});
const selectedImageId = ref<string | null>(null); // 新增实际文件列表,用于存储待上传的文件
const pendingFiles = ref<{ [key: string]: File[]; }>({});
// 新增装饰图片待上传文件
const pendingDecorativeImages = ref<{ [key: string]: File[]; }>({});
// 上传进度相关
const showUploadModal = ref(false);
const uploadStage = ref('');
const uploadProgress = ref(0);
const totalFilesToUpload = ref(0);
const uploadedFilesCount = ref(0);
const selectedImageId = ref<number | null>(null);
const isUploading = ref(false); const isUploading = ref(false);
@@ -29,31 +38,166 @@ import { v4 as uuidv4 } from 'uuid';
if ((file.file?.size ?? 0) > 10 * 1024 * 1024) { if ((file.file?.size ?? 0) > 10 * 1024 * 1024) {
message.error('文件大小不能超过10MB'); message.error('文件大小不能超过10MB');
fileList.value[key] = []; fileList.value[key] = [];
return;
}
// 存储文件以便于稍后上传
if (file.file) {
if (!pendingFiles.value[key]) {
pendingFiles.value[key] = [];
}
pendingFiles.value[key].push(file.file);
} }
} }
} }
// 更新上传进度的函数
function updateUploadProgress(stage: string, fileIndex?: number, totalFiles?: number) {
uploadStage.value = stage;
if (totalFiles !== undefined) {
totalFilesToUpload.value = totalFiles;
}
if (fileIndex !== undefined) {
uploadedFilesCount.value = fileIndex;
uploadProgress.value = Math.floor((fileIndex / totalFilesToUpload.value) * 100);
}
}
async function uploadAllFiles() {
const allPendingFiles: File[] = [];
// 计算待上传的文件总数
for (const key in pendingFiles.value) {
if (pendingFiles.value[key]?.length > 0) {
allPendingFiles.push(...pendingFiles.value[key]);
}
}
for (const key in pendingDecorativeImages.value) {
if (pendingDecorativeImages.value[key]?.length > 0) {
allPendingFiles.push(...pendingDecorativeImages.value[key]);
}
}
// 如果没有文件需要上传,直接返回
if (allPendingFiles.length === 0) {
return true;
}
// 显示上传模态框
totalFilesToUpload.value = allPendingFiles.length;
uploadedFilesCount.value = 0;
uploadProgress.value = 0;
showUploadModal.value = true;
const uploadTasks = [];
let fileCounter = 0;
// 上传普通文件
for (const key in pendingFiles.value) {
if (pendingFiles.value[key]?.length > 0) {
const filesToUpload = pendingFiles.value[key];
uploadTasks.push(
uploadFiles(
filesToUpload,
undefined,
UserFileLocation.Local,
(stage) => {
updateUploadProgress(stage, fileCounter + filesToUpload.length, totalFilesToUpload.value);
if (stage === UploadStage.Success) {
fileCounter += filesToUpload.length;
} else if (stage === UploadStage.Failed) {
message.error(`${key} 文件上传失败`);
}
}
).then(results => {
// 更新配置数据
props.configData[key] = results;
})
);
}
}
// 上传装饰图片
for (const key in pendingDecorativeImages.value) {
if (pendingDecorativeImages.value[key]?.length > 0) {
const filesToUpload = pendingDecorativeImages.value[key];
uploadTasks.push(
uploadFiles(
filesToUpload,
undefined,
UserFileLocation.Local,
(stage) => {
updateUploadProgress(stage, fileCounter + filesToUpload.length, totalFilesToUpload.value);
if (stage === UploadStage.Success) {
fileCounter += filesToUpload.length;
} else if (stage === UploadStage.Failed) {
message.error(`装饰图片上传失败`);
}
}
).then(results => {
// 创建新的装饰图片对象并添加到现有数组中
const newImages: DecorativeImageProperties[] = results.map((result, index) => ({
id: Number(result.id),
path: result.path,
name: result.name,
hash: result.hash,
src: result.path,
x: 10 + index * 5,
y: 10 + index * 5,
width: 20,
rotation: 0,
opacity: 1,
zIndex: (props.configData[key]?.length ?? 0) + index + 1,
}));
const currentImages = props.configData[key] as DecorativeImageProperties[] || [];
props.configData[key] = [...currentImages, ...newImages];
})
);
}
}
// 等待所有上传任务完成
try {
await Promise.all(uploadTasks);
// 完成上传,关闭模态框
updateUploadProgress(UploadStage.Success, totalFilesToUpload.value, totalFilesToUpload.value);
setTimeout(() => {
showUploadModal.value = false;
}, 500); // 给用户一个短暂的视觉反馈,然后关闭模态框
// 清空待上传文件
pendingFiles.value = {};
pendingDecorativeImages.value = {};
return true;
} catch (error) {
console.error("文件上传失败:", error);
message.error("文件上传失败: " + (error instanceof Error ? error.message : String(error)));
updateUploadProgress(UploadStage.Failed);
setTimeout(() => {
showUploadModal.value = false;
}, 2000); // 错误状态多显示一会儿
return false;
}
}
async function onSubmit() { async function onSubmit() {
try { try {
isUploading.value = true; isUploading.value = true;
let images = {} as {
[key: string]: { // 先上传所有文件
existImages: string[], const uploadSuccess = await uploadAllFiles();
newImagesBase64: string[], if (!uploadSuccess) {
}; isUploading.value = false;
}; return;
for (const item of props.config!) {
if (item.type == 'image') {
const key = (item as TemplateConfigImageItem<any>).key;
images[key] = await getImageUploadModel(fileList.value[key]);
}
} }
const resp = await QueryPostAPI<any>(VTSURU_API_URL + 'set-config', {
name: props.name, const success = await UploadConfig(props.name || '', props.configData, true);
json: JSON.stringify(props.configData),
images: images, if (success) {
public: 'true',
});
if (resp.code == 200) {
message.success('已保存设置'); message.success('已保存设置');
props.config?.forEach(item => { props.config?.forEach(item => {
if (item.type === 'render') { if (item.type === 'render') {
@@ -64,7 +208,7 @@ import { v4 as uuidv4 } from 'uuid';
} }
}); });
} else { } else {
message.error('保存失败: ' + resp.message); message.error('保存失败');
} }
} catch (err) { } catch (err) {
message.error('保存失败: ' + err); message.error('保存失败: ' + err);
@@ -149,7 +293,7 @@ import { v4 as uuidv4 } from 'uuid';
} }
// 装饰图片功能 // 装饰图片功能
const updateImageProp = (id: string, prop: keyof DecorativeImageProperties, value: any, key: string) => { const updateImageProp = (id: number, prop: keyof DecorativeImageProperties, value: any, key: string) => {
const images = props.configData[key] as DecorativeImageProperties[]; const images = props.configData[key] as DecorativeImageProperties[];
const index = images.findIndex(img => img.id === id); const index = images.findIndex(img => img.id === id);
if (index !== -1) { if (index !== -1) {
@@ -159,7 +303,7 @@ import { v4 as uuidv4 } from 'uuid';
} }
}; };
const removeImage = (id: string, key: string) => { const removeImage = (id: number, key: string) => {
const images = props.configData[key] as DecorativeImageProperties[]; const images = props.configData[key] as DecorativeImageProperties[];
props.configData[key] = images.filter(img => img.id !== id); props.configData[key] = images.filter(img => img.id !== id);
if (selectedImageId.value === id) { if (selectedImageId.value === id) {
@@ -167,7 +311,7 @@ import { v4 as uuidv4 } from 'uuid';
} }
}; };
const changeZIndex = (id: string, direction: 'up' | 'down', key: string) => { const changeZIndex = (id: number, direction: 'up' | 'down', key: string) => {
const images = props.configData[key] as DecorativeImageProperties[]; const images = props.configData[key] as DecorativeImageProperties[];
const index = images.findIndex(img => img.id === id); const index = images.findIndex(img => img.id === id);
if (index === -1) return; if (index === -1) return;
@@ -182,32 +326,20 @@ import { v4 as uuidv4 } from 'uuid';
}; };
const renderDecorativeImages = (key: string) => { const renderDecorativeImages = (key: string) => {
// 获取全局处理器
const uploadHandler = (window as any).$upload;
const messageHandler = (window as any).$message ?? message;
return h(NFlex, { vertical: true, size: 'large' }, () => [ return h(NFlex, { vertical: true, size: 'large' }, () => [
// 上传按钮 // 上传按钮
h(NUpload, { h(NUpload, {
multiple: true, accept: 'image/*', showFileList: false, multiple: true, accept: 'image/*', showFileList: false,
'onUpdate:fileList': (fileList: UploadFileInfo[]) => { 'onUpdate:fileList': (fileList: UploadFileInfo[]) => {
if (uploadHandler?.upload && fileList.length > 0) { if (fileList.length > 0) {
const filesToUpload = fileList.map(f => f.file).filter((f): f is File => f instanceof File); const filesToUpload = fileList.map(f => f.file).filter((f): f is File => f instanceof File);
if (filesToUpload.length > 0) { if (filesToUpload.length > 0) {
uploadHandler.upload(filesToUpload, '/api/file/upload') // 不立即上传,而是存储起来等待提交时上传
.then((results: any[]) => { if (!pendingDecorativeImages.value[key]) {
const newImages: DecorativeImageProperties[] = results.map((result: any, index: number) => ({ pendingDecorativeImages.value[key] = [];
id: uuidv4(), src: result.url, x: 10 + index * 5, y: 10 + index * 5, }
width: 20, rotation: 0, opacity: 1, pendingDecorativeImages.value[key].push(...filesToUpload);
zIndex: (props.configData[key]?.length ?? 0) + index + 1, message.success(`已选择 ${filesToUpload.length} 个装饰图片,提交时会自动上传`);
}));
const currentImages = props.configData[key] as DecorativeImageProperties[] || [];
props.configData[key] = [...currentImages, ...newImages];
})
.catch((error: any) => {
console.error("图片上传失败:", error);
messageHandler?.error("图片上传失败: " + (error?.message ?? error));
});
} }
} }
return []; return [];
@@ -227,8 +359,8 @@ import { v4 as uuidv4 } from 'uuid';
}, { }, {
default: () => h(NFlex, { justify: 'space-between', align: 'center' }, () => [ default: () => h(NFlex, { justify: 'space-between', align: 'center' }, () => [
h(NFlex, { align: 'center', size: 'small' }, () => [ h(NFlex, { align: 'center', size: 'small' }, () => [
h('img', { src: FILE_BASE_URL + img.src, style: { width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px', backgroundColor: '#f0f0f0' } }), h('img', { src: img.path, style: { width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px', backgroundColor: '#f0f0f0' } }),
h('span', `ID: ...${img.id.slice(-4)}`) h('span', `ID: ${img.id}`)
]), ]),
h(NSpace, null, () => [ h(NSpace, null, () => [
h(NButton, { size: 'tiny', circle: true, secondary: true, title: '上移一层', onClick: (e: Event) => { e.stopPropagation(); changeZIndex(img.id, 'up', key); } }, { icon: () => h(NIcon, { component: ArrowUp20Filled }) }), h(NButton, { size: 'tiny', circle: true, secondary: true, title: '上移一层', onClick: (e: Event) => { e.stopPropagation(); changeZIndex(img.id, 'up', key); } }, { icon: () => h(NIcon, { component: ArrowUp20Filled }) }),
@@ -244,9 +376,9 @@ import { v4 as uuidv4 } from 'uuid';
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '透明度:'), h(NInputNumber, { value: img.opacity, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'opacity', v ?? 0, key), min: 0, max: 1, step: 0.01 }), h(NSlider, { value: img.opacity, 'onUpdate:value': (v: number | number[]) => updateImageProp(img.id, 'opacity', Array.isArray(v) ? v[0] : v ?? 0, key), min: 0, max: 1, step: 0.01, style: { marginLeft: '10px', flexGrow: 1 } })]), h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '透明度:'), h(NInputNumber, { value: img.opacity, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'opacity', v ?? 0, key), min: 0, max: 1, step: 0.01 }), h(NSlider, { value: img.opacity, 'onUpdate:value': (v: number | number[]) => updateImageProp(img.id, 'opacity', Array.isArray(v) ? v[0] : v ?? 0, key), min: 0, max: 1, step: 0.01, style: { marginLeft: '10px', flexGrow: 1 } })]),
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '层级:'), h(NInputNumber, { value: img.zIndex, size: 'small', readonly: true })]), h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '层级:'), h(NInputNumber, { value: img.zIndex, size: 'small', readonly: true })]),
]) : null ]) : null
}) });
}) })
: h(NEmpty, { description: '暂无装饰图片' }) : h(NEmpty, { description: '暂无装饰图片' });
}), }),
]); ]);
}; };
@@ -258,13 +390,13 @@ import { v4 as uuidv4 } from 'uuid';
if (item.default && !(item.key in props.configData)) { if (item.default && !(item.key in props.configData)) {
props.configData[item.key] = item.default; props.configData[item.key] = item.default;
} }
if (item.type == 'image') { if (item.type == 'file') {
const configItem = props.configData[item.key]; const configItem = props.configData[item.key];
if (configItem) { if (configItem) {
fileList.value[item.key] = configItem.map((i: string) => ({ fileList.value[item.key] = configItem.map((uploadedFile: UploadFileResponse) => ({
id: i, id: uploadedFile.id,
thumbnailUrl: FILE_BASE_URL + i, thumbnailUrl: uploadedFile.path,
name: '', name: uploadedFile.name || '',
status: 'finished', status: 'finished',
})); }));
} }
@@ -346,16 +478,15 @@ import { v4 as uuidv4 } from 'uuid';
</NTooltip> </NTooltip>
</template> </template>
<NUpload <NUpload
v-else-if="item.type == 'image'" v-else-if="item.type == 'file'"
v-model:file-list="fileList[item.key]" v-model:file-list="fileList[item.key]"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico" accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.mp3,.mp4,.pdf,.doc,.docx"
list-type="image-card" list-type="image-card"
:default-upload="false" :default-upload="false"
:max="item.imageLimit" :max="item.fileLimit"
im
@update:file-list="file => OnFileListChange(item.key, file)" @update:file-list="file => OnFileListChange(item.key, file)"
> >
上传图片 上传文件
</NUpload> </NUpload>
</NFormItemGi> </NFormItemGi>
</NGrid> </NGrid>
@@ -367,5 +498,31 @@ import { v4 as uuidv4 } from 'uuid';
> >
提交 提交
</NButton> </NButton>
<!-- 上传进度模态框 -->
<NModal
v-model:show="showUploadModal"
preset="card"
title="文件上传进度"
:mask-closable="false"
:closable="false"
style="width: 400px"
>
<NFlex
vertical
size="large"
>
<NText>{{ uploadStage }}</NText>
<NProgress
type="line"
:percentage="uploadProgress"
:indicator-placement="'inside'"
:show-indicator="true"
/>
<NText v-if="totalFilesToUpload > 0">
{{ uploadedFilesCount }} / {{ totalFilesToUpload }} 个文件
</NText>
</NFlex>
</NModal>
</NForm> </NForm>
</template> </template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { QAInfo } from '@/api/api-models' import { QAInfo } from '@/api/api-models'
import { useQuestionBox } from '@/store/useQuestionBox'; import { useQuestionBox } from '@/store/useQuestionBox';
import { NButton, NCard, NDivider, NFlex, NImage, NTag, NText, NTime, NTooltip } from 'naive-ui' import { NButton, NCard, NDivider, NFlex, NImage, NTag, NText, NTime, NTooltip, NSpace } from 'naive-ui'
import { ref } from 'vue'; import { ref } from 'vue';
const props = defineProps<{ const props = defineProps<{
@@ -144,14 +144,19 @@ function getScoreColor(score: number | undefined): string {
:item="item" :item="item"
/> />
</template> </template>
<template v-if="item.question?.image"> <template v-if="item.questionImages && item.questionImages.length > 0">
<NImage <NSpace
v-if="item.question?.image" size="small"
:src="item.question.image" >
height="100" <NImage
lazy v-for="(img, index) in item.questionImages"
/> :key="index"
<br> :src="img.path"
height="100"
lazy
/>
</NSpace>
<NDivider style="margin: 10px 0;" />
</template> </template>
<NText <NText

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models'; import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models';
import { FILE_BASE_URL, IMGUR_URL } from '@/data/constants'; import { IMGUR_URL } from '@/data/constants';
import { NAlert, NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'naive-ui'; import { Pin16Filled } from '@vicons/fluent';
import { VehicleShip20Filled, Pin16Filled } from '@vicons/fluent'; import { NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'naive-ui';
const props = defineProps<{ const props = defineProps<{
goods: ResponsePointGoodModel | undefined; goods: ResponsePointGoodModel | undefined;
@@ -30,7 +30,7 @@
<template #cover> <template #cover>
<div class="cover-container"> <div class="cover-container">
<NImage <NImage
:src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover" :src="goods.cover ? goods.cover.path : emptyCover"
:fallback-src="emptyCover" :fallback-src="emptyCover"
height="150" height="150"
object-fit="cover" object-fit="cover"

View File

@@ -1,3 +1,4 @@
import { UploadFileResponse } from '@/api/api-models';
import { VNode, h } from 'vue'; // 导入 Vue 的 VNode 类型和 h 函数(用于示例) import { VNode, h } from 'vue'; // 导入 Vue 的 VNode 类型和 h 函数(用于示例)
// --- 基础和通用类型 --- // --- 基础和通用类型 ---
@@ -31,6 +32,37 @@ type DataAccessor<T, V> = {
set: (config: T, value: V) => void; 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' * @description 'V'
* @template T - ( unknown) * @template T - ( unknown)
@@ -117,22 +149,21 @@ export type TemplateConfigBooleanItem<T = unknown> = TemplateConfigItemWithType<
description?: string; // 可选的描述 description?: string; // 可选的描述
}; };
// 修改 TemplateConfigImageItem 以支持单个或多个图片,并返回完整 URL // 将文件类型统一为数组不再根据fileLimit区分
export type TemplateConfigImageItem<T = unknown> = TemplateConfigItemWithType<T, string[]> & { export type TemplateConfigFileItem<T = unknown> =
type: 'image'; TemplateConfigItemWithType<T, UploadFileResponse[]> & {
imageLimit: number; // 图片数量限制 type: 'file';
// onUploaded 的 data 现在是 string[] fileLimit?: number; // 变为可选参数仅用于UI限制不影响类型
onUploaded?: (data: string[], config: T) => void; fileType?: string[];
}; onUploaded?: (data: UploadFileResponse[], config: T) => void;
};
// --- 新增:装饰性图片配置 --- // --- 新增:装饰性图片配置 ---
/** /**
* @description * @description
*/ */
export interface DecorativeImageProperties { export interface DecorativeImageProperties extends UploadFileResponse {
id: string; // 唯一标识符 (例如 UUID 或时间戳)
src: string; // 图片 URL
x: number; // X 坐标 (%) x: number; // X 坐标 (%)
y: number; // Y 坐标 (%) y: number; // Y 坐标 (%)
width: number; // 宽度 (%) width: number; // 宽度 (%)
@@ -208,16 +239,16 @@ export interface TemplateConfigRenderItem<T = unknown> extends TemplateConfigBas
/** /**
* @description * @description
* 使 `<unknown>` T * 使 `<any>` T
*/ */
export type ConfigItemDefinition = export type ConfigItemDefinition =
| TemplateConfigStringItem<any> | TemplateConfigStringItem<any>
| TemplateConfigNumberItem<any> | TemplateConfigNumberItem<any>
| TemplateConfigStringArrayItem<any> | TemplateConfigStringArrayItem<any>
| TemplateConfigNumberArrayItem<any> | TemplateConfigNumberArrayItem<any>
| TemplateConfigImageItem<any> | TemplateConfigFileItem<any>
| TemplateConfigRenderItem<any> // 包含优化后的 render/onUploaded 方法 | TemplateConfigRenderItem<any>
| TemplateConfigDecorativeImagesItem<any> // 新增装饰图片类型 | TemplateConfigDecorativeImagesItem<any>
| TemplateConfigSliderNumberItem<any> | TemplateConfigSliderNumberItem<any>
| TemplateConfigBooleanItem<any> | TemplateConfigBooleanItem<any>
| TemplateConfigColorItem<any>; | TemplateConfigColorItem<any>;
@@ -240,9 +271,10 @@ export type ExtractConfigData<
// 如果没有 default则根据 'type' 属性确定类型 // 如果没有 default则根据 'type' 属性确定类型
: ItemWithKeyK extends { type: 'string'; } ? string : ItemWithKeyK extends { type: 'string'; } ? string
: ItemWithKeyK extends { type: 'stringArray'; } ? 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: 'numberArray'; } ? number[]
: ItemWithKeyK extends { type: 'image'; } ? string[] // 文件类型统一处理为数组
: ItemWithKeyK extends { type: 'file'; } ? UploadFileResponse[]
: ItemWithKeyK extends { type: 'boolean'; } ? boolean : ItemWithKeyK extends { type: 'boolean'; } ? boolean
: ItemWithKeyK extends { type: 'color'; } ? RGBAColor : ItemWithKeyK extends { type: 'color'; } ? RGBAColor
: ItemWithKeyK extends { type: 'decorativeImages'; } ? DecorativeImageProperties[] : ItemWithKeyK extends { type: 'decorativeImages'; } ? DecorativeImageProperties[]
@@ -253,22 +285,140 @@ export type ExtractConfigData<
: never // 如果 K 正确派生,则不应发生 : 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 * @description
* 使 `const Items` `as const` 使 * 使 `const Items` `as const` 使
* key 'file' key 'File'
* @template Items - ConstrainedKeyItem
* @param items * @param items
* @returns * @returns
*/ */
export function defineTemplateConfig< export function defineTemplateConfig<
const Items extends readonly ConfigItemDefinition[] // 使用 'const' 泛型进行推断 // 应用 ConstrainedKeyItem 约束到数组的每个元素
const Items extends readonly ConstrainedKeyItem<ConfigItemDefinition>[]
>(items: Items): Items { >(items: Items): Items {
// 如果需要,可以在此处添加基本的运行时验证。 // 可选的运行时验证,用于在浏览器控制台提供更友好的错误提示
// 类型检查主要由 TypeScript 根据约束完成。 items.forEach(item => {
return items; // 类型守卫确保 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 USER_INDEX_API_URL = BASE_API_URL + 'user-index/';
export const ANALYZE_API_URL = BASE_API_URL + 'analyze/'; export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/'; 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 = { export type TemplateMapType = {
[key: string]: { [key: string]: {
name: string; name: string;
@@ -102,6 +103,14 @@ export const SongListTemplateMap: TemplateMapType = {
() => import('@/views/view/songListTemplate/DefaultSongListTemplate.vue') () => import('@/views/view/songListTemplate/DefaultSongListTemplate.vue')
)) ))
}, },
traditional: {
name: '列表 (较推荐',
settingName: 'Template.SongList.Traditional',
component: markRaw(defineAsyncComponent(
() =>
import('@/views/view/songListTemplate/TraditionalSongListTemplate.vue')
))
},
simple: { simple: {
name: '简单', name: '简单',
//settingName: 'Template.SongList.Simple', //settingName: 'Template.SongList.Simple',
@@ -109,14 +118,6 @@ export const SongListTemplateMap: TemplateMapType = {
() => import('@/views/view/songListTemplate/SimpleSongListTemplate.vue') () => 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 = { 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);
}

View File

@@ -476,6 +476,7 @@
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Transition <Transition
name="fade-slide" name="fade-slide"
mode="out-in"
:appear="true" :appear="true"
> >
<KeepAlive> <KeepAlive>
@@ -676,19 +677,7 @@
// --- 路由过渡动画 --- // --- 路由过渡动画 ---
.fade-slide-enter-active, .fade-slide-enter-active,
.fade-slide-leave-active { .fade-slide-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease; transition: opacity 0.2s ease, transform 0.2s ease;
// 关键: 相对于 content-layout-container 定位
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%; // 让过渡元素也撑满容器高度
// 关键: 保持内边距和盒模型一致
padding: var(--vtsuru-content-padding);
box-sizing: border-box;
// 关键: 背景色防止透视
background-color: var(--n-card-color); // 使用内容区的背景色
z-index: 1;
} }
.fade-slide-enter-from { .fade-slide-enter-from {

View File

@@ -634,14 +634,21 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
</NFlex> </NFlex>
</template> </template>
<!-- 问题内容 --> <!-- 问题内容 -->
<template v-if="item.question?.image"> <template v-if="item.questionImages && item.questionImages.length > 0">
<NImage <NSpace
:src="item.question.image" vertical
width="100" size="small"
object-fit="cover" >
lazy <NImage
style="border-radius: 4px; margin-bottom: 5px;" v-for="(img, index) in item.questionImages"
/> :key="index"
:src="img.path"
width="100"
object-fit="cover"
lazy
style="border-radius: 4px; margin-bottom: 5px;"
/>
</NSpace>
<br> <br>
</template> </template>
<NText>{{ item.question?.message }}</NText> <NText>{{ item.question?.message }}</NText>
@@ -797,7 +804,13 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
> >
允许未注册/匿名用户进行提问 允许未注册/匿名用户进行提问
</NCheckbox> </NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.questionBox.allowImageUpload"
:disabled="useQB.isLoading"
@update:checked="saveQuestionBoxSettings"
>
允许上传图片
</NCheckbox>
<!-- 内容审查 --> <!-- 内容审查 -->
<NDivider title-placement="left"> <NDivider title-placement="left">
内容审查等级 内容审查等级

View File

@@ -128,11 +128,14 @@ onUnmounted(() => {
<div class="question-display-text"> <div class="question-display-text">
{{ question?.question.message }} {{ question?.question.message }}
</div> </div>
<img <div v-if="setting.showImage && question?.questionImages && question.questionImages.length > 0" class="question-display-images">
v-if="setting.showImage && question?.question.image" <img
class="question-display-image" v-for="(img, index) in question.questionImages"
:src="question?.question.image" :key="index"
> class="question-display-image"
:src="img.path"
>
</div>
</div> </div>
</template> </template>
<div <div
@@ -186,10 +189,18 @@ onUnmounted(() => {
white-space: pre-wrap; white-space: pre-wrap;
} }
.question-display-images {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.question-display-image { .question-display-image {
max-width: 40%; max-width: 40%;
max-height: 40%; max-height: 150px;
margin: 0 auto; border-radius: 8px;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {

View File

@@ -29,7 +29,7 @@
TemplateMapType, TemplateMapType,
USER_INDEX_API_URL, USER_INDEX_API_URL,
} from '@/data/constants'; } from '@/data/constants';
import { ConfigItemDefinition } from '@/data/VTsuruTypes'; import { ConfigItemDefinition } from '@/data/VTsuruConfigTypes';
import { Delete24Regular } from '@vicons/fluent'; import { Delete24Regular } from '@vicons/fluent';
import { import {
NAlert, NAlert,

View File

@@ -1,21 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { copyToClipboard, getImageUploadModel } from '@/Utils' import { copyToClipboard } from '@/Utils'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account' import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { FunctionTypes, GoodsStatus, GoodsTypes, UploadPointGoodsModel, ResponsePointGoodModel, KeySelectionMode } from '@/api/api-models' import {
FunctionTypes,
GoodsStatus,
GoodsTypes,
KeySelectionMode,
ResponsePointGoodModel,
UploadPointGoodsModel,
UserFileLocation
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query' import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue' import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import PointGoodsItem from '@/components/manage/PointGoodsItem.vue' import PointGoodsItem from '@/components/manage/PointGoodsItem.vue'
import { CN_HOST, CURRENT_HOST, FILE_BASE_URL, POINT_API_URL } from '@/data/constants' import { CURRENT_HOST, POINT_API_URL } from '@/data/constants'
import { uploadFiles, UploadStage } from '@/data/fileUpload'
import { useBiliAuth } from '@/store/useBiliAuth' import { useBiliAuth } from '@/store/useBiliAuth'
import { Info24Filled } from '@vicons/fluent' import { Info24Filled } from '@vicons/fluent'
import { useRouteHash } from '@vueuse/router' import { useRouteHash } from '@vueuse/router'
import { useStorage } from '@vueuse/core'
import { import {
FormItemRule, FormItemRule,
NAlert, NAlert,
NButton, NButton,
NCheckbox, NCheckbox,
NDivider, NDivider,
NDynamicTags,
NEmpty, NEmpty,
NFlex, NFlex,
NForm, NForm,
@@ -23,12 +32,12 @@ import {
NGrid, NGrid,
NGridItem, NGridItem,
NIcon, NIcon,
NImage,
NInput, NInput,
NInputNumber,
NInputGroup, NInputGroup,
NInputNumber,
NModal, NModal,
NPopconfirm, NPopconfirm,
NProgress,
NRadioButton, NRadioButton,
NRadioGroup, NRadioGroup,
NScrollbar, NScrollbar,
@@ -41,10 +50,9 @@ import {
NUpload, NUpload,
UploadFileInfo, UploadFileInfo,
useDialog, useDialog,
useMessage, useMessage
NDynamicTags,
} from 'naive-ui' } from 'naive-ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import PointOrderManage from './PointOrderManage.vue' import PointOrderManage from './PointOrderManage.vue'
import PointSettings from './PointSettings.vue' import PointSettings from './PointSettings.vue'
import PointUserManage from './PointUserManage.vue' import PointUserManage from './PointUserManage.vue'
@@ -57,6 +65,8 @@ const formRef = ref()
const isUpdating = ref(false) const isUpdating = ref(false)
const isAllowedPrivacyPolicy = ref(false) const isAllowedPrivacyPolicy = ref(false)
const showAddGoodsModal = ref(false) const showAddGoodsModal = ref(false)
const uploadProgress = ref(0)
const isUploadingCover = ref(false)
// 路由哈希处理 // 路由哈希处理
const realHash = useRouteHash('goods', { mode: 'replace' }) const realHash = useRouteHash('goods', { mode: 'replace' })
@@ -71,7 +81,7 @@ const hash = computed({
// 商品数据及模型 // 商品数据及模型
const goods = ref<ResponsePointGoodModel[]>(await biliAuth.GetGoods(accountInfo.value?.id, message)) const goods = ref<ResponsePointGoodModel[]>(await biliAuth.GetGoods(accountInfo.value?.id, message))
const defaultGoodsModel = { const defaultGoodsModel = (): { goods: UploadPointGoodsModel; fileList: UploadFileInfo[] } => ({
goods: { goods: {
type: GoodsTypes.Virtual, type: GoodsTypes.Virtual,
status: GoodsStatus.Normal, status: GoodsStatus.Normal,
@@ -87,14 +97,24 @@ const defaultGoodsModel = {
name: '', name: '',
price: 0, price: 0,
tags: [], tags: [],
description: '' description: '',
cover: undefined,
} as UploadPointGoodsModel, } as UploadPointGoodsModel,
fileList: [], fileList: [],
} as { goods: UploadPointGoodsModel; fileList: UploadFileInfo[] } })
const currentGoodsModel = ref<{ goods: UploadPointGoodsModel; fileList: UploadFileInfo[] }>( const currentGoodsModel = ref<{ goods: UploadPointGoodsModel; fileList: UploadFileInfo[] }>(
JSON.parse(JSON.stringify(defaultGoodsModel)) defaultGoodsModel()
) )
// 监听 fileList 变化,确保 cover 和 fileList 同步
watch(() => currentGoodsModel.value.fileList, (newFileList, oldFileList) => {
if (oldFileList && oldFileList.length > 0 && newFileList.length === 0) {
if (currentGoodsModel.value.goods.id && currentGoodsModel.value.goods.cover) {
currentGoodsModel.value.goods.cover = undefined
}
}
}, { deep: true })
// 计算属性 // 计算属性
const allowedYearOptions = computed(() => { const allowedYearOptions = computed(() => {
return Array.from({ length: new Date().getFullYear() - 2024 + 1 }, (_, i) => 2024 + i).map((item) => ({ return Array.from({ length: new Date().getFullYear() - 2024 + 1 }, (_, i) => 2024 + i).map((item) => ({
@@ -202,12 +222,45 @@ async function updateGoods(e: MouseEvent) {
if (isUpdating.value || !formRef.value) return if (isUpdating.value || !formRef.value) return
e.preventDefault() e.preventDefault()
isUpdating.value = true isUpdating.value = true
isUploadingCover.value = false
uploadProgress.value = 0
try { try {
await formRef.value.validate() await formRef.value.validate()
if (currentGoodsModel.value.fileList.length > 0) { const newFilesToUpload = currentGoodsModel.value.fileList.filter(f => f.file && f.status !== 'finished')
currentGoodsModel.value.goods.cover = await getImageUploadModel(currentGoodsModel.value.fileList) if (newFilesToUpload.length > 0 && newFilesToUpload[0].file) {
isUploadingCover.value = true
message.info('正在上传封面...')
const uploadResults = await uploadFiles(
[newFilesToUpload[0].file],
undefined,
UserFileLocation.Local,
(stage: string) => {
if (stage === UploadStage.Uploading) {
uploadProgress.value = 0
}
}
)
isUploadingCover.value = false
if (uploadResults && uploadResults.length > 0) {
currentGoodsModel.value.goods.cover = uploadResults[0]
message.success('封面上传成功')
const uploadedFileIndex = currentGoodsModel.value.fileList.findIndex(f => f.id === newFilesToUpload[0].id)
if (uploadedFileIndex > -1) {
currentGoodsModel.value.fileList[uploadedFileIndex] = {
...currentGoodsModel.value.fileList[uploadedFileIndex],
id: uploadResults[0].id.toString(),
status: 'finished',
thumbnailUrl: uploadResults[0].path,
url: uploadResults[0].path
};
}
} else {
throw new Error('封面上传失败')
}
} else if (currentGoodsModel.value.fileList.length === 0 && currentGoodsModel.value.goods.id) {
currentGoodsModel.value.goods.cover = undefined
} }
const { code, data, message: errMsg } = await QueryPostAPI<ResponsePointGoodModel>( const { code, data, message: errMsg } = await QueryPostAPI<ResponsePointGoodModel>(
@@ -216,9 +269,9 @@ async function updateGoods(e: MouseEvent) {
) )
if (code === 200) { if (code === 200) {
message.success('成功') message.success('商品信息保存成功')
showAddGoodsModal.value = false showAddGoodsModal.value = false
currentGoodsModel.value = JSON.parse(JSON.stringify(defaultGoodsModel)) currentGoodsModel.value = defaultGoodsModel()
const index = goods.value.findIndex(g => g.id === data.id) const index = goods.value.findIndex(g => g.id === data.id)
if (index >= 0) { if (index >= 0) {
@@ -227,13 +280,15 @@ async function updateGoods(e: MouseEvent) {
goods.value.push(data) goods.value.push(data)
} }
} else { } else {
message.error('失败: ' + errMsg) message.error('商品信息保存失败: ' + errMsg)
} }
} catch (err) { } catch (err: any) {
console.error(err) console.error(currentGoodsModel.value, err)
message.error(typeof err === 'string' ? `失败: ${err}` : '表单验证失败') const errorMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : '表单验证失败或上传出错'
message.error(`失败: ${errorMsg}`)
} finally { } finally {
isUpdating.value = false isUpdating.value = false
isUploadingCover.value = false
} }
} }
@@ -241,23 +296,24 @@ function OnFileListChange(files: UploadFileInfo[]) {
if (files.length === 1 && (files[0].file?.size ?? 0) > 10 * 1024 * 1024) { if (files.length === 1 && (files[0].file?.size ?? 0) > 10 * 1024 * 1024) {
message.error('文件大小不能超过10MB') message.error('文件大小不能超过10MB')
currentGoodsModel.value.fileList = [] currentGoodsModel.value.fileList = []
} else {
currentGoodsModel.value.fileList = files
} }
} }
function onUpdateClick(item: ResponsePointGoodModel) { function onUpdateClick(item: ResponsePointGoodModel) {
currentGoodsModel.value = { currentGoodsModel.value = {
goods: { goods: JSON.parse(JSON.stringify({
...item, ...item,
count: item.count, })),
cover: undefined,
},
fileList: item.cover fileList: item.cover
? [ ? [
{ {
id: item.cover ?? 'cover', id: item.cover.id.toString(),
thumbnailUrl: FILE_BASE_URL + item.cover, name: item.cover.name || '封面',
name: '封面',
status: 'finished', status: 'finished',
url: item.cover.path,
thumbnailUrl: item.cover.path,
}, },
] ]
: [], : [],
@@ -336,14 +392,15 @@ function onDeleteClick(item: ResponsePointGoodModel) {
} }
function onModalOpen() { function onModalOpen() {
if (currentGoodsModel.value.goods.id) { if (!currentGoodsModel.value.goods.id) {
resetGoods() resetGoods()
} }
showAddGoodsModal.value = true showAddGoodsModal.value = true
} }
function resetGoods() { function resetGoods() {
currentGoodsModel.value = JSON.parse(JSON.stringify(defaultGoodsModel)) currentGoodsModel.value = defaultGoodsModel()
isAllowedPrivacyPolicy.value = false
} }
onMounted(() => { }) onMounted(() => { })
@@ -613,6 +670,8 @@ onMounted(() => { })
style="width: 600px; max-width: 90%" style="width: 600px; max-width: 90%"
title="添加/修改礼物信息" title="添加/修改礼物信息"
class="goods-modal" class="goods-modal"
:mask-closable="!isUpdating && !isUploadingCover"
:close-on-esc="!isUpdating && !isUploadingCover"
> >
<template #header-extra> <template #header-extra>
<NPopconfirm <NPopconfirm
@@ -637,7 +696,7 @@ onMounted(() => { })
> >
<NForm <NForm
ref="formRef" ref="formRef"
:model="currentGoodsModel" :model="currentGoodsModel.goods"
:rules="rules" :rules="rules"
style="width: 100%" style="width: 100%"
> >
@@ -746,28 +805,36 @@ onMounted(() => { })
vertical vertical
:gap="8" :gap="8"
> >
<NFlex
v-if="currentGoodsModel.goods.cover"
:gap="8"
align="center"
>
<NText>当前封面: </NText>
<NImage
:src="FILE_BASE_URL + currentGoodsModel.goods.cover"
height="50"
object-fit="cover"
/>
</NFlex>
<NUpload <NUpload
v-model:file-list="currentGoodsModel.fileList" v-model:file-list="currentGoodsModel.fileList"
:max="1" :max="1"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.bmp,.tif,.tiff,.jfif,.jpe,.jp,.psd,." accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.bmp,.tif,.tiff,.jfif,.jpe,.jp,.psd,."
list-type="image-card" list-type="image-card"
:default-upload="false" :default-upload="false"
:disabled="isUploadingCover"
@update:file-list="OnFileListChange" @update:file-list="OnFileListChange"
> >
+ {{ currentGoodsModel.goods.cover ? '更换' : '上传' }}封面 <NFlex
vertical
align="center"
justify="center"
style="width: 100%; height: 100%;"
>
<NIcon
size="24"
:depth="3"
/>
<span>{{ currentGoodsModel.goods.cover ? '更换' : '上传' }}封面</span>
<span style="font-size: 12px; color: grey">(小于10MB)</span>
</NFlex>
</NUpload> </NUpload>
<NProgress
v-if="isUploadingCover"
type="line"
:percentage="uploadProgress"
:indicator-placement="'inside'"
processing
/>
</NFlex> </NFlex>
</NFormItem> </NFormItem>
@@ -779,7 +846,7 @@ onMounted(() => { })
兑换规则 兑换规则
</NDivider> </NDivider>
<NFormItem <NFormItem
path="goods.type" path="type"
label="礼物类型" label="礼物类型"
> >
<NRadioGroup v-model:value="currentGoodsModel.goods.type"> <NRadioGroup v-model:value="currentGoodsModel.goods.type">
@@ -1066,10 +1133,12 @@ onMounted(() => { })
<NButton <NButton
type="primary" type="primary"
size="large" size="large"
:loading="isUpdating" :loading="isUpdating || isUploadingCover"
:disabled="isUploadingCover"
@click="updateGoods" @click="updateGoods"
> >
{{ currentGoodsModel.goods.id ? '修改' : '创建' }} <span v-if="isUploadingCover">正在上传封面...</span>
<span v-else>{{ currentGoodsModel.goods.id ? '修改' : '创建' }}</span>
</NButton> </NButton>
</NFlex> </NFlex>
</template> </template>
@@ -1157,4 +1226,12 @@ onMounted(() => { })
border-bottom-left-radius: 6px; border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px; border-bottom-right-radius: 6px;
} }
.goods-modal :deep(.n-upload-trigger.n-upload-trigger--image-card) {
width: 104px;
height: 104px;
display: flex;
align-items: center;
justify-content: center;
}
</style> </style>

View File

@@ -1,20 +1,39 @@
<template> <template>
<yt-live-chat-author-badge-renderer :type="authorTypeText"> <yt-live-chat-author-badge-renderer :type="authorTypeText">
<NTooltip :content="readableAuthorTypeText" placement="top"> <NTooltip
:content="readableAuthorTypeText"
placement="top"
>
<template #trigger> <template #trigger>
<div id="image" class="style-scope yt-live-chat-author-badge-renderer"> <div
<yt-icon v-if="isAdmin" class="style-scope yt-live-chat-author-badge-renderer"> id="image"
<svg viewBox="0 0 16 16" class="style-scope yt-icon" preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-live-chat-author-badge-renderer"
style="pointer-events: none; display: block; width: 100%; height: 100%;"> >
<yt-icon
v-if="isAdmin"
class="style-scope yt-live-chat-author-badge-renderer"
>
<svg
viewBox="0 0 16 16"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
focusable="false"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
>
<g class="style-scope yt-icon"> <g class="style-scope yt-icon">
<path class="style-scope yt-icon" <path
d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z"> class="style-scope yt-icon"
</path> d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z"
/>
</g> </g>
</svg> </svg>
</yt-icon> </yt-icon>
<img v-else :src="`${fileServerUrl}/blivechat/icons/guard-level-${privilegeType}.png`" <img
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText"> v-else
:src="`${fileServerUrl}/blivechat/icons/guard-level-${privilegeType}.png`"
class="style-scope yt-live-chat-author-badge-renderer"
:alt="readableAuthorTypeText"
>
</div> </div>
</template> </template>
{{ readableAuthorTypeText }} {{ readableAuthorTypeText }}

View File

@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { copyToClipboard } from '@/Utils' import { copyToClipboard } from '@/Utils'
import { useAccount } from '@/api/account' import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
import { EventDataTypes, EventModel, OpenLiveInfo } from '@/api/api-models' import { EventDataTypes, EventModel, OpenLiveInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query' import { FETCH_API } from '@/data/constants'
import { FETCH_API, VTSURU_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient' import { useDanmakuClient } from '@/store/useDanmakuClient'
import { Info24Filled, Mic24Filled } from '@vicons/fluent' import { Info24Filled, Mic24Filled } from '@vicons/fluent'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
@@ -456,38 +455,23 @@ function stopSpeech() {
message.success('已停止监听') message.success('已停止监听')
} }
async function uploadConfig() { async function uploadConfig() {
await QueryPostAPI(VTSURU_API_URL + 'set-config', { const result = await UploadConfig('Speech', settings.value)
name: 'Speech', if (result) {
json: JSON.stringify(settings.value), message.success('已保存至服务器')
}) } else {
.then((data) => { message.error('保存失败')
if (data.code == 200) { }
message.success('已保存至服务器')
} else {
message.error('保存失败: ' + data.message)
}
})
.catch((err) => {
message.error('保存失败')
})
} }
async function downloadConfig() { async function downloadConfig() {
await QueryGetAPI<string>(VTSURU_API_URL + 'get-config', { const result = await DownloadConfig<SpeechSettings>('Speech')
name: 'Speech', if (result.status === 'success' && result.data) {
}) settings.value = result.data
.then((data) => { message.success('已获取配置文件')
if (data.code == 200) { } else if (result.status === 'notfound') {
settings.value = JSON.parse(data.data) message.error('未上传配置文件')
message.success('已获取配置文件') } else {
} else if (data.code == 404) { message.error('获取失败: ' + result.msg)
message.error('未上传配置文件') }
} else {
message.error('获取失败: ' + data.message)
}
})
.catch((err) => {
message.error('获取失败')
})
} }
function test(type: EventDataTypes) { function test(type: EventDataTypes) {
switch (type) { switch (type) {

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@ import { DownloadConfig, useAccount } from '@/api/account';
import { Setting_LiveRequest, SongRequestInfo, SongsInfo, UserInfo } from '@/api/api-models'; import { Setting_LiveRequest, SongRequestInfo, SongsInfo, UserInfo } from '@/api/api-models';
import { QueryGetAPI, QueryPostAPIWithParams } from '@/api/query'; import { QueryGetAPI, QueryPostAPIWithParams } from '@/api/query';
import { SONG_API_URL, SONG_REQUEST_API_URL, SongListTemplateMap } from '@/data/constants'; import { SONG_API_URL, SONG_REQUEST_API_URL, SongListTemplateMap } from '@/data/constants';
import { ConfigItemDefinition } from '@/data/VTsuruTypes'; import { ConfigItemDefinition } from '@/data/VTsuruConfigTypes';
import { useBiliAuth } from '@/store/useBiliAuth'; import { useBiliAuth } from '@/store/useBiliAuth';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { addSeconds } from 'date-fns'; import { addSeconds } from 'date-fns';

View File

@@ -4,7 +4,7 @@ import { useAccount } from '@/api/account';
import { ResponseUserIndexModel, UserInfo } from '@/api/api-models'; import { ResponseUserIndexModel, UserInfo } from '@/api/api-models';
import { QueryGetAPI } from '@/api/query'; import { QueryGetAPI } from '@/api/query';
import SimpleVideoCard from '@/components/SimpleVideoCard.vue'; import SimpleVideoCard from '@/components/SimpleVideoCard.vue';
import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruTypes'; import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruConfigTypes';
import { USER_INDEX_API_URL } from '@/data/constants'; import { USER_INDEX_API_URL } from '@/data/constants';
import { NAvatar, NButton, NCard, NDivider, NFlex, NSpace, NText, useMessage } from 'naive-ui'; import { NAvatar, NButton, NCard, NDivider, NFlex, NSpace, NText, useMessage } from 'naive-ui';
import { ref } from 'vue'; import { ref } from 'vue';

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { UserInfo } from '@/api/api-models' import { UserInfo } from '@/api/api-models'
import { TemplateConfig } from '@/data/VTsuruTypes' import { TemplateConfig } from '@/data/VTsuruConfigTypes'
import { h } from 'vue' import { h } from 'vue'
const width = window.innerWidth const width = window.innerWidth

View File

@@ -1,16 +1,12 @@
<script lang="ts"> <script lang="ts">
// --- Define Config First ---
// NOTE: Define ConfigDefinition *before* types that depend on it.
// Use 'any' for config param in render/onUploaded to break circular dependency for now.
export const Config = defineTemplateConfig([ export const Config = defineTemplateConfig([
{ {
name: '背景图', // Removed 'as const' name: '背景图', // Removed 'as const'
type: 'image', type: 'file',
key: 'backgroundImage', // Removed 'as const' key: 'backgroundFile', // Removed 'as const'
imageLimit: 1, fileLimit: 1,
default: [] as string[], onUploaded: (files: UploadFileResponse[], config: any) => {
onUploaded: (urls: string[], config: any) => { config.backgroundFile = files;
config.backgroundImage = urls;
}, },
}, },
{ {
@@ -58,26 +54,24 @@ export const Config = defineTemplateConfig([
{ {
name: '装饰图片', name: '装饰图片',
type: 'decorativeImages', type: 'decorativeImages',
key: 'decorativeImages', key: 'decorativeFile',
default: [] as DecorativeImageProperties[],
}, },
]); ]);
export type KawaiiConfigType = ExtractConfigData<typeof Config>; export type KawaiiConfigType = ExtractConfigData<typeof Config>;
export const DefaultConfig = { export const DefaultConfig = {
} as KawaiiConfigType; } as KawaiiConfigType;
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { ScheduleDayInfo, ScheduleWeekInfo } from '@/api/api-models'; import { ScheduleDayInfo, ScheduleWeekInfo, UploadFileResponse } from '@/api/api-models';
import SaveCompoent from '@/components/SaveCompoent.vue'; // 引入截图组件 import SaveCompoent from '@/components/SaveCompoent.vue'; // 引入截图组件
import { ScheduleConfigTypeWithConfig } from '@/data/TemplateTypes'; // Use base type import { ScheduleConfigTypeWithConfig } from '@/data/TemplateTypes'; // Use base type
import { DecorativeImageProperties, defineTemplateConfig, ExtractConfigData, RGBAColor, rgbaToString } from '@/data/VTsuruTypes'; import { defineTemplateConfig, ExtractConfigData, RGBAColor, rgbaToString } from '@/data/VTsuruConfigTypes';
import { FILE_BASE_URL } from '@/data/constants';
import { getWeek, getYear } from 'date-fns'; import { getWeek, getYear } from 'date-fns';
import { NButton, NDivider, NEmpty, NFlex, NSelect, NSpace, useMessage } from 'naive-ui'; import { NDivider, NSelect, NSpace, useMessage } from 'naive-ui';
import { computed, h, ref, watch, WritableComputedRef } from 'vue'; import { computed, ref, watch, WritableComputedRef } from 'vue';
// Get message instance // Get message instance
const message = useMessage(); const message = useMessage();
@@ -87,14 +81,14 @@ const props = defineProps<ScheduleConfigTypeWithConfig<KawaiiConfigType>>();
// --- 默认配置 --- Define DefaultConfig using KawaiiConfigType // --- 默认配置 --- Define DefaultConfig using KawaiiConfigType
// No export needed here // No export needed here
const DefaultConfig: KawaiiConfigType = { const DefaultConfig: KawaiiConfigType = {
backgroundImage: [], backgroundFile: [],
containerColor: { r: 255, g: 255, b: 255, a: 0.8 }, containerColor: { r: 255, g: 255, b: 255, a: 0.8 },
dayLabelColor: { r: 126, g: 136, b: 184, a: 1 }, dayLabelColor: { r: 126, g: 136, b: 184, a: 1 },
dayContentBgColor: { r: 255, g: 255, b: 255, a: 1 }, dayContentBgColor: { r: 255, g: 255, b: 255, a: 1 },
dayContentTextColor: { r: 100, g: 100, b: 100, a: 1 }, dayContentTextColor: { r: 100, g: 100, b: 100, a: 1 },
timeLabelBgColor: { r: 245, g: 189, b: 189, a: 1 }, timeLabelBgColor: { r: 245, g: 189, b: 189, a: 1 },
timeLabelTextColor: { r: 255, g: 255, b: 255, a: 1 }, timeLabelTextColor: { r: 255, g: 255, b: 255, a: 1 },
decorativeImages: [], decorativeFile: [],
}; };
// --- 状态 --- // --- 状态 ---
@@ -225,12 +219,12 @@ defineExpose({ Config, DefaultConfig });
'--day-content-text-color': rgbaToString(effectiveConfig.dayContentTextColor), '--day-content-text-color': rgbaToString(effectiveConfig.dayContentTextColor),
'--time-label-bg-color': rgbaToString(effectiveConfig.timeLabelBgColor), '--time-label-bg-color': rgbaToString(effectiveConfig.timeLabelBgColor),
'--time-label-text-color': rgbaToString(effectiveConfig.timeLabelTextColor), '--time-label-text-color': rgbaToString(effectiveConfig.timeLabelTextColor),
backgroundImage: effectiveConfig.backgroundImage && effectiveConfig.backgroundImage.length > 0 ? `url(${FILE_BASE_URL + effectiveConfig.backgroundImage[0]})` : 'none', backgroundImage: effectiveConfig.backgroundFile && effectiveConfig.backgroundFile.length > 0 ? `url(${effectiveConfig.backgroundFile[0].path})` : 'none',
}" }"
> >
<!-- 装饰图片渲染 --> <!-- 装饰图片渲染 -->
<div <div
v-for="img in effectiveConfig.decorativeImages" v-for="img in effectiveConfig.decorativeFile"
:key="img.id" :key="img.id"
class="decorative-image" class="decorative-image"
:style="{ :style="{
@@ -247,7 +241,7 @@ defineExpose({ Config, DefaultConfig });
}" }"
> >
<img <img
:src="FILE_BASE_URL + img.src" :src="img.path"
alt="decoration" alt="decoration"
style="display: block; width: 100%; height: auto;" style="display: block; width: 100%; height: auto;"
> >

View File

@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, ref, watch, VNode } from 'vue'; import { isDarkMode } from '@/Utils';
import { getUserAvatarUrl, isDarkMode } from '@/Utils'; import { useAccount } from '@/api/account';
import { SongFrom, SongRequestOption, SongsInfo } from '@/api/api-models';
import { SongListConfigTypeWithConfig } from '@/data/TemplateTypes'; import { SongListConfigTypeWithConfig } from '@/data/TemplateTypes';
import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruTypes'; import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruConfigTypes';
import { FILE_BASE_URL } from '@/data/constants'; import { useBiliAuth } from '@/store/useBiliAuth';
import { NButton, NFlex, NIcon, NInput, NInputGroup, NInputGroupLabel, NTag, NTooltip, NSelect } from 'naive-ui';
import bilibili from '@/svgs/bilibili.svg'; import bilibili from '@/svgs/bilibili.svg';
import douyin from '@/svgs/douyin.svg';
import FiveSingIcon from '@/svgs/fivesing.svg';
import neteaseMusic from '@/svgs/neteaseMusic.svg'; import neteaseMusic from '@/svgs/neteaseMusic.svg';
import qqMusic from '@/svgs/qqMusic.svg'; import qqMusic from '@/svgs/qqMusic.svg';
import douyin from '@/svgs/douyin.svg'; import { ArrowCounterclockwise20Filled, ArrowSortDown20Filled, ArrowSortUp20Filled, SquareArrowForward24Filled } from '@vicons/fluent';
import { SongFrom, SongsInfo, SongRequestOption } from '@/api/api-models';
import FiveSingIcon from '@/svgs/fivesing.svg';
import { SquareArrowForward24Filled, ArrowCounterclockwise20Filled, ArrowSortDown20Filled, ArrowSortUp20Filled } from '@vicons/fluent';
import { List } from 'linqts'; import { List } from 'linqts';
import { useAccount } from '@/api/account'; import { NButton, NFlex, NIcon, NInput, NInputGroup, NInputGroupLabel, NSelect, NTag, NTooltip } from 'naive-ui';
import { getSongRequestTooltip, getSongRequestConfirmText } from './utils/songRequestUtils'; import { computed, h, ref, VNode, watch } from 'vue';
import { useBiliAuth } from '@/store/useBiliAuth'; import { getSongRequestConfirmText, getSongRequestTooltip } from './utils/songRequestUtils';
// Interface Tab - can be reused for both language and tag buttons // Interface Tab - can be reused for both language and tag buttons
interface FilterButton { interface FilterButton {
@@ -487,11 +486,12 @@ export const DefaultConfig = {} as TraditionalConfigType;
export const Config = defineTemplateConfig([ export const Config = defineTemplateConfig([
{ {
name: '背景', name: '背景',
type: 'image', type: 'file',
imageLimit: 1, fileLimit: 1,
key: 'background', key: 'backgroundFile',
onUploaded: (url, config) => { onUploaded: (file, config) => {
config.background = url; console.log(file, config);
config.backgroundFile = file;
}, },
}, },
{ {
@@ -645,7 +645,7 @@ export const Config = defineTemplateConfig([
<div <div
class="song-list-background-wrapper" class="song-list-background-wrapper"
:style="{ :style="{
backgroundImage: props.config?.background ? `url(${FILE_BASE_URL + props.config.background})` : 'none', backgroundImage: props.config?.backgroundFile && props.config.backgroundFile.length > 0 ? `url(${props.config.backgroundFile[0].path})` : 'none',
}" }"
> >
<!-- 原始: 滚动和内容容器 --> <!-- 原始: 滚动和内容容器 -->

View File

@@ -1,26 +1,15 @@
// vite.config.ts // vite.config.ts
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
import path, { resolve } from 'path'; import path from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import Markdown from 'unplugin-vue-markdown/vite'; import Markdown from 'unplugin-vue-markdown/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import monacoEditorPluginModule from 'vite-plugin-monaco-editor';
import caddyTls from './plugins/vite-plugin-caddy';
import { VineVitePlugin } from 'vue-vine/vite';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import oxlintPlugin from 'vite-plugin-oxlint'; import oxlintPlugin from 'vite-plugin-oxlint';
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader';
import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor' import { VineVitePlugin } from 'vue-vine/vite';
const isObjectWithDefaultFunction = (
module: unknown
): module is { default: typeof monacoEditorPluginModule; } =>
module != null &&
typeof module === 'object' &&
'default' in module &&
typeof module.default === 'function';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -36,7 +25,6 @@ export default defineConfig({
Markdown({ Markdown({
/* options */ /* options */
}), }),
caddyTls(),
AutoImport({ AutoImport({
imports: ['vue', 'vue-router', '@vueuse/core', 'pinia', 'date-fns', { imports: ['vue', 'vue-router', '@vueuse/core', 'pinia', 'date-fns', {
'naive-ui': [ 'naive-ui': [