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

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