chore: 更新依赖, 支持日程表单日多日程

This commit is contained in:
Megghy
2025-09-30 09:48:57 +08:00
parent 7c516559f1
commit 6fd046adcd
15 changed files with 1307 additions and 482 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,28 +1,75 @@
import oxlint from 'eslint-plugin-oxlint';
import vue from 'eslint-plugin-vue';
import ts from 'typescript-eslint';
// `VueVine()` 返回一个 ESLint flat config
import antfu from '@antfu/eslint-config'
import VueVine from '@vue-vine/eslint-config'
export default [
export default antfu(
{
languageOptions: {
ecmaVersion: 'latest',
// 项目类型: app (默认) 或 lib
type: 'app',
// 启用 TypeScript 支持 (自动检测)
typescript: {
tsconfigPath: 'tsconfig.json',
},
// 启用 Vue 支持 (自动检测)
vue: true,
// 启用格式化规则
stylistic: {
indent: 2,
quotes: 'single',
semi: false,
},
// 禁用某些文件类型的支持
jsonc: true,
yaml: true,
markdown: true,
// 忽略的文件
ignores: [
'**/node_modules',
'**/dist',
'**/output',
'**/.vitepress/cache',
'**/.nuxt',
'**/.next',
'**/.vercel',
'**/.changeset',
'**/.idea',
'**/.cache',
'**/.output',
'**/.vite-inspect',
'**/CHANGELOG*.md',
'**/*.min.*',
'**/LICENSE*',
'**/__snapshots__',
'**/auto-import?(s).d.ts',
'**/components.d.ts',
],
},
...vue.configs['flat/recommended'],
{
// files: ['*.vue', '**/*.vue'],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
// 自定义规则
rules: {
"vue/no-mutating-props": "off",
// Vue 相关规则
'vue/multi-word-component-names': 'off',
'vue/no-mutating-props': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
// TypeScript 相关规则
'ts/no-explicit-any': 'off',
'ts/ban-ts-comment': 'off',
// 通用规则
'no-console': 'off',
'unused-imports/no-unused-vars': 'warn',
// 关闭一些过于严格的规则
'antfu/if-newline': 'off',
'style/brace-style': ['error', '1tbs'],
},
},
// 集成 VueVine 配置
...VueVine(),
...oxlint.configs['flat/recommended'], // oxlint should be the last one
]
)

View File

@@ -13,84 +13,82 @@
"@guolao/vue-monaco-editor": "^1.5.5",
"@hyperdx/browser": "^0.21.2",
"@hyperdx/cli": "^0.1.0",
"@microsoft/signalr": "^8.0.7",
"@microsoft/signalr-protocol-msgpack": "^8.0.7",
"@microsoft/signalr": "^9.0.6",
"@microsoft/signalr-protocol-msgpack": "^9.0.6",
"@mixer/postmessage-rpc": "^1.1.4",
"@oneidentity/zstd-js": "^1.0.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-http": "^2.4.4",
"@tauri-apps/plugin-log": "^2.4.0",
"@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-autostart": "^2.5.0",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-notification": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "^2.3.1",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@types/crypto-js": "^4.2.2",
"@types/md5": "^2.3.5",
"@types/vue-cropperjs": "^4.1.6",
"@vicons/fluent": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/core": "^13.3.0",
"@vueuse/integrations": "^13.3.0",
"@vueuse/router": "^13.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vueuse/core": "^13.9.0",
"@vueuse/integrations": "^13.9.0",
"@vueuse/router": "^13.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"bilibili-live-ws": "^6.3.1",
"cropperjs": "^2.0.0",
"cropperjs": "^2.0.1",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"easy-speech": "^2.4.0",
"echarts": "^5.6.0",
"eslint-plugin-oxlint": "^0.16.12",
"echarts": "^6.0.0",
"fast-xml-parser": "^5.2.5",
"file-saver": "^2.0.5",
"grapheme-splitter": "^1.0.4",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",
"idb-keyval": "^6.2.2",
"linqts": "^2.0.0",
"jszip": "^3.10.1",
"linqts": "^3.2.0",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"naive-ui": "^2.41.1",
"nanoid": "^5.1.5",
"monaco-editor": "^0.53.0",
"naive-ui": "^2.43.1",
"nanoid": "^5.1.6",
"peerjs": "^1.5.5",
"pinia": "^3.0.3",
"qrcode.vue": "^3.6.0",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.7.0",
"unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0",
"vite": "6.3.4",
"vite-plugin-oxlint": "^1.3.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"unplugin-vue-markdown": "^29.2.0",
"uuid": "^13.0.0",
"vite": "7.1.7",
"vite-svg-loader": "^5.1.0",
"vue": "3.5.13",
"vue": "3.5.22",
"vue-cropperjs": "^5.0.0",
"vue-echarts": "^7.0.3",
"vue-echarts": "^8.0.0",
"vue-request": "^2.0.4",
"vue-router": "^4.5.1",
"vue-turnstile": "^1.0.11",
"vue3-aplayer": "^1.7.3",
"vue3-marquee": "^4.2.2",
"vueuc": "^0.4.64",
"worker-timers": "^8.0.22",
"vueuc": "^0.4.65",
"worker-timers": "^8.0.25",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bun": "^1.2.16",
"@antfu/eslint-config": "^5.4.1",
"@types/bun": "^1.2.23",
"@types/file-saver": "^2.0.7",
"@types/jszip": "^3.4.1",
"@types/uuid": "^10.0.0",
"@types/uuid": "^11.0.0",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vue-vine/eslint-config": "^0.2.20",
"eslint": "^9.29.0",
"eslint-plugin-vue": "^10.2.0",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vue-vine/eslint-config": "^1.1.9",
"eslint": "^9.36.0",
"stylus": "^0.64.0",
"typescript": "^5.9.0-dev.20250614",
"vue-vine": "^0.4.4"
"typescript": "^5.9.2",
"vue-vine": "^1.7.6"
}
}

View File

