feat: 更新依赖和增强动态表单功能

- 在 package.json 中添加 hammerjs 和 tui-image-editor 依赖
- 在 DynamicForm.vue 中引入并实现装饰性图片功能,支持图片上传、删除和属性调整
- 优化颜色处理逻辑,支持 RGBA 格式
- 更新常量和类型定义,增强代码可读性和可维护性
This commit is contained in:
2025-04-29 05:31:00 +08:00
parent 0591d0575d
commit 968c34f57a
17 changed files with 1724 additions and 239 deletions

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>