mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 更新配置和文件上传逻辑, 迁移数据库结构(前端也得改
- 移除不再使用的 vite-plugin-monaco-editor - 更新 package.json 和 vite.config.mts 文件 - 修改用户配置 API 逻辑,支持上传和下载配置 - 添加对文件上传的支持,优化文件处理逻辑 - 更新多个组件以支持新文件上传功能 - 删除不必要的 VTsuruTypes.ts 文件,整合到 VTsuruConfigTypes.ts 中
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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; // 可选的文件对象
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
|
||||||
|
// 当使用FormData时,不手动设置Content-Type,让浏览器自动添加boundary
|
||||||
|
if (!(body instanceof FormData)) {
|
||||||
h['Content-Type'] = contentType
|
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
1
src/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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', {
|
const success = await UploadConfig(props.name || '', props.configData, true);
|
||||||
name: props.name,
|
|
||||||
json: JSON.stringify(props.configData),
|
if (success) {
|
||||||
images: images,
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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,13 +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">
|
||||||
|
<NSpace
|
||||||
|
vertical
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<NImage
|
<NImage
|
||||||
v-if="item.question?.image"
|
v-for="(img, index) in item.questionImages"
|
||||||
:src="item.question.image"
|
:key="index"
|
||||||
|
:src="img.path"
|
||||||
height="100"
|
height="100"
|
||||||
lazy
|
lazy
|
||||||
/>
|
/>
|
||||||
|
</NSpace>
|
||||||
<br>
|
<br>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,12 +149,13 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 新增:装饰性图片配置 ---
|
// --- 新增:装饰性图片配置 ---
|
||||||
@@ -130,9 +163,7 @@ export type TemplateConfigImageItem<T = unknown> = TemplateConfigItemWithType<T,
|
|||||||
/**
|
/**
|
||||||
* @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 => {
|
||||||
|
// 类型守卫确保 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;
|
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'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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
92
src/data/fileUpload.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<NSpace
|
||||||
|
vertical
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<NImage
|
<NImage
|
||||||
:src="item.question.image"
|
v-for="(img, index) in item.questionImages"
|
||||||
|
:key="index"
|
||||||
|
:src="img.path"
|
||||||
width="100"
|
width="100"
|
||||||
object-fit="cover"
|
object-fit="cover"
|
||||||
lazy
|
lazy
|
||||||
style="border-radius: 4px; margin-bottom: 5px;"
|
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">
|
||||||
内容审查等级
|
内容审查等级
|
||||||
|
|||||||
@@ -128,12 +128,15 @@ onUnmounted(() => {
|
|||||||
<div class="question-display-text">
|
<div class="question-display-text">
|
||||||
{{ question?.question.message }}
|
{{ question?.question.message }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="setting.showImage && question?.questionImages && question.questionImages.length > 0" class="question-display-images">
|
||||||
<img
|
<img
|
||||||
v-if="setting.showImage && question?.question.image"
|
v-for="(img, index) in question.questionImages"
|
||||||
|
:key="index"
|
||||||
class="question-display-image"
|
class="question-display-image"
|
||||||
:src="question?.question.image"
|
:src="img.path"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
if (data.code == 200) {
|
|
||||||
message.success('已保存至服务器')
|
message.success('已保存至服务器')
|
||||||
} else {
|
} else {
|
||||||
message.error('保存失败: ' + data.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
message.error('保存失败')
|
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) => {
|
|
||||||
if (data.code == 200) {
|
|
||||||
settings.value = JSON.parse(data.data)
|
|
||||||
message.success('已获取配置文件')
|
message.success('已获取配置文件')
|
||||||
} else if (data.code == 404) {
|
} else if (result.status === 'notfound') {
|
||||||
message.error('未上传配置文件')
|
message.error('未上传配置文件')
|
||||||
} else {
|
} else {
|
||||||
message.error('获取失败: ' + data.message)
|
message.error('获取失败: ' + result.msg)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.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
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +54,7 @@ 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>;
|
||||||
@@ -70,14 +65,13 @@ export const DefaultConfig = {
|
|||||||
|
|
||||||
|
|
||||||
<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;"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- 原始: 滚动和内容容器 -->
|
<!-- 原始: 滚动和内容容器 -->
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
Reference in New Issue
Block a user