@@ -413,13 +413,22 @@ export interface LotteryUserCardInfo {
export interface ScheduleWeekInfo {
year: number
week: number
days: ScheduleDayInfo[]
days: ScheduleDayInfo[][]
}
export interface ScheduleDayInfo {
title: string | null
tag: string | null
tagColor: string | null
time: string | null
id: string | null
}
export interface BatchScheduleRequest {
startYear: number
startWeek: number
count: number
dayOfWeek: number
schedule: ScheduleDayInfo
}
export enum ThemeType {
Auto = 'auto',

View File

@@ -115,6 +115,7 @@ declare global {
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const getDate: typeof import('date-fns')['getDate']
const getDay: typeof import('date-fns')['getDay']
const getDayOfYear: typeof import('date-fns')['getDayOfYear']
@@ -179,6 +180,7 @@ declare global {
const isSameWeek: typeof import('date-fns')['isSameWeek']
const isSameYear: typeof import('date-fns')['isSameYear']
const isSaturday: typeof import('date-fns')['isSaturday']
const isShallow: typeof import('vue')['isShallow']
const isSunday: typeof import('date-fns')['isSunday']
const isThisHour: typeof import('date-fns')['isThisHour']
const isThisISOWeek: typeof import('date-fns')['isThisISOWeek']
@@ -507,6 +509,7 @@ declare global {
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
@@ -553,6 +556,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

14
src/components.d.ts vendored
View File

@@ -19,27 +19,14 @@ declare module 'vue' {
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NModal: typeof import('naive-ui')['NModal']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
@@ -55,7 +42,6 @@ declare module 'vue' {
SongList: typeof import('./components/SongList.vue')['default']
SongPlayer: typeof import('./components/SongPlayer.vue')['default']
TempComponent: typeof import('./components/TempComponent.vue')['default']
ToolDynamicNineGrid: typeof import('./components/manage/tools/ToolDynamicNineGrid.vue')['default']
TurnstileVerify: typeof import('./components/TurnstileVerify.vue')['default']
UpdateNoteContainer: typeof import('./components/UpdateNoteContainer.vue')['default']
UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ScheduleWeekInfo } from '@/api/api-models'
import { ScheduleWeekInfo, ScheduleDayInfo } from '@/api/api-models'
import { useWindowSize } from '@vueuse/core'
import { NBadge, NButton, NCard, NEllipsis, NEmpty, NGrid, NGridItem, NList, NListItem, NPopconfirm, NSpace, NText, NTime } from 'naive-ui'
import { NBadge, NButton, NCard, NEllipsis, NEmpty, NGrid, NGridItem, NIcon, NList, NListItem, NPopconfirm, NSpace, NText, NTime, useThemeVars } from 'naive-ui'
import { Clock20Regular, Bed20Regular } from '@vicons/fluent'
import { h } from 'vue'
const { width } = useWindowSize()
const themeVars = useThemeVars()
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
function getDateFromWeek(year: number, week: number, dayOfWeek: number): Date {
@@ -24,6 +27,8 @@ const emit = defineEmits<{
(e: 'onUpdate', schedule: ScheduleWeekInfo): void
(e: 'onDelete', schedule: ScheduleWeekInfo): void
(e: 'onCopy', schedule: ScheduleWeekInfo): void
(e: 'onEditItem', schedule: ScheduleWeekInfo, dayIndex: number, item: ScheduleDayInfo): void
(e: 'onDeleteItem', schedule: ScheduleWeekInfo, dayIndex: number, item: ScheduleDayInfo): void
}>()
</script>
@@ -78,67 +83,199 @@ const emit = defineEmits<{
</template>
<NGrid
x-gap="8"
y-gap="8"
cols="1 1200:7"
style="align-items: stretch;"
>
<NGridItem
v-for="(day, index) in item.days"
v-for="(daySchedules, index) in item.days"
:key="index"
style="display: flex;"
>
<NCard
size="small"
:style="{ height: '65px', backgroundColor: day.tagColor + '1f' }"
content-style="padding: 5px;"
header-style="padding: 0px 6px 0px 6px;"
:embedded="day?.tag != undefined"
>
<template #header-extra>
<template v-if="day.tag">
<NSpace :size="5">
<NBadge
v-if="day.tagColor"
dot
:color="day.tagColor"
/>
<NEllipsis>
<NText :style="{ color: day.tagColor }">
{{ day.tag }}
</NText>
</NEllipsis>
</NSpace>
</template>
<template v-else>
<NText
depth="3"
style="font-size: 11px"
italic
>
休息
</NText>
</template>
</template>
<template #header>
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div
:style="{
marginBottom: '6px',
padding: '4px 8px',
fontSize: '13px',
fontWeight: '600',
background: `linear-gradient(135deg, ${themeVars.primaryColorSuppl}15 0%, ${themeVars.primaryColorSuppl}25 100%)`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}"
>
<NTime
:time="getDateFromWeek(item.year, item.week, index)"
format="MM/dd"
:style="{ color: themeVars.primaryColor }"
/>
<NText>{{ weekdays[index] }}</NText>
</div>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 65px;">
<NCard
v-if="daySchedules.length === 0"
size="small"
:style="{
minHeight: '40px',
background: `linear-gradient(135deg, ${themeVars.cardColor} 0%, ${themeVars.bodyColor} 100%)`,
border: `1px dashed ${themeVars.dividerColor}`,
cursor: isSelf ? 'pointer' : 'default',
transition: 'all 0.2s ease',
}"
:hoverable="isSelf"
content-style="display: flex; align-items: center; justify-content: center; gap: 4px;"
@click="isSelf && $emit('onUpdate', item)"
>
<NIcon :size="14" :component="Bed20Regular" :color="themeVars.textColor3" />
<NText
:depth="3"
style="font-size: 12px"
strong
:italic="!day.tag"
depth="3"
style="font-size: 11px; font-style: italic;"
:style="{ opacity: isSelf ? 0.5 : 0.6 }"
>
<NTime
:time="getDateFromWeek(item.year, item.week, index)"
format="MM/dd"
/>
{{ weekdays[index] }}
{{ day.time }}
休息
</NText>
</template>
<template v-if="day?.title">
<NEllipsis>
<NText style="font-size: 13px">
{{ day.title }}
</NText>
</NEllipsis>
</template>
</NCard>
</NCard>
<NSpace
v-else
vertical
:size="4"
>
<NCard
v-for="(schedule, scheduleIndex) in daySchedules"
:key="schedule.id || `${index}-${scheduleIndex}`"
size="small"
:style="{
backgroundColor: schedule.tagColor ? schedule.tagColor + '12' : themeVars.cardColor,
borderLeft: schedule.tagColor ? `3px solid ${schedule.tagColor}` : `3px solid ${themeVars.dividerColor}`,
cursor: isSelf ? 'pointer' : 'default',
transition: 'all 0.2s ease',
padding: '0'
}"
:bordered="true"
:hoverable="isSelf"
content-style="padding: 3px; padding-left: 5px;padding-bottom: 1px;"
@click="isSelf && $emit('onEditItem', item, index, schedule)"
>
<div style="padding: 4px 6px;">
<!-- 标签和时间行 (仅当有标签或时间时显示) -->
<div
v-if="schedule.tag || schedule.time"
style="
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
flex-wrap: nowrap;
"
>
<!-- 标签 -->
<div
v-if="schedule.tag"
:style="{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '1px 5px',
borderRadius: '3px',
backgroundColor: schedule.tagColor ? schedule.tagColor + '22' : themeVars.primaryColorSuppl + '22',
flexShrink: 0
}"
>
<NBadge
v-if="schedule.tagColor"
dot
:color="schedule.tagColor"
:style="{ transform: 'scale(0.85)' }"
/>
<NText
:style="{
color: schedule.tagColor || themeVars.primaryColor,
fontWeight: '600',
fontSize: '10.5px',
whiteSpace: 'nowrap',
lineHeight: '1.2'
}"
>
{{ schedule.tag }}
</NText>
</div>
<!-- 时间 -->
<div
v-if="schedule.time"
:style="{
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
flexShrink: 0
}"
>
<NIcon :size="12" :component="Clock20Regular" :color="themeVars.textColor3" />
<NText
depth="2"
:style="{
fontSize: '10.5px',
fontFamily: 'monospace',
whiteSpace: 'nowrap',
fontWeight: '500'
}"
>
{{ schedule.time }}
</NText>
</div>
<!-- 删除按钮 -->
<NButton
v-if="isSelf"
size="tiny"
type="error"
quaternary
circle
style="margin-left: auto; flex-shrink: 0; width: 18px; height: 18px; padding: 0;"
@click.stop="$emit('onDeleteItem', item, index, schedule)"
>
<template #icon>
<span style="font-size: 14px; line-height: 1;">×</span>
</template>
</NButton>
</div>
<!-- 内容 -->
<div v-if="schedule?.title">
<NEllipsis :line-clamp="2">
<NText
:style="{
fontSize: '12.5px',
lineHeight: '1.4',
color: themeVars.textColor2
}"
>
{{ schedule.title }}
</NText>
</NEllipsis>
</div>
<!-- 如果既没有标签也没有时间但有删除按钮 -->
<div
v-if="!schedule.tag && !schedule.time && isSelf && !schedule?.title"
style="display: flex; justify-content: flex-end;"
>
<NButton
size="tiny"
type="error"
quaternary
circle
style="width: 18px; height: 18px; padding: 0;"
@click.stop="$emit('onDeleteItem', item, index, schedule)"
>
<template #icon>
<span style="font-size: 14px; line-height: 1;">×</span>
</template>
</NButton>
</div>
</div>
</NCard>
</NSpace>
</div>
</div>
</NGridItem>
</NGrid>
</NCard>

