feat: 更新组件和配置,增强功能和用户体验, 添加签到功能

- 在 .editorconfig 中调整文件格式设置,统一代码风格。
- 在 default.d.ts 中为 naive-ui 添加 TabPaneSlots 接口声明,增强类型支持。
- 在多个组件中优化了模板和样式,提升用户交互体验。
- 在 ClientAutoAction.vue 中新增签到设置标签页,丰富功能选项。
- 在 Utils.ts 中增强 GUID 处理逻辑,增加输入验证和错误处理。
- 更新多个组件的逻辑,简化代码结构,提升可读性和维护性。
This commit is contained in:
2025-04-26 01:35:59 +08:00
parent e48b3df236
commit 8bed5bbc1a
24 changed files with 2004 additions and 328 deletions

View File

@@ -206,197 +206,289 @@ onUnmounted(() => {
</script>
<template>
<div
class="question-box-container"
title="提问"
>
<div class="question-box-container">
<!-- 提问表单 -->
<NCard embedded>
<NSpace vertical>
<!-- 话题选择区域 -->
<NCard
v-if="tags.length > 0"
title="投稿话题 (可选)"
size="small"
>
<NSpace>
<NTag
v-for="tag in tags"
:key="tag"
style="cursor: pointer"
:bordered="false"
:type="selectedTag === tag ? 'primary' : 'default'"
@click="onSelectTag(tag)"
<transition
name="fade-slide-down"
appear
>
<NCard
embedded
class="question-form-card"
:class="{ 'self-user': isSelf }"
>
<NSpace vertical>
<!-- 话题选择区域 -->
<transition
name="fade-scale"
appear
>
<NCard
v-if="tags.length > 0"
title="投稿话题 (可选)"
size="small"
class="topic-card"
>
{{ tag }}
</NTag>
</NSpace>
</NCard>
<transition-group
name="tag-list"
tag="div"
class="tag-container"
>
<NTag
v-for="tag in tags"
:key="tag"
class="tag-item"
:bordered="false"
:type="selectedTag === tag ? 'primary' : 'default'"
@click="onSelectTag(tag)"
>
{{ tag }}
</NTag>
</transition-group>
</NCard>
</transition>
<!-- 提问内容区域 -->
<NSpace
align="center"
justify="center"
>
<NInput
v-model:value="questionMessage"
:disabled="isSelf"
show-count
maxlength="5000"
type="textarea"
:count-graphemes="countGraphemes"
style="width: 300px"
/>
<NUpload
v-model:file-list="fileList"
:max="1"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
list-type="image-card"
:disabled="!accountInfo.id || isSelf"
:default-upload="false"
@update:file-list="OnFileListChange"
<!-- 提问内容区域 -->
<div class="question-input-area">
<NInput
v-model:value="questionMessage"
:disabled="isSelf"
show-count
maxlength="5000"
type="textarea"
:count-graphemes="countGraphemes"
class="question-textarea"
placeholder="在这里输入您的问题..."
/>
<transition
name="fade-scale"
>
<NUpload
v-model:file-list="fileList"
:max="1"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
list-type="image-card"
:disabled="!accountInfo.id || isSelf"
:default-upload="false"
class="image-upload"
@update:file-list="OnFileListChange"
>
<div class="upload-trigger">
<div class="upload-icon">
+
</div>
<span>上传图片</span>
</div>
</NUpload>
</transition>
</div>
<NDivider class="form-divider" />
<!-- 提示信息 -->
<transition
name="fade"
appear
>
+ 上传图片
</NUpload>
</NSpace>
<NAlert
v-if="!accountInfo.id && !isSelf"
type="warning"
class="login-alert"
>
只有注册用户才能够上传图片
</NAlert>
</transition>
<NDivider style="margin: 10px 0" />
<!-- 提示信息 -->
<NSpace align="center">
<NAlert
v-if="!accountInfo.id && !isSelf"
type="warning"
<!-- 匿名选项 -->
<transition
name="fade"
appear
>
只有注册用户才能够上传图片
</NAlert>
</NSpace>
<NSpace
v-if="accountInfo.id"
vertical
class="anonymous-option"
>
<NCheckbox
v-model:checked="isAnonymous"
:disabled="isSelf"
label="匿名提问"
/>
<NDivider class="form-divider" />
</NSpace>
</transition>
<!-- 匿名选项 -->
<NSpace
v-if="accountInfo.id"
vertical
>
<NCheckbox
v-model:checked="isAnonymous"
:disabled="isSelf"
label="匿名提问"
/>
<NDivider style="margin: 10px 0" />
</NSpace>
<!-- 操作按钮 -->
<div class="action-buttons">
<NButton
:disabled="isSelf"
type="primary"
:loading="isSending || !token"
class="send-button"
@click="SendQuestion"
>
发送
</NButton>
<NButton
:disabled="isSelf || !accountInfo.id"
type="info"
class="my-questions-button"
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })"
>
我发送的
</NButton>
</div>
<!-- 操作按钮 -->
<NSpace justify="center">
<NButton
:disabled="isSelf"
type="primary"
:loading="isSending || !token"
@click="SendQuestion"
<!-- 验证码 -->
<div class="turnstile-container">
<VueTurnstile
ref="turnstile"
v-model="token"
:site-key="TURNSTILE_KEY"
theme="auto"
/>
</div>
<!-- 错误提示 -->
<transition
name="fade-slide-up"
appear
>
发送
</NButton>
<NButton
:disabled="isSelf || !accountInfo.id"
type="info"
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })"
>
我发送的
</NButton>
<NAlert
v-if="isSelf"
type="warning"
class="self-alert"
>
不能给自己提问
</NAlert>
</transition>
</NSpace>
<!-- 验证码 -->
<VueTurnstile
ref="turnstile"
v-model="token"
:site-key="TURNSTILE_KEY"
theme="auto"
style="text-align: center"
/>
<!-- 错误提示 -->
<NAlert
v-if="isSelf"
type="warning"
>
不能给自己提问
</NAlert>
</NSpace>
</NCard>
</NCard>
</transition>
<!-- 公开回复列表 -->
<NDivider> 公开回复 </NDivider>
<NList v-if="publicQuestions.length > 0">
<NListItem
v-for="item in publicQuestions"
:key="item.id"
>
<NCard
:embedded="!item.isReaded"
hoverable
size="small"
<transition
name="fade"
appear
>
<div>
<NDivider class="public-divider">
<div class="divider-content">
公开回复
</div>
</NDivider>
<transition-group
name="list-fade"
tag="div"
class="questions-list-container"
>
<!-- 问题头部 -->
<template #header>
<NSpace
:size="0"
align="center"
>
<NText
depth="3"
style="font-size: small"
>
<NTooltip>
<template #trigger>
<NTime
:time="item.sendAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
</NSpace>
</template>
<!-- 问题内容 -->
<NCard style="text-align: center">
{{ item.question.message }}
<br>
<NImage
v-if="item.question.image"
:src="item.question.image"
height="100"
lazy
/>
</NCard>
<!-- 回答内容 -->
<template
v-if="item.answer"
#footer
<NList
v-if="publicQuestions.length > 0"
class="questions-list"
>
<NSpace
align="center"
:size="6"
:wrap="false"
<NListItem
v-for="item in publicQuestions"
:key="item.id"
class="question-list-item"
>
<NAvatar
:src="AVATAR_URL + userInfo?.biliId + '?size=64'"
circle
:size="45"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider vertical />
<NText style="font-size: 16px">
{{ item.answer?.message }}
</NText>
</NSpace>
</template>
</NCard>
</NListItem>
</NList>
<NEmpty v-else />
<NCard
:embedded="!item.isReaded"
hoverable
size="small"
class="question-card"
:class="{ 'unread': !item.isReaded }"
>
<!-- 问题头部 -->
<template #header>
<NSpace
:size="0"
align="center"
class="question-header"
>
<NText
depth="3"
class="time-text"
>
<NTooltip>
<template #trigger>
<NTime
:time="item.sendAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
<div
v-if="item.tag"
class="question-tag"
>
<NTag
size="small"
type="info"
>
{{ item.tag }}
</NTag>
</div>
</NSpace>
</template>
<!-- 问题内容 -->
<NCard class="question-content">
<div class="question-message">
{{ item.question.message }}
</div>
<div
v-if="item.question.image"
class="question-image-container"
>
<NImage
:src="item.question.image"
class="question-image"
lazy
object-fit="contain"
/>
</div>
</NCard>
<!-- 回答内容 -->
<template
v-if="item.answer"
#footer
>
<div class="answer-container">
<NSpace
align="center"
:wrap="false"
class="answer-content"
>
<NAvatar
:src="AVATAR_URL + userInfo?.biliId + '?size=64'"
circle
class="answer-avatar"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider
vertical
class="answer-divider"
/>
<NText class="answer-text">
{{ item.answer?.message }}
</NText>
</NSpace>
</div>
</template>
</NCard>
</NListItem>
</NList>
<NEmpty
v-else
class="empty-state"
/>
</transition-group>
</div>
</transition>
<NDivider />
</div>
@@ -408,5 +500,380 @@ onUnmounted(() => {
margin: 0 auto;
position: relative;
width: 100%;
padding: 0 16px;
}
/* 卡片样式 */
.question-form-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
overflow: hidden;
}
.question-form-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.self-user {
border-left: 4px solid #f5222d;
}
/* 话题选择卡片 */
.topic-card {
border-radius: 8px;
overflow: hidden;
}
.tag-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
border-radius: 16px;
}
.tag-item:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 提问输入区域 */
.question-textarea {
flex: 1;
border-radius: 8px;
transition: all 0.3s ease;
width: 100% !important; /* 使用 !important 确保不被其他样式覆盖 */
min-height: 100px; /* 设置最小高度 */
resize: vertical; /* 允许垂直调整大小 */
}
/* 设置 naive-ui 内部元素样式 */
.question-textarea :deep(.n-input__textarea) {
min-width: 100% !important;
width: 100% !important;
}
.question-textarea :deep(.n-input__textarea-el) {
min-width: 100% !important;
width: 100% !important;
}
/* 确保输入框容器占满可用空间 */
.question-input-area {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
width: 100%;
}
@media (min-width: 640px) {
.question-input-area {
flex-direction: row;
align-items: flex-start;
}
/* 在水平布局中设置输入框区域的最小宽度 */
.question-textarea {
min-width: 75%; /* 占据至少75%的宽度 */
}
}
.image-upload {
min-width: 112px;
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
opacity: 0.8;
transition: all 0.3s ease;
}
.upload-trigger:hover {
opacity: 1;
transform: scale(1.05);
}
.upload-icon {
font-size: 24px;
margin-bottom: 4px;
}
/* 分隔线 */
.form-divider {
margin: 10px 0;
opacity: 0.6;
}
/* 警告提示 */
.login-alert,
.self-alert {
border-radius: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(255, 213, 79, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0);
}
}
/* 匿名选项 */
.anonymous-option {
margin: 8px 0;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin: 8px 0 16px 0;
}
.send-button,
.my-questions-button {
min-width: 100px;
transition: all 0.3s ease;
border-radius: 20px;
}
.send-button:not(:disabled):hover,
.my-questions-button:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 验证码容器 */
.turnstile-container {
display: flex;
justify-content: center;
margin: 8px 0;
}
/* 公开回复部分 */
.public-divider {
margin: 32px 0 24px;
position: relative;
}
.divider-content {
font-weight: bold;
color: #36ad6a;
background-image: linear-gradient(90deg, #36ad6a, #18a058);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
display: inline-block;
padding: 0 8px;
position: relative;
}
.questions-list-container {
position: relative;
}
.questions-list {
border-radius: 12px;
overflow: hidden;
}
.question-list-item {
margin-bottom: 16px;
}
.question-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.question-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.unread {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
}
.question-header {
padding: 8px 0;
}
.time-text {
font-size: 12px;
opacity: 0.8;
}
.question-tag {
margin-left: 8px;
}
.question-content {
text-align: center;
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 6px;
}
.question-message {
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 12px;
}
.question-image-container {
display: flex;
justify-content: center;
margin-top: 12px;
}
.question-image {
max-height: 200px;
border-radius: 8px;
transition: all 0.3s ease;
}
.question-image:hover {
transform: scale(1.02);
}
.answer-container {
padding: 12px;
background-color: rgba(24, 160, 88, 0.06);
border-radius: 8px;
margin-top: 8px;
}
.answer-content {
gap: 12px;
}
.answer-avatar {
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 2px solid #fff;
transition: all 0.3s ease;
}
.answer-avatar:hover {
transform: scale(1.05);
}
.answer-divider {
height: 24px;
margin: 0 4px;
}
.answer-text {
font-size: 16px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.empty-state {
padding: 24px;
opacity: 0.7;
}
/* 过渡动效 */
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 下滑淡入 */
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.5s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-20px);
}
/* 上滑淡入 */
.fade-slide-up-enter-active,
.fade-slide-up-leave-active {
transition: all 0.5s ease;
}
.fade-slide-up-enter-from,
.fade-slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 缩放淡入 */
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.5s ease;
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.9);
}
/* 标签列表过渡 */
.tag-list-move {
transition: all 0.5s ease;
}
.tag-list-enter-active,
.tag-list-leave-active {
transition: all 0.5s ease;
}
.tag-list-enter-from,
.tag-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 问题列表过渡 */
.list-fade-move {
transition: transform 0.5s ease;
}
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.5s ease;
}
.list-fade-enter-from,
.list-fade-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

