mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 更新依赖和增强动态表单功能
- 在 package.json 中添加 hammerjs 和 tui-image-editor 依赖 - 在 DynamicForm.vue 中引入并实现装饰性图片功能,支持图片上传、删除和属性调整 - 优化颜色处理逻辑,支持 RGBA 格式 - 更新常量和类型定义,增强代码可读性和可维护性
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getImageUploadModel } from '@/Utils';
|
||||
import { QueryPostAPI } from '@/api/query';
|
||||
import { ConfigItemDefinition, TemplateConfigImageItem } from '@/data/VTsuruTypes';
|
||||
import { ConfigItemDefinition, DecorativeImageProperties, TemplateConfigImageItem, RGBAColor, rgbaToString } from '@/data/VTsuruTypes';
|
||||
import { FILE_BASE_URL, VTSURU_API_URL } from '@/data/constants';
|
||||
import { ArrowDown20Filled, ArrowUp20Filled, Delete20Filled } from '@vicons/fluent';
|
||||
import { Info24Filled } from '@vicons/fluent';
|
||||
import { NButton, NCheckbox, NColorPicker, NEmpty, NForm, NGrid, NInput, NInputNumber, NSlider, 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 } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
@@ -16,6 +19,7 @@ import { onMounted, ref } from 'vue';
|
||||
}>();
|
||||
|
||||
const fileList = ref<{ [key: string]: UploadFileInfo[]; }>({});
|
||||
const selectedImageId = ref<string | null>(null);
|
||||
|
||||
const isUploading = ref(false);
|
||||
|
||||
@@ -70,6 +74,183 @@ import { onMounted, ref } from 'vue';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 颜色转换辅助函数 ---
|
||||
function stringToRgba(colorString: string | null | undefined): RGBAColor {
|
||||
const defaultColor: RGBAColor = { r: 0, g: 0, b: 0, a: 1 }; // 默认黑色不透明
|
||||
if (!colorString) return defaultColor;
|
||||
|
||||
try {
|
||||
// 尝试匹配 rgba(r, g, b, a)
|
||||
const rgbaMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbaMatch) {
|
||||
return {
|
||||
r: parseInt(rgbaMatch[1], 10),
|
||||
g: parseInt(rgbaMatch[2], 10),
|
||||
b: parseInt(rgbaMatch[3], 10),
|
||||
a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RRGGBBAA
|
||||
const hex8Match = colorString.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (hex8Match) {
|
||||
return {
|
||||
r: parseInt(hex8Match[1], 16),
|
||||
g: parseInt(hex8Match[2], 16),
|
||||
b: parseInt(hex8Match[3], 16),
|
||||
a: parseInt(hex8Match[4], 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RRGGBB
|
||||
const hex6Match = colorString.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (hex6Match) {
|
||||
return {
|
||||
r: parseInt(hex6Match[1], 16),
|
||||
g: parseInt(hex6Match[2], 16),
|
||||
b: parseInt(hex6Match[3], 16),
|
||||
a: 1, // Hex6 doesn't have alpha, assume 1
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RGBA
|
||||
const shortHex4Match = colorString.match(/^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])$/i);
|
||||
if (shortHex4Match) {
|
||||
return {
|
||||
r: parseInt(shortHex4Match[1] + shortHex4Match[1], 16),
|
||||
g: parseInt(shortHex4Match[2] + shortHex4Match[2], 16),
|
||||
b: parseInt(shortHex4Match[3] + shortHex4Match[3], 16),
|
||||
a: parseInt(shortHex4Match[4] + shortHex4Match[4], 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RGB
|
||||
const shortHex3Match = colorString.match(/^#?([a-f\d])([a-f\d])([a-f\d])$/i);
|
||||
if (shortHex3Match) {
|
||||
return {
|
||||
r: parseInt(shortHex3Match[1] + shortHex3Match[1], 16),
|
||||
g: parseInt(shortHex3Match[2] + shortHex3Match[2], 16),
|
||||
b: parseInt(shortHex3Match[3] + shortHex3Match[3], 16),
|
||||
a: 1, // Hex3 doesn't have alpha, assume 1
|
||||
};
|
||||
}
|
||||
|
||||
console.warn(`无法解析颜色字符串: "${colorString}", 已返回默认颜色`);
|
||||
return defaultColor;
|
||||
} catch (e) {
|
||||
console.error(`解析颜色字符串 "${colorString}" 时出错:`, e);
|
||||
return defaultColor;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 rgbaToString 也能处理 null/undefined
|
||||
const safeRgbaToString = (color: RGBAColor | null | undefined): string => {
|
||||
return rgbaToString(color ?? { r: 0, g: 0, b: 0, a: 1 }); // 提供默认值以防万一
|
||||
}
|
||||
|
||||
// 装饰图片功能
|
||||
const updateImageProp = (id: string, prop: keyof DecorativeImageProperties, value: any, key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
const index = images.findIndex(img => img.id === id);
|
||||
if (index !== -1) {
|
||||
const updatedImages = [...images];
|
||||
updatedImages[index] = { ...updatedImages[index], [prop]: value };
|
||||
props.configData[key] = updatedImages;
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (id: string, key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
props.configData[key] = images.filter(img => img.id !== id);
|
||||
if (selectedImageId.value === id) {
|
||||
selectedImageId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const changeZIndex = (id: string, direction: 'up' | 'down', key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
const index = images.findIndex(img => img.id === id);
|
||||
if (index === -1) return;
|
||||
const newImages = [...images];
|
||||
if (direction === 'up' && index < newImages.length - 1) {
|
||||
[newImages[index], newImages[index + 1]] = [newImages[index + 1], newImages[index]];
|
||||
} else if (direction === 'down' && index > 0) {
|
||||
[newImages[index], newImages[index - 1]] = [newImages[index - 1], newImages[index]];
|
||||
}
|
||||
newImages.forEach((img, i) => img.zIndex = i + 1);
|
||||
props.configData[key] = newImages;
|
||||
};
|
||||
|
||||
const renderDecorativeImages = (key: string) => {
|
||||
// 获取全局处理器
|
||||
const uploadHandler = (window as any).$upload;
|
||||
const messageHandler = (window as any).$message ?? message;
|
||||
|
||||
return h(NFlex, { vertical: true, size: 'large' }, () => [
|
||||
// 上传按钮
|
||||
h(NUpload, {
|
||||
multiple: true, accept: 'image/*', showFileList: false,
|
||||
'onUpdate:fileList': (fileList: UploadFileInfo[]) => {
|
||||
if (uploadHandler?.upload && fileList.length > 0) {
|
||||
const filesToUpload = fileList.map(f => f.file).filter((f): f is File => f instanceof File);
|
||||
if (filesToUpload.length > 0) {
|
||||
uploadHandler.upload(filesToUpload, '/api/file/upload')
|
||||
.then((results: any[]) => {
|
||||
const newImages: DecorativeImageProperties[] = results.map((result: any, index: number) => ({
|
||||
id: uuidv4(), src: result.url, 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];
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("图片上传失败:", error);
|
||||
messageHandler?.error("图片上传失败: " + (error?.message ?? error));
|
||||
});
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}, { default: () => h(NButton, null, () => '添加装饰图片') }),
|
||||
|
||||
// 图片列表
|
||||
h(NScrollbar, { style: { maxHeight: '300px', marginTop: '10px' } }, () => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[] || [];
|
||||
return images.length > 0
|
||||
? images.map((img: DecorativeImageProperties) => {
|
||||
const isSelected = selectedImageId.value === img.id;
|
||||
return h(NCard, {
|
||||
key: img.id, size: 'small', hoverable: true,
|
||||
style: { marginBottom: '10px', cursor: 'pointer', border: isSelected ? '2px solid var(--primary-color)' : '1px solid #eee' },
|
||||
onClick: () => selectedImageId.value = img.id
|
||||
}, {
|
||||
default: () => h(NFlex, { justify: 'space-between', align: 'center' }, () => [
|
||||
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('span', `ID: ...${img.id.slice(-4)}`)
|
||||
]),
|
||||
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, 'down', key); } }, { icon: () => h(NIcon, { component: ArrowDown20Filled }) }),
|
||||
h(NButton, { size: 'tiny', circle: true, type: 'error', ghost: true, title: '删除', onClick: (e: Event) => { e.stopPropagation(); removeImage(img.id, key); } }, { icon: () => h(NIcon, { component: Delete20Filled }) })
|
||||
])
|
||||
]),
|
||||
footer: () => isSelected ? h(NFlex, { vertical: true, size: 'small', style: { marginTop: '10px' } }, () => [
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, 'X (%):'), h(NInputNumber, { value: img.x, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'x', v ?? 0, key), min: 0, max: 100, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, 'Y (%):'), h(NInputNumber, { value: img.y, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'y', v ?? 0, key), min: 0, max: 100, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '宽度(%):'), h(NInputNumber, { value: img.width, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'width', v ?? 1, key), min: 1, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '旋转(°):'), h(NInputNumber, { value: img.rotation, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'rotation', v ?? 0, key), min: -360, max: 360, step: 1 }), h(NSlider, { value: img.rotation, 'onUpdate:value': (v: number | number[]) => updateImageProp(img.id, 'rotation', Array.isArray(v) ? v[0] : v ?? 0, key), min: -180, max: 180, step: 1, 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 })]),
|
||||
]) : null
|
||||
})
|
||||
})
|
||||
: h(NEmpty, { description: '暂无装饰图片' })
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
function getItems() { }
|
||||
onMounted(() => {
|
||||
props.config?.forEach(item => {
|
||||
@@ -115,6 +296,10 @@ import { onMounted, ref } from 'vue';
|
||||
:is="item.render(configData)"
|
||||
v-if="item.type == 'render'"
|
||||
/>
|
||||
<component
|
||||
:is="renderDecorativeImages(item.key)"
|
||||
v-else-if="item.type == 'decorativeImages'"
|
||||
/>
|
||||
<template v-else-if="item.type == 'string'">
|
||||
<NInput
|
||||
:value="configData[item.key]"
|
||||
@@ -125,9 +310,9 @@ import { onMounted, ref } from 'vue';
|
||||
</template>
|
||||
<NColorPicker
|
||||
v-else-if="item.type == 'color'"
|
||||
:value="configData[item.key]"
|
||||
:value="safeRgbaToString(configData[item.key])"
|
||||
:show-alpha="item.showAlpha ?? false"
|
||||
@update:value="configData[item.key] = $event"
|
||||
@update:value="configData[item.key] = stringToRgba($event)"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-else-if="item.type == 'number'"
|
||||
@@ -146,7 +331,8 @@ import { onMounted, ref } from 'vue';
|
||||
<template v-else-if="item.type == 'boolean'">
|
||||
<NCheckbox
|
||||
:checked="configData[item.key]"
|
||||
@update:checked="configData[item.key] = $event">
|
||||
@update:checked="configData[item.key] = $event"
|
||||
>
|
||||
启用
|
||||
</NCheckbox>
|
||||
<NTooltip
|
||||
|
||||
@@ -21,5 +21,8 @@ export interface ScheduleConfigType {
|
||||
userInfo: UserInfo | undefined
|
||||
biliInfo: any | undefined
|
||||
data: ScheduleWeekInfo[] | undefined
|
||||
config?: any
|
||||
}
|
||||
|
||||
export interface ScheduleConfigTypeWithConfig<T> extends ScheduleConfigType {
|
||||
config?: T
|
||||
}
|
||||
|
||||
@@ -88,9 +88,18 @@ export type TemplateConfigNumberItem<T = unknown> = TemplateConfigItemWithType<T
|
||||
|
||||
};
|
||||
|
||||
export type TemplateConfigColorItem<T = unknown> = TemplateConfigItemWithType<T, number> & {
|
||||
// RGBA颜色对象接口
|
||||
export interface RGBAColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
// 修改 TemplateConfigColorItem 以使用 RGBAColor 接口
|
||||
export type TemplateConfigColorItem<T = unknown> = TemplateConfigItemWithType<T, RGBAColor> & {
|
||||
type: 'color';
|
||||
showAlpha?: boolean;
|
||||
showAlpha?: boolean; // 控制是否显示透明度调整
|
||||
};
|
||||
|
||||
export type TemplateConfigSliderNumberItem<T = unknown> = TemplateConfigItemWithType<T, number> & {
|
||||
@@ -108,11 +117,57 @@ export type TemplateConfigBooleanItem<T = unknown> = TemplateConfigItemWithType<
|
||||
description?: string; // 可选的描述
|
||||
};
|
||||
|
||||
// 修改 TemplateConfigImageItem 以支持单个或多个图片,并返回完整 URL
|
||||
export type TemplateConfigImageItem<T = unknown> = TemplateConfigItemWithType<T, string[]> & {
|
||||
type: 'image';
|
||||
imageLimit: number; // 图片数量限制
|
||||
// onUploaded 的 data 现在是 string[]
|
||||
onUploaded?: (data: string[], config: T) => void;
|
||||
};
|
||||
|
||||
// --- 新增:装饰性图片配置 ---
|
||||
|
||||
/**
|
||||
* @description 单个装饰图片的属性接口
|
||||
*/
|
||||
export interface DecorativeImageProperties {
|
||||
id: string; // 唯一标识符 (例如 UUID 或时间戳)
|
||||
src: string; // 图片 URL
|
||||
x: number; // X 坐标 (%)
|
||||
y: number; // Y 坐标 (%)
|
||||
width: number; // 宽度 (%)
|
||||
// height: number; // 高度通常由宽度和图片比例决定,或设为 auto
|
||||
rotation: number; // 旋转角度 (deg)
|
||||
opacity: number; // 透明度 (0-1)
|
||||
zIndex: number; // 层叠顺序
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 用于管理装饰性图片数组的渲染配置项。
|
||||
* 由于 UI 复杂性,使用 TemplateConfigRenderItem。
|
||||
* @template T - 完整配置对象的类型 (默认为 unknown)。
|
||||
*/
|
||||
export interface TemplateConfigDecorativeImagesItem<T = unknown> extends TemplateConfigBase {
|
||||
type: 'decorativeImages'; // 新类型标识符
|
||||
default?: DecorativeImageProperties[]; // 默认值是图片属性数组
|
||||
|
||||
/**
|
||||
* @description 渲染此项的自定义 VNode (配置 UI)。
|
||||
* @param config 整个配置对象 (类型为 T, 默认为 unknown)。
|
||||
* @returns 表示配置 UI 的 VNode。
|
||||
*/
|
||||
render?(config: T): VNode;
|
||||
|
||||
/**
|
||||
* @description 当装饰图片数组更新时调用的回调。
|
||||
* @param data 更新后的 DecorativeImageProperties 数组。
|
||||
* @param config 整个配置对象。
|
||||
*/
|
||||
onUploaded?(data: DecorativeImageProperties[], config: T): void; // data 类型是数组
|
||||
|
||||
// 继承 TemplateConfigBase 的 default?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 自定义渲染项的配置。使用 'this' 类型实现动态参数类型。
|
||||
* @template T - 完整配置对象的类型 (默认为 unknown)。
|
||||
@@ -162,6 +217,7 @@ export type ConfigItemDefinition =
|
||||
| TemplateConfigNumberArrayItem<any>
|
||||
| TemplateConfigImageItem<any>
|
||||
| TemplateConfigRenderItem<any> // 包含优化后的 render/onUploaded 方法
|
||||
| TemplateConfigDecorativeImagesItem<any> // 新增装饰图片类型
|
||||
| TemplateConfigSliderNumberItem<any>
|
||||
| TemplateConfigBooleanItem<any>
|
||||
| TemplateConfigColorItem<any>;
|
||||
@@ -187,6 +243,9 @@ export type ExtractConfigData<
|
||||
: ItemWithKeyK extends { type: 'number' | 'sliderNumber' | 'color'; } ? number
|
||||
: ItemWithKeyK extends { type: 'numberArray'; } ? number[]
|
||||
: ItemWithKeyK extends { type: 'image'; } ? string[]
|
||||
: ItemWithKeyK extends { type: 'boolean'; } ? boolean
|
||||
: ItemWithKeyK extends { type: 'color'; } ? RGBAColor
|
||||
: ItemWithKeyK extends { type: 'decorativeImages'; } ? DecorativeImageProperties[]
|
||||
// *** 优化应用:无 default 的 render 类型回退到 'unknown' ***
|
||||
: ItemWithKeyK extends { type: 'render'; } ? unknown
|
||||
// 其他意外情况的回退类型
|
||||
@@ -206,4 +265,10 @@ export function defineTemplateConfig<
|
||||
// 如果需要,可以在此处添加基本的运行时验证。
|
||||
// 类型检查主要由 TypeScript 根据约束完成。
|
||||
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})`;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const BASE_HUB_URL =
|
||||
|
||||
export const TURNSTILE_KEY = '0x4AAAAAAAETUSAKbds019h0';
|
||||
|
||||
export const CURRENT_HOST = `${window.location.protocol}//${window.location.host}/`;
|
||||
export const CURRENT_HOST = `${window.location.protocol}//${isDev ? window.location.host : 'vtsuru.live'}/`;
|
||||
export const CN_HOST = 'https://vtsuru.suki.club/';
|
||||
|
||||
export const USER_API_URL = BASE_API_URL + 'user/';
|
||||
@@ -84,6 +84,13 @@ export const ScheduleTemplateMap: TemplateMapType = {
|
||||
component: markRaw(defineAsyncComponent(
|
||||
() => import('@/views/view/scheduleTemplate/PinkySchedule.vue')
|
||||
))
|
||||
},
|
||||
kawaii: {
|
||||
name: '可爱手帐 (未完成',
|
||||
settingName: 'Template.Schedule.Kawaii',
|
||||
component: markRaw(defineAsyncComponent(
|
||||
() => import('@/views/view/scheduleTemplate/KawaiiSchedule.vue')
|
||||
))
|
||||
}
|
||||
};
|
||||
export const SongListTemplateMap: TemplateMapType = {
|
||||
|
||||
@@ -221,12 +221,12 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
|
||||
>
|
||||
<NButton
|
||||
tag="a"
|
||||
href="https://keydb.dev/"
|
||||
href="hhttps://microsoft.github.io/garnet/"
|
||||
target="_blank"
|
||||
text
|
||||
style="padding: 0; color: inherit;"
|
||||
>
|
||||
KeyDB
|
||||
Garnet
|
||||
</NButton>
|
||||
</NTag>
|
||||
</div>
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
>
|
||||
<NAvatar
|
||||
class="sider-avatar"
|
||||
:class="{ 'streaming-avatar': userInfo?.streamerInfo?.isStreaming }"
|
||||
:src="userInfo.streamerInfo.faceUrl"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
round
|
||||
@@ -369,9 +370,20 @@
|
||||
v-if="siderWidth > 100"
|
||||
style="max-width: 100%"
|
||||
>
|
||||
<NText strong>
|
||||
{{ userInfo?.streamerInfo.name }}
|
||||
</NText>
|
||||
<NSpace
|
||||
align="center"
|
||||
:size="4"
|
||||
:wrap="false"
|
||||
>
|
||||
<NText strong>
|
||||
{{ userInfo?.streamerInfo.name }}
|
||||
</NText>
|
||||
<span
|
||||
v-if="userInfo?.streamerInfo?.isStreaming"
|
||||
class="live-indicator-dot"
|
||||
title="直播中"
|
||||
/>
|
||||
</NSpace>
|
||||
</NEllipsis>
|
||||
</NSpace>
|
||||
</div>
|
||||
@@ -530,6 +542,7 @@
|
||||
:root {
|
||||
--vtsuru-header-height: 50px; // 顶部导航栏高度
|
||||
--vtsuru-content-padding: 20px; // 内容区域内边距
|
||||
--streaming-glow-color: #00ff00; // 直播状态光晕颜色
|
||||
}
|
||||
|
||||
// --- 布局样式 ---
|
||||
@@ -561,10 +574,30 @@
|
||||
.sider-avatar {
|
||||
box-shadow: var(--n-avatar-box-shadow, 0 2px 3px rgba(0, 0, 0, 0.1)); // 使用 Naive UI 变量或默认值
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease; // 添加悬浮效果
|
||||
transition: transform 0.2s ease, box-shadow 0.3s ease; // 添加悬浮效果和阴影过渡
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&.streaming-avatar {
|
||||
border: 2px solid var(--streaming-glow-color);
|
||||
box-shadow: 0 0 10px var(--streaming-glow-color), 0 0 15px var(--streaming-glow-color) inset;
|
||||
animation: pulse 1.5s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 5px var(--streaming-glow-color), 0 0 8px var(--streaming-glow-color) inset;
|
||||
border-color: rgba(0, 255, 0, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px var(--streaming-glow-color), 0 0 18px var(--streaming-glow-color) inset;
|
||||
border-color: var(--streaming-glow-color);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--streaming-glow-color), 0 0 8px var(--streaming-glow-color) inset;
|
||||
border-color: rgba(0, 255, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.sider-username {
|
||||
@@ -666,4 +699,31 @@
|
||||
.n-back-top {
|
||||
z-index: 10; // 确保在最上层
|
||||
}
|
||||
|
||||
.live-indicator-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #66bb6a; // 改为柔和绿色
|
||||
margin-left: 4px; // 与用户名稍微隔开
|
||||
vertical-align: middle; // 垂直居中对齐
|
||||
box-shadow: 0 0 4px #66bb6a; // 同色阴影
|
||||
animation: dot-pulse 1.5s infinite ease-in-out; // 添加脉冲动画
|
||||
}
|
||||
|
||||
@keyframes dot-pulse { // 定义绿点脉冲动画
|
||||
0% {
|
||||
box-shadow: 0 0 3px #66bb6a;
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 6px #66bb6a;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 3px #66bb6a;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -241,12 +241,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, computed, nextTick, onUnmounted, watch } from 'vue';
|
||||
import { NCard, NGrid, NGridItem, NSpin, NStatistic, NTabPane, NTabs, useMessage, NTag, NIcon, NDivider, NFlex, NSpace } from 'naive-ui';
|
||||
import * as echarts from 'echarts';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { LineChart, BarChart } from 'echarts/charts';
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
DataZoomComponent
|
||||
} from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { QueryGetAPI } from '@/api/query';
|
||||
import { ANALYZE_API_URL } from '@/data/constants';
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { TrendingDown, TrendingUp } from '@vicons/ionicons5';
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
BarChart,
|
||||
CanvasRenderer,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
DataZoomComponent
|
||||
]);
|
||||
|
||||
// types.ts
|
||||
interface ChartItem {
|
||||
income: number;
|
||||
|
||||
@@ -62,8 +62,9 @@ const shareModalVisiable = ref(false) // 分享模态框可见性
|
||||
const showOBSModal = ref(false) // OBS预览模态框可见性
|
||||
const replyMessage = ref('') // 回复输入框内容
|
||||
const addTagName = ref('') // 添加标签输入框内容
|
||||
const useCNUrl = useStorage('Settings.UseCNUrl', false) // 是否使用国内镜像URL (持久化存储)
|
||||
const shareCardRef = ref<HTMLElement | null>(null) // 分享卡片DOM引用
|
||||
const selectedShareTag = ref<string | null>(null) // 分享时选择的标签
|
||||
const selectedDirectShareTag = ref<string | null>(null) // 主链接区域选择的标签
|
||||
const ps = ref(20) // 分页大小 (每页条数)
|
||||
const pn = ref(1) // 当前页码
|
||||
const savedCardSize = useStorage<{ width: number; height: number }>('Settings.QuestionDisplay.CardSize', { // 问题展示卡片尺寸 (持久化存储)
|
||||
@@ -85,10 +86,17 @@ const setting = computed({
|
||||
},
|
||||
})
|
||||
|
||||
// 分享链接 (当前域名)
|
||||
const shareUrl = computed(() => `${CURRENT_HOST}@${accountInfo.value?.name}/question-box`)
|
||||
// 分享链接 (国内镜像)
|
||||
const shareUrlCN = computed(() => `${CN_HOST}@${accountInfo.value?.name}/question-box`)
|
||||
// 分享链接 (统一 Host, 根据选择的标签附加参数)
|
||||
const shareUrlWithTag = (tag: string | null) => {
|
||||
const base = `${CURRENT_HOST}@${accountInfo.value?.name}/question-box`
|
||||
return tag ? `${base}?tag=${encodeURIComponent(tag)}` : base
|
||||
}
|
||||
|
||||
// 主链接区域显示的链接
|
||||
const directShareUrl = computed(() => shareUrlWithTag(selectedDirectShareTag.value))
|
||||
|
||||
// 分享模态框中的二维码/卡片链接 (也基于selectedShareTag)
|
||||
const modalShareUrl = computed(() => shareUrlWithTag(selectedShareTag.value))
|
||||
|
||||
// 分页后的问题列表 (仅限收到的问题)
|
||||
const pagedQuestions = computed(() =>
|
||||
@@ -181,9 +189,9 @@ function saveShareImage() {
|
||||
|
||||
// 保存二维码图片
|
||||
function saveQRCode() {
|
||||
if (!shareUrl.value || !accountInfo.value?.name) return
|
||||
if (!modalShareUrl.value || !accountInfo.value?.name) return
|
||||
// 使用 QR Server API 生成并下载二维码
|
||||
downloadImage(`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareUrl.value)}`, `vtsuru-提问箱二维码-${accountInfo.value.name}.png`)
|
||||
downloadImage(`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(modalShareUrl.value)}`, `vtsuru-提问箱二维码-${accountInfo.value.name}.png`)
|
||||
message.success('二维码已开始下载')
|
||||
}
|
||||
|
||||
@@ -362,21 +370,28 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
|
||||
提问页链接
|
||||
</NDivider>
|
||||
<NFlex align="center">
|
||||
<NInputGroup style="max-width: 400px;">
|
||||
<!-- 主链接区域输入框和复制按钮 -->
|
||||
<NInputGroup style="flex-grow: 1; max-width: 500px;">
|
||||
<NInput
|
||||
:value="`${useCNUrl ? shareUrlCN : shareUrl}`"
|
||||
:value="directShareUrl"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(`${useCNUrl ? shareUrlCN : shareUrl}`)"
|
||||
@click="copyToClipboard(directShareUrl)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="useCNUrl">
|
||||
使用国内镜像(访问更快)
|
||||
</NCheckbox>
|
||||
<!-- 主链接区域标签选择器 -->
|
||||
<NSelect
|
||||
v-model:value="selectedDirectShareTag"
|
||||
placeholder="附加话题 (可选)"
|
||||
filterable
|
||||
clearable
|
||||
:options="useQB.tags.filter(t => t.visiable).map((s) => ({ label: s.name, value: s.name }))"
|
||||
style="min-width: 150px; max-width: 200px;"
|
||||
/>
|
||||
</NFlex>
|
||||
|
||||
<!-- 审核中提示 -->
|
||||
@@ -696,7 +711,7 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
|
||||
closable
|
||||
style="margin-bottom: 10px;"
|
||||
>
|
||||
这里存放的是被内容审查机制自动过滤的提问。您可以查看、删除或将其标记为正常提问。标记为正常后,提问将移至“我收到的”列表。
|
||||
这里存放的是被内容审查机制自动过滤的提问。您可以查看、删除或将其标记为正常提问。标记为正常后,提问将移至"我收到的"列表。
|
||||
</NAlert>
|
||||
<NEmpty
|
||||
v-if="useQB.trashQuestions.length === 0"
|
||||
@@ -1034,7 +1049,7 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
|
||||
</div>
|
||||
<div class="share-card-qr">
|
||||
<QrcodeVue
|
||||
:value="shareUrl"
|
||||
:value="modalShareUrl"
|
||||
level="Q"
|
||||
:size="90"
|
||||
background="#FFFFFF"
|
||||
@@ -1046,31 +1061,32 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NDivider style="margin-top: 20px; margin-bottom: 10px;">
|
||||
分享链接设置
|
||||
</NDivider>
|
||||
<NSpace vertical>
|
||||
<NSelect
|
||||
v-model:value="selectedShareTag"
|
||||
placeholder="选择要附加到链接的话题 (可选)"
|
||||
filterable
|
||||
clearable
|
||||
:options="useQB.tags.filter(t => t.visiable).map((s) => ({ label: s.name, value: s.name }))"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NDivider style="margin-top: 20px; margin-bottom: 10px;">
|
||||
分享链接
|
||||
</NDivider>
|
||||
<NInputGroup>
|
||||
<NInputGroupLabel> 默认 </NInputGroupLabel>
|
||||
<NInputGroupLabel> 链接 </NInputGroupLabel>
|
||||
<NInput
|
||||
:value="shareUrl"
|
||||
:value="modalShareUrl"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(shareUrl)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NInputGroup style="margin-top: 5px;">
|
||||
<NInputGroupLabel> 国内 </NInputGroupLabel>
|
||||
<NInput
|
||||
:value="shareUrlCN"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(shareUrlCN)"
|
||||
@click="copyToClipboard(modalShareUrl)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
|
||||
@@ -136,8 +136,6 @@ const showCopyModal = ref(false)
|
||||
const updateScheduleModel = ref<ScheduleWeekInfo>({} as ScheduleWeekInfo)
|
||||
const selectedExistTag = ref()
|
||||
|
||||
const useCNUrl = useStorage('Settings.UseCNUrl', false)
|
||||
|
||||
const selectedDay = ref(0)
|
||||
const selectedScheduleYear = ref(new Date().getFullYear())
|
||||
const selectedScheduleWeek = ref(Number(format(Date.now(), 'w')) + 1)
|
||||
@@ -270,35 +268,51 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<NSpace align="center">
|
||||
<NAlert :type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Schedule) ? 'success' : 'warning'"
|
||||
style="max-width: 200px">
|
||||
<NAlert
|
||||
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Schedule) ? 'success' : 'warning'"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
启用日程表
|
||||
<NDivider vertical />
|
||||
<NSwitch :value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Schedule)"
|
||||
@update:value="setFunctionEnable" />
|
||||
<NSwitch
|
||||
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Schedule)"
|
||||
@update:value="setFunctionEnable"
|
||||
/>
|
||||
</NAlert>
|
||||
<NButton type="primary" @click="showAddModal = true">
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
添加周程
|
||||
</NButton>
|
||||
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'template', template: 'schedule' } })">
|
||||
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'setting', setting: 'template', template: 'schedule' } })">
|
||||
修改模板
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NDivider style="margin: 16px 0 16px 0" title-placement="left">
|
||||
<NDivider
|
||||
style="margin: 16px 0 16px 0"
|
||||
title-placement="left"
|
||||
>
|
||||
日程表展示页链接
|
||||
</NDivider>
|
||||
<NFlex align="center">
|
||||
<NInputGroup style="max-width: 400px;">
|
||||
<NInput :value="`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/schedule`" readonly />
|
||||
<NButton secondary @click="copyToClipboard(`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/schedule`)">
|
||||
<NInput
|
||||
:value="`${CURRENT_HOST}@${accountInfo.name}/schedule`"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/schedule`)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="useCNUrl">
|
||||
使用国内镜像(访问更快)
|
||||
</NCheckbox>
|
||||
</NFlex>
|
||||
<NDivider style="margin: 16px 0 16px 0" title-placement="left">
|
||||
<NDivider
|
||||
style="margin: 16px 0 16px 0"
|
||||
title-placement="left"
|
||||
>
|
||||
订阅链接
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
@@ -311,42 +325,84 @@ onMounted(() => {
|
||||
</NDivider>
|
||||
<NFlex align="center">
|
||||
<NInputGroup style="max-width: 400px;">
|
||||
<NInput :value="`${SCHEDULE_API_URL}${accountInfo.id}.ics`" readonly />
|
||||
<NButton secondary @click="copyToClipboard(`${SCHEDULE_API_URL}${accountInfo.id}.ics`)">
|
||||
<NInput
|
||||
:value="`${SCHEDULE_API_URL}${accountInfo.id}.ics`"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(`${SCHEDULE_API_URL}${accountInfo.id}.ics`)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
</NFlex>
|
||||
<NDivider />
|
||||
<NModal v-model:show="showAddModal" style="width: 600px; max-width: 90vw" preset="card" title="添加周程">
|
||||
<NModal
|
||||
v-model:show="showAddModal"
|
||||
style="width: 600px; max-width: 90vw"
|
||||
preset="card"
|
||||
title="添加周程"
|
||||
>
|
||||
<NSpace vertical>
|
||||
年份
|
||||
<NSelect v-model:value="selectedScheduleYear" :options="yearOptions" />
|
||||
<NSelect
|
||||
v-model:value="selectedScheduleYear"
|
||||
:options="yearOptions"
|
||||
/>
|
||||
第几周
|
||||
<NSelect v-model:value="selectedScheduleWeek" :options="weekOptions" />
|
||||
<NSelect
|
||||
v-model:value="selectedScheduleWeek"
|
||||
:options="weekOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
<NButton :loading="isFetching" @click="addSchedule">
|
||||
<NButton
|
||||
:loading="isFetching"
|
||||
@click="addSchedule"
|
||||
>
|
||||
添加
|
||||
</NButton>
|
||||
</NModal>
|
||||
<NModal v-model:show="showCopyModal" style="width: 600px; max-width: 90vw" preset="card" title="复制周程">
|
||||
<NModal
|
||||
v-model:show="showCopyModal"
|
||||
style="width: 600px; max-width: 90vw"
|
||||
preset="card"
|
||||
title="复制周程"
|
||||
>
|
||||
<NAlert type="info">
|
||||
复制为
|
||||
</NAlert>
|
||||
<NSpace vertical>
|
||||
年份
|
||||
<NSelect v-model:value="selectedScheduleYear" :options="yearOptions" />
|
||||
<NSelect
|
||||
v-model:value="selectedScheduleYear"
|
||||
:options="yearOptions"
|
||||
/>
|
||||
第几周
|
||||
<NSelect v-model:value="selectedScheduleWeek" :options="weekOptions" />
|
||||
<NSelect
|
||||
v-model:value="selectedScheduleWeek"
|
||||
:options="weekOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
<NButton :loading="isFetching" @click="onCopySchedule">
|
||||
<NButton
|
||||
:loading="isFetching"
|
||||
@click="onCopySchedule"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NModal>
|
||||
<NModal v-model:show="showUpdateModal" style="width: 600px; max-width: 90vw" preset="card" title="编辑周程">
|
||||
<NSelect v-model:value="selectedDay" :options="dayOptions" />
|
||||
<NModal
|
||||
v-model:show="showUpdateModal"
|
||||
style="width: 600px; max-width: 90vw"
|
||||
preset="card"
|
||||
title="编辑周程"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="selectedDay"
|
||||
:options="dayOptions"
|
||||
/>
|
||||
<NDivider />
|
||||
<template v-if="updateScheduleModel">
|
||||
<NSpace vertical>
|
||||
@@ -355,29 +411,66 @@ onMounted(() => {
|
||||
<NInputGroupLabel type="primary">
|
||||
标签
|
||||
</NInputGroupLabel>
|
||||
<NInput v-model:value="updateScheduleModel.days[selectedDay].tag" placeholder="标签 | 留空视为无安排"
|
||||
style="max-width: 300px" maxlength="10" show-count />
|
||||
<NInput
|
||||
v-model:value="updateScheduleModel.days[selectedDay].tag"
|
||||
placeholder="标签 | 留空视为无安排"
|
||||
style="max-width: 300px"
|
||||
maxlength="10"
|
||||
show-count
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NSelect v-model:value="selectedExistTag" :options="existTagOptions" filterable clearable placeholder="使用过的标签"
|
||||
style="max-width: 150px" :render-option="renderOption" @update:value="onSelectChange" />
|
||||
<NSelect
|
||||
v-model:value="selectedExistTag"
|
||||
:options="existTagOptions"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="使用过的标签"
|
||||
style="max-width: 150px"
|
||||
:render-option="renderOption"
|
||||
@update:value="onSelectChange"
|
||||
/>
|
||||
</NSpace>
|
||||
<NInputGroup>
|
||||
<NInputGroupLabel> 内容 </NInputGroupLabel>
|
||||
<NInput v-model:value="updateScheduleModel.days[selectedDay].title" placeholder="内容" style="max-width: 200px"
|
||||
maxlength="30" show-count />
|
||||
<NInput
|
||||
v-model:value="updateScheduleModel.days[selectedDay].title"
|
||||
placeholder="内容"
|
||||
style="max-width: 200px"
|
||||
maxlength="30"
|
||||
show-count
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NTimePicker v-model:formatted-value="updateScheduleModel.days[selectedDay].time"
|
||||
default-formatted-value="20:00" format="HH:mm" />
|
||||
<NColorPicker v-model:value="updateScheduleModel.days[selectedDay].tagColor"
|
||||
:swatches="['#FFFFFF', '#18A058', '#2080F0', '#F0A020', 'rgba(208, 48, 80, 1)']" default-value="#61B589"
|
||||
:show-alpha="false" :modes="['hex']" />
|
||||
<NButton :loading="isFetching" @click="onUpdateSchedule()">
|
||||
<NTimePicker
|
||||
v-model:formatted-value="updateScheduleModel.days[selectedDay].time"
|
||||
default-formatted-value="20:00"
|
||||
format="HH:mm"
|
||||
/>
|
||||
<NColorPicker
|
||||
v-model:value="updateScheduleModel.days[selectedDay].tagColor"
|
||||
:swatches="['#FFFFFF', '#18A058', '#2080F0', '#F0A020', 'rgba(208, 48, 80, 1)']"
|
||||
default-value="#61B589"
|
||||
:show-alpha="false"
|
||||
:modes="['hex']"
|
||||
/>
|
||||
<NButton
|
||||
:loading="isFetching"
|
||||
@click="onUpdateSchedule()"
|
||||
>
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
<NSpin v-if="isLoading" show />
|
||||
<ScheduleList v-else :schedules="schedules ?? []" is-self @on-update="onOpenUpdateModal" @on-delete="onDeleteSchedule"
|
||||
@on-copy="onOpenCopyModal" />
|
||||
<NSpin
|
||||
v-if="isLoading"
|
||||
show
|
||||
/>
|
||||
<ScheduleList
|
||||
v-else
|
||||
:schedules="schedules ?? []"
|
||||
is-self
|
||||
@on-update="onOpenUpdateModal"
|
||||
@on-delete="onDeleteSchedule"
|
||||
@on-copy="onOpenCopyModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -58,7 +58,6 @@ const accountInfo = useAccount()
|
||||
const isLoading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const showModalRenderKey = ref(0)
|
||||
const useCNUrl = useStorage('Settings.UseCNUrl', false)
|
||||
const onlyResetNameOnAdded = ref(true)
|
||||
|
||||
// 歌曲列表数据
|
||||
@@ -750,19 +749,16 @@ onMounted(async () => {
|
||||
<NFlex align="center">
|
||||
<NInputGroup style="max-width: 400px;">
|
||||
<NInput
|
||||
:value="`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/song-list`"
|
||||
:value="`${CURRENT_HOST}@${accountInfo.name}/song-list`"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/song-list`)"
|
||||
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/song-list`)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="useCNUrl">
|
||||
使用国内镜像(访问更快)
|
||||
</NCheckbox>
|
||||
</NFlex>
|
||||
<NDivider style="margin: 16px 0 16px 0" />
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ const useBiliAuth = useAuthStore()
|
||||
const formRef = ref()
|
||||
const isUpdating = ref(false)
|
||||
const isAllowedPrivacyPolicy = ref(false)
|
||||
const useCNUrl = useStorage('Settings.UseCNUrl', false)
|
||||
const showAddGoodsModal = ref(false)
|
||||
|
||||
// 路由哈希处理
|
||||
@@ -401,7 +400,7 @@ onMounted(() => { })
|
||||
|
||||
<!-- 礼物展示页链接 -->
|
||||
<NDivider
|
||||
style="margin: 16px 0"
|
||||
style="margin: 0"
|
||||
title-placement="left"
|
||||
>
|
||||
礼物展示页链接
|
||||
@@ -413,19 +412,16 @@ onMounted(() => { })
|
||||
>
|
||||
<NInputGroup style="max-width: 400px;">
|
||||
<NInput
|
||||
:value="`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/goods`"
|
||||
:value="`${CURRENT_HOST}@${accountInfo.name}/goods`"
|
||||
readonly
|
||||
/>
|
||||
<NButton
|
||||
secondary
|
||||
@click="copyToClipboard(`${useCNUrl ? CN_HOST : CURRENT_HOST}@${accountInfo.name}/goods`)"
|
||||
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/goods`)"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="useCNUrl">
|
||||
使用国内镜像(访问更快)
|
||||
</NCheckbox>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="point-goods-container">
|
||||
<!-- 未认证提示 -->
|
||||
<NAlert
|
||||
v-if="!useAuth.isAuthed"
|
||||
@@ -332,152 +332,173 @@ onMounted(async () => {
|
||||
title="需要认证"
|
||||
>
|
||||
你尚未进行 Bilibili 账号认证, 无法查看积分或兑换礼物。
|
||||
<br>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
style="margin-top: 12px"
|
||||
style="margin-top: 8px"
|
||||
@click="$router.push({ name: 'bili-auth' })"
|
||||
>
|
||||
立即认证
|
||||
</NButton>
|
||||
</NAlert>
|
||||
|
||||
<!-- 用户信息与积分展示 -->
|
||||
<NCard
|
||||
<!-- 优化后的用户信息与筛选区域 -->
|
||||
<div
|
||||
v-else
|
||||
style="max-width: 600px; margin: 0 auto;"
|
||||
embedded
|
||||
hoverable
|
||||
class="header-section"
|
||||
>
|
||||
<template #header>
|
||||
你好, {{ biliAuth.name }} <!-- 直接使用计算属性 -->
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NFlex>
|
||||
<NButton
|
||||
type="info"
|
||||
secondary
|
||||
size="small"
|
||||
@click="gotoAuthPage"
|
||||
>
|
||||
前往认证用户中心
|
||||
</NButton>
|
||||
<NButton
|
||||
secondary
|
||||
size="small"
|
||||
@click="NavigateToNewTab('/bili-user#settings')"
|
||||
>
|
||||
切换账号
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</template>
|
||||
<NText v-if="currentPoint >= 0">
|
||||
你在 {{ userInfo.extra?.streamerInfo?.name ?? userInfo.name }} 的直播间的积分为 {{ currentPoint }}
|
||||
</NText>
|
||||
<NText v-else>
|
||||
正在加载积分...
|
||||
</NText>
|
||||
</NCard>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<!-- 礼物筛选区域 -->
|
||||
<NCard
|
||||
v-if="tags.length > 0 || goods.length > 0"
|
||||
size="small"
|
||||
title="礼物筛选与排序"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<!-- 标签筛选 -->
|
||||
<NFlex
|
||||
v-if="tags.length > 0"
|
||||
align="center"
|
||||
justify="start"
|
||||
wrap
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<NText style="margin-right: 8px;">
|
||||
标签:
|
||||
</NText>
|
||||
<NButton
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
:type="tag === selectedTag ? 'success' : 'default'"
|
||||
:ghost="tag !== selectedTag"
|
||||
style="margin: 2px;"
|
||||
size="small"
|
||||
@click="selectedTag = selectedTag === tag ? undefined : tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="selectedTag"
|
||||
text
|
||||
type="warning"
|
||||
size="small"
|
||||
style="margin-left: 8px;"
|
||||
@click="selectedTag = undefined"
|
||||
>
|
||||
清除标签
|
||||
</NButton>
|
||||
</NFlex>
|
||||
|
||||
<!-- 搜索与选项 -->
|
||||
<NFlex
|
||||
wrap
|
||||
justify="space-between"
|
||||
align="center"
|
||||
:size="[12, 8]"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<NInput
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索礼物名称或描述"
|
||||
clearable
|
||||
style="min-width: 200px; flex-grow: 1;"
|
||||
/>
|
||||
|
||||
<!-- 筛选选项 -->
|
||||
<!-- 用户信息区域 -->
|
||||
<div class="user-info-section">
|
||||
<NFlex
|
||||
wrap
|
||||
:gap="12"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<NCheckbox v-model:checked="onlyCanBuy">
|
||||
只显示可兑换
|
||||
</NCheckbox>
|
||||
<NCheckbox v-model:checked="ignoreGuard">
|
||||
忽略舰长限制
|
||||
</NCheckbox>
|
||||
<!-- 价格排序 -->
|
||||
<NSelect
|
||||
v-model:value="priceOrder"
|
||||
:options="[
|
||||
{ label: '默认排序', value: 'null' },
|
||||
{ label: '价格 低→高', value: 'asc' },
|
||||
{ label: '价格 高→低', value: 'desc' }
|
||||
]"
|
||||
placeholder="价格排序"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
<NFlex align="center">
|
||||
<NText class="username">
|
||||
你好, {{ biliAuth.name }}
|
||||
</NText>
|
||||
<NText
|
||||
v-if="currentPoint >= 0"
|
||||
class="point-info"
|
||||
>
|
||||
你在本直播间的积分: <strong>{{ currentPoint }}</strong>
|
||||
</NText>
|
||||
<NText
|
||||
v-else
|
||||
class="point-info loading"
|
||||
>
|
||||
积分加载中...
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NFlex :size="8">
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="gotoAuthPage"
|
||||
>
|
||||
账号中心
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="NavigateToNewTab('/bili-user#settings')"
|
||||
>
|
||||
切换账号
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<!-- 礼物筛选区域 -->
|
||||
<div
|
||||
v-if="tags.length > 0 || goods.length > 0"
|
||||
class="filter-section"
|
||||
>
|
||||
<!-- 标签筛选 -->
|
||||
<NFlex
|
||||
v-if="tags.length > 0"
|
||||
wrap
|
||||
class="tags-container"
|
||||
>
|
||||
<div class="filter-label">
|
||||
分类:
|
||||
</div>
|
||||
<div class="tags-wrapper">
|
||||
<NButton
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
:type="tag === selectedTag ? 'primary' : 'default'"
|
||||
:ghost="tag !== selectedTag"
|
||||
class="tag-button"
|
||||
size="tiny"
|
||||
@click="selectedTag = selectedTag === tag ? undefined : tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="selectedTag"
|
||||
text
|
||||
type="error"
|
||||
size="tiny"
|
||||
@click="selectedTag = undefined"
|
||||
>
|
||||
✕
|
||||
</NButton>
|
||||
</div>
|
||||
</NFlex>
|
||||
|
||||
<!-- 搜索与高级筛选 -->
|
||||
<NFlex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap
|
||||
class="search-filter-row"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<NInput
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索礼物名称"
|
||||
clearable
|
||||
size="small"
|
||||
class="search-input"
|
||||
>
|
||||
<template #prefix>
|
||||
🔍
|
||||
</template>
|
||||
</NInput>
|
||||
|
||||
<!-- 筛选选项 -->
|
||||
<NFlex
|
||||
wrap
|
||||
align="center"
|
||||
class="filter-options"
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="onlyCanBuy"
|
||||
size="small"
|
||||
class="filter-checkbox"
|
||||
>
|
||||
仅显示可兑换
|
||||
</NCheckbox>
|
||||
<NCheckbox
|
||||
v-model:checked="ignoreGuard"
|
||||
size="small"
|
||||
class="filter-checkbox"
|
||||
>
|
||||
忽略舰长限制
|
||||
</NCheckbox>
|
||||
<!-- 价格排序 -->
|
||||
<NSelect
|
||||
v-model:value="priceOrder"
|
||||
:options="[
|
||||
{ label: '默认排序', value: 'null' },
|
||||
{ label: '价格 ↑', value: 'asc' },
|
||||
{ label: '价格 ↓', value: 'desc' }
|
||||
]"
|
||||
placeholder="排序方式"
|
||||
size="small"
|
||||
class="sort-select"
|
||||
/>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 礼物列表区域 -->
|
||||
<NSpin :show="isLoading">
|
||||
<NSpin
|
||||
:show="isLoading"
|
||||
class="goods-list-container"
|
||||
>
|
||||
<template #description>
|
||||
加载中...
|
||||
</template>
|
||||
<NEmpty
|
||||
v-if="!isLoading && selectedItems.length === 0"
|
||||
description="没有找到符合条件的礼物"
|
||||
:description="goods.length === 0 ? '当前没有可兑换的礼物哦~' : '没有找到符合筛选条件的礼物'"
|
||||
>
|
||||
<template #extra>
|
||||
<NButton
|
||||
v-if="selectedTag || searchKeyword || onlyCanBuy || ignoreGuard || priceOrder"
|
||||
v-if="goods.length > 0 && (selectedTag || searchKeyword || onlyCanBuy || ignoreGuard || priceOrder)"
|
||||
size="small"
|
||||
@click="() => { selectedTag = undefined; searchKeyword = ''; onlyCanBuy = false; ignoreGuard = false; priceOrder = null; }"
|
||||
>
|
||||
@@ -485,51 +506,49 @@ onMounted(async () => {
|
||||
</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
<NFlex
|
||||
<div
|
||||
v-else
|
||||
wrap
|
||||
justify="center"
|
||||
:gap="16"
|
||||
class="goods-grid"
|
||||
>
|
||||
<PointGoodsItem
|
||||
v-for="item in selectedItems"
|
||||
:key="item.id"
|
||||
:goods="item"
|
||||
content-style="max-width: 300px; min-width: 250px; height: 380px;"
|
||||
style="flex-grow: 1;"
|
||||
class="goods-item"
|
||||
>
|
||||
<template #footer>
|
||||
<NFlex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
class="goods-footer"
|
||||
>
|
||||
<NTooltip placement="bottom">
|
||||
<template #trigger>
|
||||
<!-- 按钮禁用状态由 getTooltip 控制 -->
|
||||
<NButton
|
||||
:disabled="getTooltip(item) !== '开始兑换'"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="exchange-btn"
|
||||
@click="onBuyClick(item)"
|
||||
>
|
||||
兑换
|
||||
</NButton>
|
||||
</template>
|
||||
{{ getTooltip(item) }} <!-- 显示提示信息 -->
|
||||
{{ getTooltip(item) }}
|
||||
</NTooltip>
|
||||
<NFlex
|
||||
align="center"
|
||||
justify="end"
|
||||
style="flex-grow: 1;"
|
||||
class="price-display"
|
||||
>
|
||||
<NTooltip placement="bottom">
|
||||
<template #trigger>
|
||||
<NText
|
||||
style="font-size: 1.1em; font-weight: bold;"
|
||||
class="price-text"
|
||||
:delete="item.canFreeBuy"
|
||||
>
|
||||
🪙
|
||||
{{ item.price > 0 ? item.price : '免费' }}
|
||||
🪙 {{ item.price > 0 ? item.price : '免费' }}
|
||||
</NText>
|
||||
</template>
|
||||
{{ item.canFreeBuy ? '你可以免费兑换此礼物' : '所需积分' }}
|
||||
@@ -538,7 +557,7 @@ onMounted(async () => {
|
||||
</NFlex>
|
||||
</template>
|
||||
</PointGoodsItem>
|
||||
</NFlex>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
<!-- 兑换确认模态框 -->
|
||||
@@ -658,8 +677,132 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加一些 scoped 样式来优化布局或外观 */
|
||||
.n-card {
|
||||
margin-bottom: 16px; /* 为卡片添加一些底部间距 */
|
||||
.point-goods-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 16px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: var(--font-weight-strong);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.point-info {
|
||||
color: var(--text-color-2);
|
||||
}
|
||||
|
||||
.point-info.loading {
|
||||
font-style: italic;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 12px 16px;
|
||||
background-color: var(--action-color);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-color-2);
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.tag-button {
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--border-radius-small);
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.goods-list-container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.goods-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
break-inside: avoid;
|
||||
background-color: var(--card-color);
|
||||
transition: all 0.2s ease-in-out;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.goods-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.goods-footer {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.exchange-btn {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.price-text {
|
||||
font-size: 1.1em;
|
||||
font-weight: var(--font-weight-strong);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.goods-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import VueTurnstile from 'vue-turnstile'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { biliInfo, userInfo } = defineProps<{
|
||||
biliInfo: any | undefined
|
||||
@@ -49,6 +50,7 @@ const isGetting = ref(true) // 是否正在获取数据
|
||||
// 验证码相关
|
||||
const token = ref('')
|
||||
const turnstile = ref()
|
||||
const route = useRoute()
|
||||
|
||||
// 防刷控制
|
||||
const nextSendQuestionTime = ref(Date.now())
|
||||
@@ -184,6 +186,11 @@ function getTags() {
|
||||
})
|
||||
.finally(() => {
|
||||
isGetting.value = false
|
||||
// 检查 URL 参数中的 tag
|
||||
const tagFromQuery = route.query.tag as string | undefined
|
||||
if (tagFromQuery && tags.value.includes(tagFromQuery)) {
|
||||
selectedTag.value = tagFromQuery
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,6 +247,7 @@ onUnmounted(() => {
|
||||
class="tag-item"
|
||||
:bordered="false"
|
||||
:type="selectedTag === tag ? 'primary' : 'default'"
|
||||
:clearable="false"
|
||||
@click="onSelectTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -495,6 +503,10 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-list {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.question-box-container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
456
src/views/view/scheduleTemplate/KawaiiSchedule.vue
Normal file
456
src/views/view/scheduleTemplate/KawaiiSchedule.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<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([
|
||||
{
|
||||
name: '背景图', // Removed 'as const'
|
||||
type: 'image',
|
||||
key: 'backgroundImage', // Removed 'as const'
|
||||
imageLimit: 1,
|
||||
default: [] as string[],
|
||||
onUploaded: (urls: string[], config: any) => {
|
||||
config.backgroundImage = urls;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '容器背景色',
|
||||
type: 'color',
|
||||
key: 'containerColor',
|
||||
default: { r: 255, g: 255, b: 255, a: 0.8 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '日期标签文字色',
|
||||
type: 'color',
|
||||
key: 'dayLabelColor',
|
||||
default: { r: 126, g: 136, b: 184, a: 1 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '日程内容背景色',
|
||||
type: 'color',
|
||||
key: 'dayContentBgColor',
|
||||
default: { r: 255, g: 255, b: 255, a: 1 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '日程内容文字色',
|
||||
type: 'color',
|
||||
key: 'dayContentTextColor',
|
||||
default: { r: 100, g: 100, b: 100, a: 1 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '时间标签背景色',
|
||||
type: 'color',
|
||||
key: 'timeLabelBgColor',
|
||||
default: { r: 245, g: 189, b: 189, a: 1 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '时间标签文字色',
|
||||
type: 'color',
|
||||
key: 'timeLabelTextColor',
|
||||
default: { r: 255, g: 255, b: 255, a: 1 } as RGBAColor,
|
||||
showAlpha: true,
|
||||
},
|
||||
{
|
||||
name: '装饰图片',
|
||||
type: 'decorativeImages',
|
||||
key: 'decorativeImages',
|
||||
default: [] as DecorativeImageProperties[],
|
||||
},
|
||||
]);
|
||||
export type KawaiiConfigType = ExtractConfigData<typeof Config>;
|
||||
export const DefaultConfig = {
|
||||
|
||||
} as KawaiiConfigType;
|
||||
</script>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ScheduleDayInfo, ScheduleWeekInfo } from '@/api/api-models';
|
||||
import SaveCompoent from '@/components/SaveCompoent.vue'; // 引入截图组件
|
||||
import { ScheduleConfigTypeWithConfig } from '@/data/TemplateTypes'; // Use base type
|
||||
import { DecorativeImageProperties, defineTemplateConfig, ExtractConfigData, RGBAColor, rgbaToString } from '@/data/VTsuruTypes';
|
||||
import { FILE_BASE_URL } from '@/data/constants';
|
||||
import { getWeek, getYear } from 'date-fns';
|
||||
import { NButton, NDivider, NEmpty, NFlex, NSelect, NSpace, useMessage } from 'naive-ui';
|
||||
import { computed, h, ref, watch, WritableComputedRef } from 'vue';
|
||||
|
||||
// Get message instance
|
||||
const message = useMessage();
|
||||
|
||||
const props = defineProps<ScheduleConfigTypeWithConfig<KawaiiConfigType>>();
|
||||
|
||||
// --- 默认配置 --- Define DefaultConfig using KawaiiConfigType
|
||||
// No export needed here
|
||||
const DefaultConfig: KawaiiConfigType = {
|
||||
backgroundImage: [],
|
||||
containerColor: { r: 255, g: 255, b: 255, a: 0.8 },
|
||||
dayLabelColor: { r: 126, g: 136, b: 184, a: 1 },
|
||||
dayContentBgColor: { r: 255, g: 255, b: 255, a: 1 },
|
||||
dayContentTextColor: { r: 100, g: 100, b: 100, a: 1 },
|
||||
timeLabelBgColor: { r: 245, g: 189, b: 189, a: 1 },
|
||||
timeLabelTextColor: { r: 255, g: 255, b: 255, a: 1 },
|
||||
decorativeImages: [],
|
||||
};
|
||||
|
||||
// --- 状态 ---
|
||||
const tableRef = ref<HTMLElement | null>(null);
|
||||
const _selectedDate = ref<string>(); // Internal state
|
||||
|
||||
// --- Computed Properties ---
|
||||
|
||||
// 合并默认配置和传入的配置
|
||||
const effectiveConfig = computed(() => {
|
||||
return { ...DefaultConfig, ...props.config };
|
||||
});
|
||||
|
||||
// Writable computed for selectedDate to handle potential side effects safely
|
||||
const selectedDate: WritableComputedRef<string | undefined> = computed({
|
||||
get: () => _selectedDate.value,
|
||||
set: (val) => { _selectedDate.value = val; }
|
||||
});
|
||||
|
||||
// 周选择器选项
|
||||
const weekOptions = computed(() => {
|
||||
return props.data?.map((item: ScheduleWeekInfo) => ({
|
||||
label: `${item.year}年 第${item.week}周`,
|
||||
value: `${item.year}-${item.week}`,
|
||||
})) ?? [];
|
||||
});
|
||||
|
||||
// Find current/selected week data without side effects
|
||||
const currentWeekData = computed<ScheduleWeekInfo | null>(() => {
|
||||
if (!props.data || props.data.length === 0) return null;
|
||||
const findPredicateSelected = (item: ScheduleWeekInfo) => `${item.year}-${item.week}` === _selectedDate.value;
|
||||
const findPredicateCurrent = (item: ScheduleWeekInfo) => isTodayInWeek(item.year, item.week);
|
||||
|
||||
let target = _selectedDate.value
|
||||
? props.data.find(findPredicateSelected)
|
||||
: props.data.find(findPredicateCurrent);
|
||||
|
||||
// Fallback if target not found (e.g., selected date no longer exists)
|
||||
if (!target) {
|
||||
target = props.data.find(findPredicateCurrent) || props.data[0];
|
||||
}
|
||||
return target || null;
|
||||
});
|
||||
|
||||
// Watcher to initialize or update selectedDate based on available data
|
||||
watch([() => props.data, currentWeekData], ([newDataArray, newCurrentWeek], [oldDataArray, oldCurrentWeek]) => {
|
||||
const currentSelection = _selectedDate.value;
|
||||
const dataAvailable = newDataArray && newDataArray.length > 0;
|
||||
|
||||
if (!currentSelection && newCurrentWeek) {
|
||||
// Initialize selection if empty and current week data is available
|
||||
_selectedDate.value = `${newCurrentWeek.year}-${newCurrentWeek.week}`;
|
||||
} else if (currentSelection && dataAvailable) {
|
||||
// Check if the currently selected date still exists in the new data array
|
||||
const selectionExists = newDataArray.some((d: ScheduleWeekInfo) => `${d.year}-${d.week}` === currentSelection);
|
||||
if (!selectionExists) {
|
||||
// If selection no longer exists, fallback to current week or first available
|
||||
const fallbackWeek = newDataArray.find((d: ScheduleWeekInfo) => isTodayInWeek(d.year, d.week)) || newDataArray[0];
|
||||
_selectedDate.value = fallbackWeek ? `${fallbackWeek.year}-${fallbackWeek.week}` : undefined;
|
||||
}
|
||||
} else if (!dataAvailable) {
|
||||
// Clear selection if no data is available
|
||||
_selectedDate.value = undefined;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Day mapping and order
|
||||
const dayMap: Record<string, string> = { Mon: '周一', Tue: '周二', Wed: '周三', Thu: '周四', Fri: '周五', Sat: '周六', Sun: '周日' };
|
||||
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// Formatted schedule data for display
|
||||
const formattedSchedule = computed(() => {
|
||||
if (!currentWeekData.value || !Array.isArray(currentWeekData.value.days)) return [];
|
||||
const scheduleMap = new Map<string, ScheduleDayInfo>();
|
||||
currentWeekData.value.days.forEach((day: ScheduleDayInfo, index: number) => {
|
||||
const dayKey = daysOfWeek[index] || `day${index}`;
|
||||
scheduleMap.set(dayKey, day);
|
||||
});
|
||||
return daysOfWeek.map(dayKey => ({
|
||||
key: dayKey,
|
||||
label: dayMap[dayKey] || dayKey,
|
||||
data: scheduleMap.get(dayKey) || { time: '', tag: '', title: '' }
|
||||
}));
|
||||
});
|
||||
|
||||
// --- 方法 ---
|
||||
function isTodayInWeek(year: number, week: number): boolean {
|
||||
const today = new Date();
|
||||
const todayYear = getYear(today);
|
||||
const todayWeek = getWeek(today, { weekStartsOn: 1 });
|
||||
return todayYear === year && todayWeek === week;
|
||||
}
|
||||
|
||||
// --- Expose Config and DefaultConfig for template system ---
|
||||
// These need to be the actual constant values
|
||||
defineExpose({ Config, DefaultConfig });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kawaii-schedule-selector">
|
||||
<NSpace align="center">
|
||||
<NSelect
|
||||
v-model:value="selectedDate"
|
||||
:options="weekOptions"
|
||||
style="width: 200px"
|
||||
placeholder="选择周次"
|
||||
size="small"
|
||||
clearable
|
||||
/>
|
||||
<SaveCompoent
|
||||
v-if="tableRef"
|
||||
:compoent="tableRef"
|
||||
:file-name="`日程表_${selectedDate || '当前'}_${props.userInfo?.name || '用户'}`"
|
||||
tooltip-text="保存当前周表为图片"
|
||||
/>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="tableRef"
|
||||
class="kawaii-schedule-container"
|
||||
:style="{
|
||||
'--container-bg-color': rgbaToString(effectiveConfig.containerColor),
|
||||
'--day-label-color': rgbaToString(effectiveConfig.dayLabelColor),
|
||||
'--day-content-bg-color': rgbaToString(effectiveConfig.dayContentBgColor),
|
||||
'--day-content-text-color': rgbaToString(effectiveConfig.dayContentTextColor),
|
||||
'--time-label-bg-color': rgbaToString(effectiveConfig.timeLabelBgColor),
|
||||
'--time-label-text-color': rgbaToString(effectiveConfig.timeLabelTextColor),
|
||||
backgroundImage: effectiveConfig.backgroundImage && effectiveConfig.backgroundImage.length > 0 ? `url(${FILE_BASE_URL + effectiveConfig.backgroundImage[0]})` : 'none',
|
||||
}"
|
||||
>
|
||||
<!-- 装饰图片渲染 -->
|
||||
<div
|
||||
v-for="img in effectiveConfig.decorativeImages"
|
||||
:key="img.id"
|
||||
class="decorative-image"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
left: `${img.x}%`,
|
||||
top: `${img.y}%`,
|
||||
width: `${img.width}%`,
|
||||
height: 'auto',
|
||||
transform: `translate(-50%, -50%) rotate(${img.rotation}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
opacity: img.opacity,
|
||||
zIndex: img.zIndex,
|
||||
pointerEvents: 'none',
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="FILE_BASE_URL + img.src"
|
||||
alt="decoration"
|
||||
style="display: block; width: 100%; height: auto;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 日程表主体 -->
|
||||
<div class="schedule-main-grid">
|
||||
<!-- 左侧日程 -->
|
||||
<div class="schedule-days-left">
|
||||
<div
|
||||
v-for="day in formattedSchedule.slice(0, 5)"
|
||||
:key="day.key"
|
||||
class="day-item-wrapper"
|
||||
>
|
||||
<div class="day-label">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="day-content">
|
||||
<div
|
||||
v-if="day.data?.time"
|
||||
class="time-label"
|
||||
>
|
||||
{{ day.data.time }}
|
||||
</div>
|
||||
<div class="content-text">
|
||||
{{ day.data?.title || '休息' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧日程 -->
|
||||
<div class="schedule-days-right">
|
||||
<div
|
||||
v-for="day in formattedSchedule.slice(5)"
|
||||
:key="day.key"
|
||||
class="day-item-wrapper"
|
||||
>
|
||||
<div class="day-label">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="day-content">
|
||||
<div
|
||||
v-if="day.data?.time"
|
||||
class="time-label"
|
||||
>
|
||||
{{ day.data.time }}
|
||||
</div>
|
||||
<div class="content-text">
|
||||
{{ day.data?.title || '待定~' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Styles remain the same */
|
||||
/* --- Base Container --- */
|
||||
.kawaii-schedule-container {
|
||||
position: relative;
|
||||
/* Crucial for absolute positioned decorations */
|
||||
width: 900px;
|
||||
/* Adjust width as needed */
|
||||
/* height: 650px; */
|
||||
/* Let content determine height or set fixed */
|
||||
min-height: 650px;
|
||||
/* Ensure minimum height */
|
||||
padding: 30px;
|
||||
margin: 0 auto;
|
||||
border-radius: 25px;
|
||||
background-color: var(--container-bg-color, rgba(253, 240, 240, 0.8));
|
||||
/* Default soft pinkish */
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
/* Clip decorations exceeding bounds */
|
||||
box-sizing: border-box;
|
||||
/* Add font later */
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
/* Example font */
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Decorative image base style */
|
||||
.decorative-image {
|
||||
/* Style defined inline via :style binding */
|
||||
}
|
||||
|
||||
.decorative-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
/* Ensure image fits within its bounds */
|
||||
}
|
||||
|
||||
|
||||
/* --- Layout Grid --- */
|
||||
.schedule-main-grid {
|
||||
position: relative;
|
||||
/* Ensure content is above background decorations if needed */
|
||||
z-index: 10;
|
||||
/* Content above default decoration z-index */
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
/* Adjust column ratio as needed */
|
||||
gap: 25px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.schedule-days-left,
|
||||
.schedule-days-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
/* Space between day items */
|
||||
}
|
||||
|
||||
/* --- Day Item Styling --- */
|
||||
.day-item-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
/* Adjust width */
|
||||
height: 45px;
|
||||
/* Adjust height */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #fdecec;
|
||||
/* Light pink cloud */
|
||||
border-radius: 15px 15px 15px 15px / 20px 20px 20px 20px;
|
||||
/* Cloud shape */
|
||||
color: var(--day-label-color);
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.day-content {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
/* For absolute positioning of time label */
|
||||
background-color: var(--day-content-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 10px 15px;
|
||||
min-height: 50px;
|
||||
/* Ensure minimum height */
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
/* Use flex for content alignment if needed */
|
||||
align-items: center;
|
||||
/* Vertically center text */
|
||||
}
|
||||
|
||||
.time-label {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
/* Position above the content box */
|
||||
right: 15px;
|
||||
/* Align to the right */
|
||||
background-color: var(--time-label-bg-color);
|
||||
color: var(--time-label-text-color);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: var(--day-content-text-color);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
/* Take full width */
|
||||
}
|
||||
|
||||
|
||||
/* --- Week Selector Area --- */
|
||||
.kawaii-schedule-selector {
|
||||
padding: 5px 10px;
|
||||
/* Add some padding */
|
||||
}
|
||||
|
||||
/* Optional: Style Naive components if needed */
|
||||
:deep(.n-select .n-base-selection) {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* --- Configuration UI specific styles --- */
|
||||
/* Add styles for the NCard and controls within the render function if needed */
|
||||
.n-card {
|
||||
transition: border 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user