View File

@@ -23,7 +23,7 @@ import {
VideoAdd20Filled,
Mail24Filled,
} from '@vicons/fluent'
import { AnalyticsSharp, BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, Eye, PlayForward, PlayBack, Play, Pause, VolumeHigh, ChevronUp, ChevronDown, TrashBin } from '@vicons/ionicons5'
import { AnalyticsSharp, BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, Eye, PlayForward, PlayBack, Play, Pause, VolumeHigh, ChevronUp, ChevronDown, TrashBin, Bookmark, BookmarkOutline } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
NAlert,
@@ -68,6 +68,50 @@ const themeType = useStorage('Settings.Theme', ThemeType.Auto)
// 收藏功能相关
const favoriteMenuItems = useStorage<string[]>('Settings.FavoriteMenuItems', [])
const isFavorite = (key: string) => favoriteMenuItems.value?.includes(key)
const toggleFavorite = (key: string) => {
const list = favoriteMenuItems.value ?? []
const idx = list.indexOf(key)
if (idx === -1) list.unshift(key)
else list.splice(idx, 1)
favoriteMenuItems.value = [...list]
}
const renderFavoriteExtra = (key: string) => () =>
h(
'span',
{ class: ['menu-fav', isFavorite(key) ? 'active' : ''] },
[
h(
NTooltip,
{ placement: 'right' },
{
trigger: () =>
h(
NButton,
{
text: true,
size: 'tiny',
circle: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
toggleFavorite(key)
},
style: 'padding: 0; height: 18px; width: 18px;'
},
{
icon: () =>
h(NIcon, {
component: isFavorite(key) ? Bookmark : BookmarkOutline,
size: 16,
color: isFavorite(key) ? '#f5c451' : undefined,
}),
},
),
default: () => (isFavorite(key) ? '取消收藏' : '收藏'),
},
),
],
)
// 侧边栏和布局相关
const sider = ref()
@@ -130,259 +174,323 @@ const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
// 图标渲染函数 - 用于菜单项
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
// 菜单配置
// 菜单配置(支持分组与收藏置顶)
const menuOptions = computed(() => {
return [
{
// 通用的菜单项工厂,自动挂载收藏按钮到叶子节点
const withFavoriteExtra = (item: any): any => {
if (item?.children?.length) {
return {
...item,
children: item.children.map(withFavoriteExtra),
}
}
return {
...item,
extra: width.value >= 180 ? renderFavoriteExtra(item.key) : undefined,
}
}
const commonItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(AnalyticsSharp),
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-live' } }, { default: () => '直播记录' }),
key: 'manage-live',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Live24Filled),
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-analyze' } }, { default: () => '直播数据' }),
key: 'manage-analyze',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Eye),
},
{
}),
]
const dataItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-event' } }, { default: () => '舰长和SC' }),
key: 'manage-event',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(VehicleShip24Filled),
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-point' } }, { default: () => '积分和礼物' }),
key: 'manage-point',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(BookCoins20Filled),
},
{
}),
]
const toolsItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-schedule' } }, { default: () => '日程' }),
key: 'manage-schedule',
icon: renderIcon(CalendarClock24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-songList' } }, { default: () => '歌单' }),
key: 'manage-songList',
icon: renderIcon(MusicalNote),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-questionBox' } }, { default: () => '棉花糖 (提问箱' }),
key: 'manage-questionBox',
icon: renderIcon(Chatbox),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-videoCollect' } }, { default: () => '视频征集' }),
key: 'manage-videoCollect',
icon: renderIcon(VideoAdd20Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-lottery' } }, { default: () => '动态抽奖' }),
key: 'manage-lottery',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
NText,
() => [
'弹幕相关',
h(
NTooltip,
{ style: 'padding: 0;' },
{
trigger: () => h(NIcon, { component: Info24Filled }),
default: () => h(
NAlert,
{
type: 'warning',
size: 'small',
title: '可用性警告',
style: 'max-width: 600px;',
},
() => h('div', {}, [
' 当浏览器在后台运行时, 定时器和 Websocket 连接将受到严格限制, 这会导致弹幕接收功能无法正常工作 (详见',
h(
NButton,
{
text: true,
tag: 'a',
href: 'https://developer.chrome.com/blog/background_tabs/',
target: '_blank',
type: 'info',
},
() => '此文章',
),
'), 虽然本站已经针对此问题做出了处理, 一般情况下即使掉线了也会重连, 不过还是有可能会遗漏事件',
h('br'),
'为避免这种情况, 建议注册本站账后使用',
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
tag: 'a',
href: 'https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs',
target: '_blank',
},
() => 'VtsuruEventFetcher',
),
', 否则请在使用功能时尽量保持网页在前台运行, 同时关闭浏览器的 页面休眠/内存节省 功能',
h('br'),
'Chrome: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81',
target: '_blank',
},
() => '让特定网站保持活动状态',
),
', Edge: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25',
target: '_blank',
},
() => '永远不想进入睡眠状态的网站',
),
]),
),
},
),
]
),
default: () => isBiliVerified.value
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false || !isBiliVerified.value,
children: [
{
label: () => !isBiliVerified.value ? '弹幕机' : h(
NBadge,
{ value: '新', offset: [15, 12], type: 'info' },
() => h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
),
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}
)
),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
},
{
label: () => !isBiliVerified.value ? '抽奖' : h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点播' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点歌' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '排队' : h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '读弹幕' : h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
},
/*{
label: () => !isBiliVerified.value ? '弹幕投票' : h(
RouterLink,
{ to: { name: 'manage-danmakuVote' } },
{ default: () => '弹幕投票' },
),
key: 'manage-danmakuVote',
icon: renderIcon(Chat24Filled),
disabled: !isBiliVerified.value,
},*/
],
},
}),
]
const danmakuItem = {
label: () => h(
NTooltip,
{},
{
trigger: () => h(
NText,
() => [
'弹幕相关',
h(
NTooltip,
{ style: 'padding: 0;' },
{
trigger: () => h(NIcon, { component: Info24Filled }),
default: () => h(
NAlert,
{
type: 'warning',
size: 'small',
title: '可用性警告',
style: 'max-width: 600px;',
},
() => h('div', {}, [
' 当浏览器在后台运行时, 定时器和 Websocket 连接将受到严格限制, 这会导致弹幕接收功能无法正常工作 (详见',
h(
NButton,
{
text: true,
tag: 'a',
href: 'https://developer.chrome.com/blog/background_tabs/',
target: '_blank',
type: 'info',
},
() => '此文章',
),
'), 虽然本站已经针对此问题做出了处理, 一般情况下即使掉线了也会重连, 不过还是有可能会遗漏事件',
h('br'),
'为避免这种情况, 建议注册本站账后使用',
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
tag: 'a',
href: 'https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs',
target: '_blank',
},
() => 'VtsuruEventFetcher',
),
', 否则请在使用功能时尽量保持网页在前台运行, 同时关闭浏览器的 页面休眠/内存节省 功能',
h('br'),
'Chrome: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81',
target: '_blank',
},
() => '让特定网站保持活动状态',
),
', Edge: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25',
target: '_blank',
},
() => '永远不想进入睡眠状态的网站',
),
]),
),
},
),
]
),
default: () => (isBiliVerified.value
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定'),
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false || !isBiliVerified.value,
children: [
withFavoriteExtra({
label: () => !isBiliVerified.value ? '弹幕机' : h(NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
),
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
}),
withFavoriteExtra({
label: () => !isBiliVerified.value ? '抽奖' : h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value ? '点播' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value ? '点歌' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value ? '排队' : h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value ? '读弹幕' : h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
}),
/*withFavoriteExtra({
label: () => !isBiliVerified.value ? '弹幕投票' : h(
RouterLink,
{ to: { name: 'manage-danmakuVote' } },
{ default: () => '弹幕投票' },
),
key: 'manage-danmakuVote',
icon: renderIcon(Chat24Filled),
disabled: !isBiliVerified.value,
}),*/
],
}
// 扁平化叶子项用于收藏置顶
const flattenLeaf = (items: any[]): any[] => {
const result: any[] = []
for (const it of items) {
if (it.children?.length) {
result.push(...flattenLeaf(it.children))
} else {
result.push(it)
}
}
return result
}
const allLeaf = [
...flattenLeaf(commonItems),
...flattenLeaf(dataItems),
...flattenLeaf(toolsItems),
...flattenLeaf(danmakuItem.children ?? []),
]
const leafMap = new Map(allLeaf.map(i => [i.key, i]))
const favorites = (favoriteMenuItems.value ?? [])
.map(k => leafMap.get(k))
.filter(Boolean) as any[]
const notFav = (i: any) => !isFavorite(i.key)
const danmakuChildren = (danmakuItem.children ?? []).filter(notFav)
const danmakuForGroup = danmakuChildren.length > 0 ? { ...danmakuItem, children: danmakuChildren } : null
const groups: any[] = []
if (favorites.length > 0) {
groups.push({ type: 'group', key: 'group-favorites', label: '我的收藏', children: favorites })
}
if (commonItems.filter(notFav).length > 0) {
groups.push({ type: 'group', key: 'group-common', label: '常用', children: commonItems.filter(notFav) })
}
if (dataItems.filter(notFav).length > 0) {
groups.push({ type: 'group', key: 'group-data', label: '数据', children: dataItems.filter(notFav) })
}
const toolsGroupChildren = [
...(danmakuForGroup ? [danmakuForGroup] : []),
...toolsItems.filter(notFav),
]
if (toolsGroupChildren.length > 0) {
groups.push({ type: 'group', key: 'group-tools', label: '互动与工具', children: toolsGroupChildren })
}
return groups
})
// 重发验证邮件
@@ -590,11 +698,15 @@ onMounted(() => {
<!-- 主导航菜单 -->
<NMenu
class="manage-sider-menu"
style="margin-top: 12px"
:disabled="accountInfo?.isEmailVerified !== true"
:default-value="($route.meta.parent as string) ?? $route.name?.toString()"
:collapsed-width="64"
:collapsed-icon-size="22"
:icon-size="16"
:root-indent="10"
:indent="12"
:options="menuOptions"
/>
@@ -1337,4 +1449,40 @@ onMounted(() => {
.music-player-card .n-tag:hover {
transform: scale(1.05);
}
/* 侧边栏菜单收藏按钮与紧凑样式 */
:deep(.manage-sider-menu .menu-fav) {
opacity: 0;
width: 0;
margin-left: 0;
overflow: hidden;
transition: opacity 0.15s ease, width 0.15s ease, margin-left 0.15s ease;
pointer-events: none; /* 不阻挡文字区域点击 */
display: inline-flex;
align-items: center;
justify-content: center;
}
:deep(.manage-sider-menu .n-menu-item:hover .menu-fav),
:deep(.manage-sider-menu .menu-fav.active) {
opacity: 1;
width: 18px;
margin-left: 6px;
pointer-events: auto;
}
:deep(.manage-sider-menu .menu-fav .n-button) {
padding: 0;
height: 18px;
width: 18px;
}
/* 略微收紧图标与文本的间距,提升有效可读宽度 */
:deep(.manage-sider-menu .n-menu-item .n-menu-item-content .n-menu-item-content__icon) {
margin-right: 6px;
}
:deep(.manage-sider-menu .n-menu-item .n-menu-item-content) {
padding-right: 6px;
}
</style>

View File

@@ -1,21 +1,22 @@
<script setup lang="ts">
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { FunctionTypes, ScheduleWeekInfo } from '@/api/api-models'
import { FunctionTypes, ScheduleDayInfo, ScheduleWeekInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import ScheduleList from '@/components/ScheduleList.vue'
import { BASE_API_URL, CN_HOST, CURRENT_HOST, SCHEDULE_API_URL } from '@/data/constants'
import { CURRENT_HOST, SCHEDULE_API_URL } from '@/data/constants'
import { copyToClipboard } from '@/Utils'
import { TagQuestionMark16Filled } from '@vicons/fluent'
import { useStorage } from '@vueuse/core'
import { addWeeks, endOfWeek, endOfYear, format, isBefore, startOfWeek, startOfYear } from 'date-fns'
import {
NAlert,
NBadge,
NButton,
NCard,
NCheckbox,
NColorPicker,
NDivider,
NFlex,
NIcon,
NInput,
NInputGroup,
NInputGroupLabel,
@@ -24,12 +25,13 @@ import {
NSpace,
NSpin,
NSwitch,
NText,
NTimePicker,
NTooltip,
useMessage,
} from 'naive-ui'
import { SelectMixedOption, SelectOption } from 'naive-ui/es/select/src/interface'
import { VNode, computed, h, onMounted, ref } from 'vue'
import { VNode, computed, h, onMounted, ref, watch } from 'vue'
const rules = {
user: {
@@ -76,33 +78,219 @@ const weekOptions = computed(() => {
return weeks
})
const dayOptions = computed(() => {
const days = [] as SelectMixedOption[]
const days: SelectMixedOption[] = []
for (let i = 0; i < 7; i++) {
try {
days.push({
label: updateScheduleModel.value?.days[i].tag ? weekdays[i] + ' (已安排)' : weekdays[i],
value: i,
})
} catch (err) {
console.error(err)
}
const entries = updateScheduleModel.value?.days?.[i] ?? []
const count = entries.length
days.push({
label: count > 0 ? `${weekdays[i]} (共${count}项)` : weekdays[i],
value: i,
})
}
return days
})
const existTagOptions = computed(() => {
const colors = [] as SelectMixedOption[]
const colors: SelectMixedOption[] = []
const exists = new Set<string>()
schedules.value?.forEach((s) => {
s.days.forEach((d) => {
if (d.tag && !colors.find((c) => c.value == d.tagColor && c.label == d.tag)) {
colors.push({
label: d.tag,
value: d.tagColor ?? '',
})
}
s.days.forEach((dayList) => {
dayList.forEach((item) => {
const tag = item.tag ?? ''
const color = normalizeColor(item.tagColor) ?? ''
if (tag) {
const key = `${tag}__${color}`
if (!exists.has(key)) {
exists.add(key)
colors.push({
label: tag,
value: color,
})
}
}
})
})
})
return colors
})
function normalizeColor(color: any): string | null {
// 如果是 null 或 undefined返回 null
if (color == null) return null
// 将 HSL 转 RGB返回 #RRGGBB
const hslToHex = (h: number, s: number, l: number) => {
const hue = ((h % 360) + 360) % 360 / 360 // 归一化到 [0, 1)
const saturation = Math.min(Math.max(s / 100, 0), 1)
const lightness = Math.min(Math.max(l / 100, 0), 1)
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
let r: number, g: number, b: number
if (saturation === 0) {
r = g = b = lightness
} else {
const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation
const p = 2 * lightness - q
r = hue2rgb(p, q, hue + 1 / 3)
g = hue2rgb(p, q, hue)
b = hue2rgb(p, q, hue - 1 / 3)
}
const toHex = (c: number) => {
const hex = Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
}
// 如果是字符串,尝试解析并规范化
if (typeof color === 'string') {
const str = color.trim()
// 1) 处理 hex统一为 #RRGGBB 大写
if (str.startsWith('#')) {
const hex = str.replace('#', '')
if (hex.length === 3) {
const r = hex[0]
const g = hex[1]
const b = hex[2]
return (`#${r}${r}${g}${g}${b}${b}`).toUpperCase()
}
if (hex.length >= 6) {
return (`#${hex.substring(0, 6)}`).toUpperCase()
}
return str.toUpperCase()
}
// 2) 处理 hsla/hsl 字符串
const hslMatch = str.match(/^hsla?\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)%\s*,\s*([+-]?\d+(?:\.\d+)?)%\s*(?:,\s*([+-]?\d*(?:\.\d+)?)\s*)?\)$/i)
if (hslMatch) {
const h = parseFloat(hslMatch[1])
const s = parseFloat(hslMatch[2])
const l = parseFloat(hslMatch[3])
return hslToHex(h, s, l)
}
// 3) 处理 rgba/rgb 字符串,忽略 alpha
const rgbMatch = str.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([+-]?\d*(?:\.\d+)?)\s*)?\)$/i)
if (rgbMatch) {
const r = Math.min(255, Math.max(0, parseInt(rgbMatch[1])))
const g = Math.min(255, Math.max(0, parseInt(rgbMatch[2])))
const b = Math.min(255, Math.max(0, parseInt(rgbMatch[3])))
const toHex = (n: number) => n.toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
}
// 4) 其他字符串,原样返回(交由下游使用场景判断)
return str
}
// 如果是数组([h, s, l, (a)] HS(L)A转换为十六进制
if (Array.isArray(color) && color.length >= 3) {
const [h, s, l] = color
return hslToHex(Number(h), Number(s), Number(l))
}
return null
}
function createEmptyDay(): ScheduleDayInfo {
return {
title: null,
tag: null,
tagColor: null,
time: null,
id: null,
}
}
function createEmptyDays(): ScheduleDayInfo[][] {
return Array.from({ length: 7 }, () => [] as ScheduleDayInfo[])
}
function normalizeWeek(week?: ScheduleWeekInfo): ScheduleWeekInfo {
const normalizedDays = Array.from({ length: 7 }, (_, index) => {
const list = week?.days?.[index]
if (!Array.isArray(list)) return [] as ScheduleDayInfo[]
return list
.filter(Boolean)
.map((item) => ({
title: item?.title ?? null,
tag: item?.tag ?? null,
tagColor: normalizeColor(item?.tagColor),
time: item?.time ?? null,
id: item?.id ?? null,
}))
})
return {
year: week?.year ?? new Date().getFullYear(),
week: week?.week ?? Number(format(Date.now(), 'w')) + 1,
days: normalizedDays,
}
}
function cloneWeek(week: ScheduleWeekInfo, options: { resetIds?: boolean } = {}): ScheduleWeekInfo {
// 深度克隆以完全断开响应式引用
const deepCloned = JSON.parse(JSON.stringify(week))
const normalized = normalizeWeek(deepCloned)
return {
year: normalized.year,
week: normalized.week,
days: normalized.days.map((dayList) =>
dayList.map((item) => ({
title: item.title ?? null,
tag: item.tag ?? null,
tagColor: normalizeColor(item.tagColor),
time: item.time ?? null,
id: options.resetIds ? null : item.id ?? null,
})),
),
}
}
function createEmptyWeek(year?: number, week?: number): ScheduleWeekInfo {
return {
year: year ?? new Date().getFullYear(),
week: week ?? Number(format(Date.now(), 'w')) + 1,
days: createEmptyDays(),
}
}
function ensureDayInitialized(target: ScheduleWeekInfo, dayIndex: number) {
if (!target.days || !Array.isArray(target.days)) {
target.days = createEmptyDays()
}
if (!Array.isArray(target.days[dayIndex])) {
target.days[dayIndex] = []
}
if (target.days[dayIndex].length === 0) {
target.days[dayIndex].push(createEmptyDay())
}
}
function sanitizeDays(days?: ScheduleDayInfo[][]): ScheduleDayInfo[][] {
return Array.from({ length: 7 }, (_, index) => {
const list = days?.[index] ?? []
return list
.filter((item) => !!item && (item.title?.trim() || item.tag?.trim() || item.time?.trim()))
.map((item) => ({
title: item.title?.trim() || null,
tag: item.tag?.trim() || null,
tagColor: normalizeColor(item.tagColor),
time: item.time?.trim() || null,
id: item.id ?? null,
}))
})
}
function getAllWeeks(year: number) {
const startDate = startOfYear(new Date(year, 0, 1))
const endDate = endOfYear(new Date(year, 11, 31))
@@ -125,7 +313,7 @@ function getAllWeeks(year: number) {
return weeks
}
const accountInfo = useAccount()
const schedules = ref<ScheduleWeekInfo[]>()
const schedules = ref<ScheduleWeekInfo[]>([])
const message = useMessage()
const isLoading = ref(true)
@@ -133,13 +321,47 @@ const isLoading = ref(true)
const showUpdateModal = ref(false)
const showAddModal = ref(false)
const showCopyModal = ref(false)
const updateScheduleModel = ref<ScheduleWeekInfo>({} as ScheduleWeekInfo)
const updateScheduleModel = ref<ScheduleWeekInfo>(createEmptyWeek())
const selectedExistTag = ref()
const editingItemIndex = ref<number | null>(null)
const selectedDay = ref(0)
const selectedScheduleYear = ref(new Date().getFullYear())
const selectedScheduleWeek = ref(Number(format(Date.now(), 'w')) + 1)
watch(showUpdateModal, (visible) => {
if (visible) {
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
// 清理所有可能的数组格式颜色值
updateScheduleModel.value.days.forEach(dayList => {
dayList.forEach(item => {
if (item.tagColor && Array.isArray(item.tagColor)) {
item.tagColor = normalizeColor(item.tagColor)
}
})
})
}
})
// 深度监听 updateScheduleModel 的 tagColor确保它们始终是字符串格式
watch(
() => updateScheduleModel.value.days,
(days) => {
days?.forEach(dayList => {
dayList?.forEach(item => {
if (item.tagColor && Array.isArray(item.tagColor)) {
item.tagColor = normalizeColor(item.tagColor)
}
})
})
},
{ deep: true }
)
watch(selectedDay, (value) => {
ensureDayInitialized(updateScheduleModel.value, value)
})
async function get() {
isLoading.value = true
await QueryGetAPI<ScheduleWeekInfo[]>(SCHEDULE_API_URL + 'get', {
@@ -147,12 +369,12 @@ async function get() {
})
.then((data) => {
if (data.code == 200) {
schedules.value = data.data
schedules.value = (data.data ?? []).map((week) => normalizeWeek(week))
} else {
message.error('加载失败: ' + data.message)
}
})
.catch((err) => {
.catch(() => {
message.error('加载失败')
})
.finally(() => (isLoading.value = false))
@@ -160,15 +382,17 @@ async function get() {
const isFetching = ref(false)
async function addSchedule() {
isFetching.value = true
const emptyWeek = createEmptyWeek(selectedScheduleYear.value, selectedScheduleWeek.value)
await QueryPostAPI(SCHEDULE_API_URL + 'update', {
year: selectedScheduleYear.value,
week: selectedScheduleWeek.value,
year: emptyWeek.year,
week: emptyWeek.week,
days: emptyWeek.days,
})
.then((data) => {
if (data.code == 200) {
message.success('添加成功')
showAddModal.value = false
get()
schedules.value = [...schedules.value, emptyWeek]
} else {
message.error('添加失败: ' + data.message)
}
@@ -183,30 +407,60 @@ async function onCopySchedule() {
} else {
updateScheduleModel.value.year = selectedScheduleYear.value
updateScheduleModel.value.week = selectedScheduleWeek.value
await onUpdateSchedule()
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
await saveSchedule(null)
showCopyModal.value = false
}
}
async function onUpdateSchedule() {
async function saveSchedule(day: number | null) {
isFetching.value = true
await QueryPostAPI(SCHEDULE_API_URL + 'update', {
const sanitizedDays = sanitizeDays(updateScheduleModel.value.days)
const payload: {
year: number
week: number
day?: number
days: ScheduleDayInfo[][]
} = {
year: updateScheduleModel.value.year,
week: updateScheduleModel.value.week,
day: selectedDay.value,
days: updateScheduleModel.value?.days,
})
days: sanitizedDays,
}
if (day !== null && day !== undefined) {
payload.day = day
}
await QueryPostAPI(SCHEDULE_API_URL + 'update', payload)
.then((data) => {
if (data.code == 200) {
message.success('成功')
const s = schedules.value?.find(
(s) => s.year == selectedScheduleYear.value && s.week == selectedScheduleWeek.value,
const normalizedWeek = normalizeWeek({
year: payload.year,
week: payload.week,
days: sanitizedDays,
})
const index = schedules.value.findIndex(
(s) => s.year == updateScheduleModel.value.year && s.week == updateScheduleModel.value.week,
)
if (s) {
s.days[selectedDay.value] = updateScheduleModel.value.days[selectedDay.value]
if (index >= 0) {
if (day !== null && day !== undefined) {
const current = cloneWeek(schedules.value[index])
current.days[day] = normalizedWeek.days[day]
schedules.value.splice(index, 1, current)
} else {
schedules.value.splice(index, 1, normalizedWeek)
}
} else {
schedules.value?.push(updateScheduleModel.value)
schedules.value.push(normalizedWeek)
}
//updateScheduleModel.value = {} as ScheduleWeekInfo
updateScheduleModel.value = normalizeWeek({
year: payload.year,
week: payload.week,
days: sanitizedDays,
})
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
} else {
message.error('修改失败: ' + data.message)
}
@@ -215,6 +469,9 @@ async function onUpdateSchedule() {
isFetching.value = false
})
}
async function onUpdateSchedule() {
await saveSchedule(selectedDay.value)
}
async function onDeleteSchedule(schedule: ScheduleWeekInfo) {
await QueryGetAPI(SCHEDULE_API_URL + 'del', {
year: schedule.year,
@@ -229,19 +486,97 @@ async function onDeleteSchedule(schedule: ScheduleWeekInfo) {
})
}
function onOpenUpdateModal(schedule: ScheduleWeekInfo) {
updateScheduleModel.value = JSON.parse(JSON.stringify(schedule))
updateScheduleModel.value = cloneWeek(schedule)
selectedDay.value = 0
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
showUpdateModal.value = true
}
function onOpenCopyModal(schedule: ScheduleWeekInfo) {
updateScheduleModel.value = JSON.parse(JSON.stringify(schedule))
updateScheduleModel.value = cloneWeek(schedule, { resetIds: true })
selectedDay.value = 0
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
showCopyModal.value = true
}
function onSelectChange(value: string | null, option: SelectMixedOption) {
function onEditScheduleItem(schedule: ScheduleWeekInfo, dayIndex: number, item: ScheduleDayInfo) {
updateScheduleModel.value = cloneWeek(schedule)
selectedDay.value = dayIndex
ensureDayInitialized(updateScheduleModel.value, dayIndex)
showUpdateModal.value = true
}
async function onDeleteScheduleItem(schedule: ScheduleWeekInfo, dayIndex: number, item: ScheduleDayInfo) {
const targetSchedule = schedules.value.find(s => s.year === schedule.year && s.week === schedule.week)
if (!targetSchedule) return
const itemIndex = targetSchedule.days[dayIndex].findIndex(i =>
i.id === item.id || (i.title === item.title && i.time === item.time && i.tag === item.tag)
)
if (itemIndex === -1) return
const updatedDays = targetSchedule.days.map((dayList, idx) => {
if (idx === dayIndex) {
return dayList.filter((_, i) => i !== itemIndex)
}
return dayList
})
await QueryPostAPI(SCHEDULE_API_URL + 'update', {
year: schedule.year,
week: schedule.week,
days: sanitizeDays(updatedDays),
}).then((data) => {
if (data.code == 200) {
message.success('已删除')
const index = schedules.value.findIndex(s => s.year === schedule.year && s.week === schedule.week)
if (index >= 0) {
schedules.value[index] = normalizeWeek({
year: schedule.year,
week: schedule.week,
days: updatedDays,
})
}
} else {
message.error('删除失败: ' + data.message)
}
})
}
function onSelectChange(value: string | null, option: SelectMixedOption, itemIndex: number) {
if (value) {
updateScheduleModel.value.days[selectedDay.value].tagColor = value
updateScheduleModel.value.days[selectedDay.value].tag = option.label as string
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
const entry = updateScheduleModel.value.days[selectedDay.value][itemIndex]
if (entry) {
entry.tagColor = value
entry.tag = option.label as string
}
}
}
function addScheduleItem() {
ensureDayInitialized(updateScheduleModel.value, selectedDay.value)
updateScheduleModel.value.days[selectedDay.value].push(createEmptyDay())
}
function removeScheduleItem(index: number) {
const dayList = updateScheduleModel.value.days[selectedDay.value]
if (dayList && dayList.length > 0) {
dayList.splice(index, 1)
if (dayList.length === 0) {
dayList.push(createEmptyDay())
}
}
}
function moveScheduleItem(index: number, direction: 'up' | 'down') {
const dayList = updateScheduleModel.value.days[selectedDay.value]
if (!dayList) return
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= dayList.length) return
const temp = dayList[index]
dayList[index] = dayList[targetIndex]
dayList[targetIndex] = temp
}
const renderOption = ({ node, option }: { node: VNode; option: SelectOption }) =>
h(NSpace, { align: 'center', size: 3, style: 'margin-left: 5px' }, () => [
option.value ? h(NBadge, { dot: true, color: option.value?.toString() }) : null,
@@ -395,7 +730,7 @@ onMounted(() => {
</NModal>
<NModal
v-model:show="showUpdateModal"
style="width: 600px; max-width: 90vw"
style="width: 800px; max-width: 95vw; max-height: 90vh;"
preset="card"
title="编辑周程"
>
@@ -405,60 +740,142 @@ onMounted(() => {
/>
<NDivider />
<template v-if="updateScheduleModel">
<NSpace vertical>
<NSpace>
<NInputGroup>
<NInputGroupLabel type="primary">
标签
</NInputGroupLabel>
<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"
/>
</NSpace>
<NInputGroup>
<NInputGroupLabel> 内容 </NInputGroupLabel>
<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()"
<div
style="
max-height: calc(90vh - 300px);
overflow-y: auto;
padding-right: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
"
>
<NSpace
vertical
:size="12"
>
保存
</NButton>
</NSpace>
<NButton
type="primary"
secondary
@click="addScheduleItem"
>
+ 添加行程项
</NButton>
<NCard
v-for="(item, itemIndex) in updateScheduleModel.days[selectedDay]"
:key="itemIndex"
size="small"
:bordered="true"
:style="{
borderLeft: item.tagColor ? `4px solid ${item.tagColor}` : 'none',
backgroundColor: item.tagColor ? item.tagColor + '08' : 'transparent'
}"
>
<template #header>
<NSpace align="center" :size="8">
<NText strong style="font-size: 14px;">行程 {{ itemIndex + 1 }}</NText>
<NButton
v-if="itemIndex > 0"
size="tiny"
quaternary
@click="moveScheduleItem(itemIndex, 'up')"
>
</NButton>
<NButton
v-if="itemIndex < updateScheduleModel.days[selectedDay].length - 1"
size="tiny"
quaternary
@click="moveScheduleItem(itemIndex, 'down')"
>
</NButton>
</NSpace>
</template>
<template #header-extra>
<NButton
size="tiny"
type="error"
quaternary
@click="removeScheduleItem(itemIndex)"
>
删除
</NButton>
</template>
<NSpace
vertical
:size="12"
>
<NSpace align="center" :size="8" style="flex-wrap: wrap;">
<NInputGroup style="width: auto; min-width: 200px;">
<NInputGroupLabel type="primary" style="min-width: 50px;">
标签
</NInputGroupLabel>
<NInput
v-model:value="item.tag"
placeholder="标签名称"
style="width: 150px;"
maxlength="10"
show-count
/>
</NInputGroup>
<NSelect
:value="null"
:options="existTagOptions"
filterable
clearable
placeholder="选择已用标签"
style="width: 140px;"
:render-option="renderOption"
@update:value="(val, opt) => onSelectChange(val, opt, itemIndex)"
/>
</NSpace>
<NInputGroup>
<NInputGroupLabel style="min-width: 50px;"> 内容 </NInputGroupLabel>
<NInput
v-model:value="item.title"
placeholder="事件内容描述"
maxlength="50"
show-count
/>
</NInputGroup>
<NSpace align="center" :size="8">
<NInputGroup style="width: auto;">
<NInputGroupLabel style="min-width: 50px;">时间</NInputGroupLabel>
<NTimePicker
v-model:formatted-value="item.time"
default-formatted-value="20:00"
format="HH:mm"
style="width: 120px"
clearable
/>
</NInputGroup>
<NInputGroup style="width: auto;">
<NInputGroupLabel style="min-width: 50px;">颜色</NInputGroupLabel>
<NColorPicker
:key="`color-${selectedDay}-${itemIndex}-${item.id || 'new'}`"
:value="normalizeColor(item.tagColor)"
@update:value="(val) => item.tagColor = normalizeColor(val)"
:swatches="['#18A058', '#2080F0', '#F0A020', '#D03050', '#9333EA', '#14B8A6']"
default-value="#2080F0"
:show-alpha="false"
:modes="['hex']"
style="width: 120px;"
/>
</NInputGroup>
</NSpace>
</NSpace>
</NCard>
</NSpace>
</div>
<NDivider />
<NButton
type="primary"
:loading="isFetching"
block
@click="onUpdateSchedule()"
>
保存全部
</NButton>
</template>
</NModal>
<NSpin
@@ -472,5 +889,39 @@ onMounted(() => {
@on-update="onOpenUpdateModal"
@on-delete="onDeleteSchedule"
@on-copy="onOpenCopyModal"
@on-edit-item="onEditScheduleItem"
@on-delete-item="onDeleteScheduleItem"
/>
</template>
<style scoped>
/* 自定义滚动条样式 - Webkit浏览器 */
div::-webkit-scrollbar {
width: 8px;
height: 8px;
}
div::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
div::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
transition: background 0.2s;
}
div::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 深色模式下的滚动条 */
html.dark div::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
html.dark div::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -99,48 +99,55 @@
year: 2023,
week: 30,
days: [
{
[{
title: '唱唱歌!',
tag: '歌回',
tagColor: '#61B589',
time: '10:00 AM',
},
{
id: null,
}],
[{
title: '玩点游戏',
tag: '游戏',
tagColor: '#A36565',
time: '20:00 PM',
},
{
id: null,
}],
[{
title: 'Title 3',
tag: 'Tag 3',
tagColor: '#7BCDEF',
time: '11:00 PM',
},
{
id: null,
}],
[{
title: null,
tag: null,
tagColor: null,
time: null,
},
{
id: null,
}],
[{
title: null,
tag: null,
tagColor: null,
time: null,
},
{
id: null,
}],
[{
title: null,
tag: null,
tagColor: null,
time: null,
},
{
id: null,
}],
[{
title: null,
tag: null,
tagColor: null,
time: null,
},
id: null,
}],
],
},
] as ScheduleWeekInfo[],

View File

@@ -162,16 +162,20 @@ 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, index) => {
const dayList = currentWeekData.value!.days[index];
// 如果当天有多个行程,取第一个展示;如果没有则显示默认
const firstItem = Array.isArray(dayList) && dayList.length > 0
? dayList[0]
: { time: '', tag: '', title: '', tagColor: '', id: null };
return {
key: dayKey,
label: dayMap[dayKey] || dayKey,
data: firstItem
};
});
return daysOfWeek.map(dayKey => ({
key: dayKey,
label: dayMap[dayKey] || dayKey,
data: scheduleMap.get(dayKey) || { time: '', tag: '', title: '' }
}));
});
// --- 方法 ---

View File

@@ -21,6 +21,19 @@ const currentWeek = computed(() => {
return isTodayInWeek(item.year, item.week)
})
})
const formattedDays = computed(() => {
const weekData = currentWeek.value
if (!weekData || !Array.isArray(weekData.days)) return []
return weekData.days.map((dayList) => {
// 取每天第一个行程展示
if (Array.isArray(dayList) && dayList.length > 0) {
return dayList[0]
}
return { time: '', tag: '', title: '', tagColor: '', id: null }
})
})
const options = computed(() => {
return props.data?.map((item) => {
return {
@@ -56,7 +69,7 @@ onMounted(() => {
/>
<SaveCompoent
:compoent="table"
:file-name="`周表_${selectedDate}_${userInfo?.name}`"
:file-name="`周表_${selectedDate}_${props.userInfo?.name}`"
/>
</NSpace>
<NDivider />
@@ -66,7 +79,7 @@ onMounted(() => {
>
<div class="schedule-template pinky day-container">
<div
v-for="(item, index) in currentWeek?.days"
v-for="(item, index) in formattedDays"
:id="index.toString()"
:key="index"
class="schedule-template pinky day-item"
@@ -76,15 +89,15 @@ onMounted(() => {
{{ days[index] }}
</span>
<span class="schedule-template pinky time">
{{ item.time }}
{{ item?.time }}
</span>
<span class="schedule-template pinky tag">
{{ item.tag }}
{{ item?.tag }}
</span>
</div>
<div class="schedule-template pinky day-content-container">
<span
v-if="item.tag"
v-if="item?.tag"
id="work"
class="schedule-template pinky day-content"
>

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"target": "ESNext",
"module": "ESNext",
"strict": true,
"moduleResolution": "bundler",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
@@ -16,7 +16,9 @@
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"lib": ["ESNext", "DOM", "DOM.Iterable", "ScriptHost"],
"resolveJsonModule": true,
"isolatedModules": true
},
"include": [
"src/**/*.ts",
@@ -27,7 +29,9 @@
"env.d.ts",
"default.d.ts",
"src/data/chat/ChatClientDirectOpenLive.js",
"src/data/chat/models.js", "src/store/useDanmakuClient.ts",
"src/data/chat/models.js",
"src/store/useDanmakuClient.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
},
"include": ["vite.config.mts", "eslint.config.mjs"]
}

View File

@@ -7,7 +7,6 @@ import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import Markdown from 'unplugin-vue-markdown/vite';
import { defineConfig } from 'vite';
import oxlintPlugin from 'vite-plugin-oxlint';
import svgLoader from 'vite-svg-loader';
import { VineVitePlugin } from 'vue-vine/vite';
@@ -18,11 +17,11 @@ const removeSodipodiInkscape = {
fn: () => {
return {
element: {
enter: (node, parentNode) => {
enter: (node: any, parentNode: any) => {
// 检查元素名称是否以sodipodi:或inkscape:开头
if (node.name && (node.name.startsWith('sodipodi:') || node.name.startsWith('inkscape:'))) {
// 从父节点的children数组中过滤掉当前节点
parentNode.children = parentNode.children.filter(child => child !== node);
parentNode.children = parentNode.children.filter((child: any) => child !== node);
}
},
},
@@ -85,10 +84,8 @@ export default defineConfig({
resolvers: [NaiveUiResolver()],
dts: 'src/components.d.ts',
extensions: ['vue', 'md'],
include: [/\.vue$/, /\.vue\?vue/, /\.md$/, /\.vine$/],
}),
oxlintPlugin(),
VineVitePlugin(),
],
server: { port: 51000 },
@@ -103,5 +100,16 @@ export default defineConfig({
},
build: {
sourcemap: true,
target: 'esnext',
minify: 'esbuild',
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['naive-ui', '@vueuse/core'],
}
}
}
},
});