View File

@@ -31,6 +31,8 @@ const selectedLanguage = ref<string | undefined>();
const selectedTag = ref<string | undefined>(); // Renamed from activeTab for clarity
const searchQuery = ref<string>('');
const selectedArtist = ref<string | null>(null);
// 添加点歌条件筛选状态
const selectedOption = ref<string | undefined>();
// --- New: Sorting State ---
type SortKey = 'name' | 'author' | 'language' | 'tags' | 'options' | 'description' | null;
@@ -42,6 +44,10 @@ const sortOrder = ref<'asc' | 'desc'>('asc'); // 当前排序顺序
// Extract unique languages
const allUniqueLanguages = computed<string[]>(() => {
const languages = new Set<string>();
// 添加"未设定"语言选项
languages.add('未设定');
props.data?.forEach(song => {
song.language?.forEach(lang => {
if (lang?.trim()) {
@@ -60,6 +66,10 @@ const languageButtons = computed<FilterButton[]>(() =>
// Extract unique tags (similar to original 'tabs' logic)
const allUniqueTags = computed<string[]>(() => {
const tags = new Set<string>();
// 添加"未设定"标签选项
tags.add('未设定');
props.data?.forEach(song => {
song.tags?.forEach(tag => {
if (tag?.trim()) {
@@ -75,6 +85,28 @@ const tagButtons = computed<FilterButton[]>(() =>
allUniqueTags.value.map((tag, index) => ({ id: index, name: tag }))
);
// --- 添加点歌条件筛选按钮 ---
// 提取所有唯一的点歌条件类型
const allOptionTypes = computed<string[]>(() => {
const optionTypes = new Set<string>();
// 添加"未设定"选项
optionTypes.add('未设定');
// 添加基本选项类型
optionTypes.add('舰长');
optionTypes.add('提督');
optionTypes.add('总督');
optionTypes.add('粉丝牌');
optionTypes.add('SC');
return Array.from(optionTypes);
});
// 创建点歌条件筛选按钮
const optionButtons = computed<FilterButton[]>(() =>
allOptionTypes.value.map((option, index) => ({ id: index, name: option }))
);
// --- Computed Properties for Data ---
@@ -105,21 +137,52 @@ const filteredAndSortedSongs = computed(() => {
// 1. Filter by Selected Language
if (selectedLanguage.value) {
const lang = selectedLanguage.value;
query = query.Where(song => song.language?.includes(lang));
if (lang === '未设定') {
// 筛选没有设置语言或语言数组为空的歌曲
query = query.Where(song => !song.language || song.language.length === 0);
} else {
query = query.Where(song => song.language?.includes(lang));
}
}
// 2. Filter by Selected Tag
if (selectedTag.value) {
const tag = selectedTag.value;
query = query.Where(song => song.tags?.includes(tag) ?? false);
if (tag === '未设定') {
// 筛选没有设置标签或标签数组为空的歌曲
query = query.Where(song => !song.tags || song.tags.length === 0);
} else {
query = query.Where(song => song.tags?.includes(tag) ?? false);
}
}
// 3. Filter by Selected Artist
if (selectedArtist.value) {
const artist = selectedArtist.value;
query = query.Where(song => song.author?.includes(artist));
query = query.Where(song => song.author?.includes(artist) ?? false);
}
// 新增: 4. 根据点歌条件筛选
if (selectedOption.value) {
const option = selectedOption.value;
if (option === '未设定') {
// 筛选没有设置点歌条件的歌曲
query = query.Where(song => !song.options);
} else if (option === '舰长') {
query = query.Where(song => song.options?.needJianzhang === true);
} else if (option === '提督') {
query = query.Where(song => song.options?.needTidu === true);
} else if (option === '总督') {
query = query.Where(song => song.options?.needZongdu === true);
} else if (option === '粉丝牌') {
query = query.Where(song => (song.options?.fanMedalMinLevel ?? 0) > 0);
} else if (option === 'SC') {
query = query.Where(song => (song.options?.scMinPrice ?? 0) > 0);
}
}
// 原有的搜索逻辑
// 4. Filter by Search Query (case-insensitive, including tags)
if (searchQuery.value.trim()) {
const lowerSearch = searchQuery.value.toLowerCase().trim();
@@ -190,6 +253,15 @@ const selectTag = (tagName: string) => {
}
};
// 新增: 选择/取消选择点歌条件
const selectOption = (optionName: string) => {
if (optionName === selectedOption.value) {
selectedOption.value = undefined; // 点击已激活的按钮则取消筛选
} else {
selectedOption.value = optionName;
}
};
// Select Artist (from table click, updated to allow deselect)
const selectArtistFromTable = (artist: string) => {
if (selectedArtist.value === artist) {
@@ -213,6 +285,7 @@ const clearFilters = () => {
selectedLanguage.value = undefined;
selectedTag.value = undefined;
selectedArtist.value = null; // Reset NSelect value
selectedOption.value = undefined; // 清除点歌条件筛选
searchQuery.value = '';
};
@@ -361,8 +434,8 @@ function GetPlayButton(song: SongsInfo) {
// --- New: Helper function for Song Request Options ---
function getOptionDisplay(options?: SongRequestOption) {
if (!options) {
// 为"无特殊要求"添加 'empty-placeholder' 类
return h('span', { class: 'empty-placeholder' }, '无特殊要求');
// 直接返回空元素,不显示"无特殊要求"
return h('span', {});
}
const conditions: VNode[] = [];
@@ -384,8 +457,8 @@ function getOptionDisplay(options?: SongRequestOption) {
}
if (conditions.length === 0) {
// 为"无特殊要求"添加 'empty-placeholder' 类
return h('span', { class: 'empty-placeholder' }, '无特殊要求');
// 如果没有条件,直接返回空元素,不显示"无特殊要求"
return h('span', {});
}
// Use NFlex for better wrapping
@@ -697,6 +770,23 @@ export const Config = defineTemplateConfig([
</button>
</div>
<!-- 新增: 点歌条件筛选按钮 -->
<div
v-if="optionButtons.length > 0"
class="filter-button-group option-filters"
>
<span class="filter-label">点歌条件:</span>
<button
v-for="option in optionButtons"
:key="option.id"
:class="{ active: selectedOption === option.name }"
class="filter-button"
@click="selectOption(option.name)"
>
{{ option.name }}
</button>
</div>
<!-- Divider -->
<div class="filter-divider" />
@@ -885,10 +975,11 @@ export const Config = defineTemplateConfig([
<span v-if="index < song.language.length - 1">, </span>
</span>
</span>
<span v-else>未知</span>
<!-- 移除了 "未知" 占位文本 -->
</td>
<td>
<n-flex
v-if="song.tags && song.tags.length > 0"
:size="4"
:wrap="true"
style="gap: 4px;"
@@ -904,12 +995,8 @@ export const Config = defineTemplateConfig([
>
{{ tag }}
</n-tag>
<!-- "无标签"添加 'empty-placeholder' -->
<span
v-if="!song.tags || song.tags.length === 0"
class="empty-placeholder"
>无标签</span>
</n-flex>
<!-- 移除了 "无标签" 占位文本 -->
</td>
<td>
<component :is="getOptionDisplay(song.options)" />
@@ -981,7 +1068,6 @@ html.dark .filter-button {
html.dark .filter-button:hover:not(.active) {
background-color: var(--item-color-hover);
border-color: var(--border-color-hover);
}
/* Divider between filters and search bar */