Compare commits

...

16 Commits

Author SHA1 Message Date
jxxghp
258e64bca7 fix: 更新站点 Cookie 处理逻辑,添加请求失败提示,优化服务工作者缓存策略 2026-05-31 09:16:52 +08:00
jxxghp
e905df014e fix: add precomposed apple touch icon 2026-05-31 08:39:21 +08:00
jxxghp
b93f8f2bff fix: 消息中心首次打开时SSE与数据库消息重复显示
SSE消息只有date字段、note为null,数据库消息只有reg_time、note为{},
原getMessageKey将reg_time和date作为两个独立字段拼接签名导致同一条消息签名不同。
归一化时间字段(reg_time||date)和note字段后去重恢复正常。
2026-05-30 19:18:55 +08:00
jxxghp
9aa0a5e1b7 更新 package.json 2026-05-30 08:58:34 +08:00
jxxghp
ee9f41d015 更新 package.json 2026-05-30 08:58:22 +08:00
jxxghp
ad6a664cbe fix: proxy bangumi images 2026-05-30 08:54:40 +08:00
Album
3387067636 fix: handle episode group values in preview transfer (#480) 2026-05-30 08:28:00 +08:00
jxxghp
07dc3c3e9a fix: build Emby app deep links with server ids 2026-05-28 15:06:30 +08:00
jxxghp
262b4bebd4 更新 package.json 2026-05-28 14:37:07 +08:00
jxxghp
6e50cf31de fix: correct media server card links 2026-05-28 14:33:50 +08:00
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
29 changed files with 651 additions and 136 deletions

View File

@@ -37,7 +37,7 @@
<!-- iOS Safari PWA 优化 --> <!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" /> <link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 --> <!-- iOS Safari 全屏模式 -->

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.13.1-1", "version": "2.13.3",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -1025,6 +1025,10 @@ export interface FileItem {
export interface MediaServerPlayItem { export interface MediaServerPlayItem {
// ID // ID
id?: string | number id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题 // 标题
title: string title: string
// 副标题 // 副标题
@@ -1049,6 +1053,10 @@ export interface MediaServerLibrary {
server: string server: string
// ID // ID
id?: string | number id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称 // 名称
name: string name: string
// 路径 // 路径
@@ -1292,7 +1300,7 @@ export interface TransferForm {
// 目标存储 // 目标存储
target_storage: string target_storage: string
// 目标路径 // 目标路径
target_path: string target_path: string | null
// TMDB ID // TMDB ID
tmdbid?: number tmdbid?: number
// 豆瓣 ID // 豆瓣 ID
@@ -1335,6 +1343,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
fileitems?: FileItem[] fileitems?: FileItem[]
} }
// 手动整理目的路径匹配结果
export interface ManualTransferTargetPathData {
// 目标存储
target_storage?: string | null
// 目标路径
target_path?: string | null
// 整理方式
transfer_type?: string | null
// 刮削
scrape?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
}
// 手动整理预览统计 // 手动整理预览统计
export interface ManualTransferPreviewSummary { export interface ManualTransferPreviewSummary {
// 总数 // 总数
@@ -1369,6 +1393,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string episode_end?: number | string
// Part // Part
part?: string part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
} }
// 手动整理预览数据 // 手动整理预览数据

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types' import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg' import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink' import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
media: Object as PropType<MediaServerPlayItem>, media: Object as PropType<MediaServerPlayItem>,
@@ -25,8 +25,8 @@ function imageErrorHandler() {
// 跳转播放 // 跳转播放
async function goPlay() { async function goPlay() {
if (props.media?.link) { if (props.media) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type) await openMediaServerItem(props.media)
} }
} }

View File

@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png' import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png' import jellyfin from '@images/misc/jellyfin.png'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink' import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -49,8 +49,8 @@ function getDefaultImage() {
// 跳转播放 // 跳转播放
async function goPlay() { async function goPlay() {
if (props.media?.link) { if (props.media) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type) await openMediaServerItem(props.media)
} }
} }

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import noImage from '@images/no-image.jpeg' import noImage from '@images/no-image.jpeg'
import { getLogoUrl } from '@/utils/imageUtils' import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import api from '@/api' import api from '@/api'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters' import { formatSeason, formatRating } from '@/@core/utils/formatters'
@@ -464,13 +464,7 @@ function setupIntersectionObserver() {
const getImgUrl: Ref<string> = computed(() => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存 return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
}) })
// 移除订阅 // 移除订阅

View File

@@ -3,6 +3,7 @@ import personIcon from '@images/misc/person-icon.png'
import type { Person } from '@/api/types' import type { Person } from '@/api/types'
import router from '@/router' import router from '@/router'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const personProps = defineProps({ const personProps = defineProps({
person: Object as PropType<Person>, person: Object as PropType<Person>,
@@ -40,9 +41,7 @@ function getPersonImage() {
} else { } else {
return personIcon return personIcon
} }
if (globalSettings.GLOBAL_IMAGE_CACHE && url) return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
} }
// 人物姓名 // 人物姓名

View File

@@ -2,7 +2,7 @@
import type { PropType } from 'vue' import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types' import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg' import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink' import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -38,8 +38,8 @@ const getImgUrl = computed(() => {
// 跳转播放 // 跳转播放
async function goPlay(isHovering: boolean | null = false) { async function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) { if (props.media && isHovering) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type) await openMediaServerItem(props.media)
} }
} }
</script> </script>

View File

@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue')) const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue')) const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -363,19 +364,13 @@ watch(
// 计算backdrop图片地址 // 计算backdrop图片地址
const backdropUrl = computed(() => { const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存 return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 计算海报图片地址 // 计算海报图片地址
const posterUrl = computed(() => { const posterUrl = computed(() => {
const url = props.media?.poster const url = props.media?.poster
// 使用图片缓存 return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 订阅编辑保存 // 订阅编辑保存

View File

@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
import router from '@/router' import router from '@/router'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue')) const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue')) const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
// 计算backdrop图片地址 // 计算backdrop图片地址
const backdropUrl = computed(() => { const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存 return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 计算海报图片地址 // 计算海报图片地址
const posterUrl = computed(() => { const posterUrl = computed(() => {
const url = props.media?.poster const url = props.media?.poster
// 使用图片缓存 return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 获得mediaid // 获得mediaid

View File

@@ -102,6 +102,15 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
// 活跃用户统计 // 活跃用户统计
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {}) const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
/** 格式化版本安装统计数字为千分位展示。 */
function formatVersionStatisticNumber(value: unknown) {
const numberValue = Number(value ?? 0)
if (!Number.isFinite(numberValue)) return '0'
return numberValue.toLocaleString()
}
// 打开日志对话框 // 打开日志对话框
function showReleaseDialog(title: string, body: string) { function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title releaseDialogTitle.value = title
@@ -473,19 +482,19 @@ onMounted(() => {
<div class="version-stat-summary"> <div class="version-stat-summary">
<div> <div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div> <div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div> <div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
</div> </div>
<div> <div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div> <div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div> <div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div> </div>
<div> <div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div> <div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div> <div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
</div> </div>
<div> <div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div> <div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div> <div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
</div> </div>
</div> </div>
<div class="mt-5"> <div class="mt-5">
@@ -502,7 +511,7 @@ onMounted(() => {
<td> <td>
<code>{{ item.version }}</code> <code>{{ item.version }}</code>
</td> </td>
<td class="text-end">{{ item.count }}</td> <td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr> </tr>
<tr v-if="!backendVersionStatistics.length"> <tr v-if="!backendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td> <td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
@@ -524,7 +533,7 @@ onMounted(() => {
<td> <td>
<code>{{ item.version }}</code> <code>{{ item.version }}</code>
</td> </td>
<td class="text-end">{{ item.count }}</td> <td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr> </tr>
<tr v-if="!frontendVersionStatistics.length"> <tr v-if="!frontendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td> <td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>

View File

@@ -7,6 +7,7 @@ import { useToast } from 'vue-toastification'
import { VBtn } from 'vuetify/lib/components/index.mjs' import { VBtn } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -88,10 +89,7 @@ async function unfollowUser() {
// 计算海报图片地址 // 计算海报图片地址
const posterUrl = computed(() => { const posterUrl = computed(() => {
const url = props.media?.poster const url = props.media?.poster
// 使用图片缓存 return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 获得mediaid // 获得mediaid

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload, ManualTransferPayload,
ManualTransferPreviewData, ManualTransferPreviewData,
ManualTransferPreviewItem, ManualTransferPreviewItem,
ManualTransferTargetPathData,
StorageConf, StorageConf,
TransferDirectoryConf, TransferDirectoryConf,
TransferForm, TransferForm,
@@ -113,6 +114,14 @@ const episodeFormatRecommendState = reactive<{
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined) const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
interface ManualTransferTargetPathRequest {
fileitem?: FileItem
fileitems?: FileItem[]
logid?: number
logids?: number[]
target_storage?: string | null
}
// 生成文件项稳定键,用于去重和状态同步。 // 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) { function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|') return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -265,7 +274,7 @@ const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem, fileitem: {} as FileItem,
logid: 0, logid: 0,
target_storage: props.target_storage ?? 'local', target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? '', target_path: normalizeTargetPath(props.target_path),
transfer_type: '', transfer_type: '',
min_filesize: 0, min_filesize: 0,
scrape: false, scrape: false,
@@ -292,6 +301,79 @@ const targetDirectories = computed(() => {
return [...new Set(libraryDirectories)] return [...new Set(libraryDirectories)]
}) })
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
const payload: ManualTransferTargetPathRequest = {}
if (props.target_storage) {
payload.target_storage = props.target_storage
}
if (normalizedItems.value.length === 1) {
payload.fileitem = normalizedItems.value[0]
return payload
}
if (normalizedItems.value.length > 1) {
payload.fileitems = normalizedItems.value
return payload
}
if (props.logids?.length) {
if (props.logids.length > 1) {
payload.logids = props.logids
return payload
}
payload.logid = props.logids[0]
return payload
}
}
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
const matchedTargetPath = normalizeTargetPath(data?.target_path)
if (!matchedTargetPath) {
transferForm.target_path = null
return
}
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
transferForm.scrape = data?.scrape ?? false
transferForm.library_type_folder = data?.library_type_folder ?? false
transferForm.library_category_folder = data?.library_category_folder ?? false
transferForm.target_path = matchedTargetPath
}
// 请求后端按源目录匹配最合适的手动整理目的路径。
async function autoSelectTargetPath() {
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
const payload = createTargetPathMatchRequest()
if (!payload) {
transferForm.target_path = null
return
}
try {
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
'transfer/manual/target-path',
payload,
)
if (!result.success) {
transferForm.target_path = null
return
}
applyMatchedTargetPath(result.data)
} catch (error) {
console.log(error)
transferForm.target_path = null
}
}
// 监听目的路径变化,配置默认值 // 监听目的路径变化,配置默认值
watch( watch(
() => transferForm.target_path, () => transferForm.target_path,
@@ -344,6 +426,16 @@ watch(
}, },
) )
watch(
() => transferForm.episode_group,
episodeGroup => {
const normalizedEpisodeGroup = normalizeEpisodeGroup(episodeGroup)
if (episodeGroup !== normalizedEpisodeGroup) {
transferForm.episode_group = normalizedEpisodeGroup
}
},
)
// 过滤后的预览数据 // 过滤后的预览数据
const filteredPreviewItems = computed(() => { const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? [] return previewData.value?.items ?? []
@@ -397,6 +489,28 @@ function getUniqueValues(values: (string | undefined)[]) {
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])] return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
} }
// 归一化可选目的路径,保证未指定时向接口传递 null 而不是空字符串。
function normalizeTargetPath(path?: string | null) {
const normalizedPath = path?.trim()
return normalizedPath || null
}
// 归一化剧集组值,兼容历史对象态值。
function normalizeEpisodeGroup(
episodeGroup?: string | { value?: string | null } | null,
) {
if (!episodeGroup) return null
if (typeof episodeGroup === 'string') {
const normalizedEpisodeGroup = episodeGroup.trim()
return normalizedEpisodeGroup || null
}
if (typeof episodeGroup === 'object' && typeof episodeGroup.value === 'string') {
const normalizedEpisodeGroup = episodeGroup.value.trim()
return normalizedEpisodeGroup || null
}
return null
}
// 统一解析接口返回的数字字段,兼容 string/number // 统一解析接口返回的数字字段,兼容 string/number
function toPreviewNumber(value: unknown) { function toPreviewNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined if (value === undefined || value === null || value === '') return undefined
@@ -511,6 +625,22 @@ const previewFileRows = computed(() => {
}) })
}) })
// 标准化预览项中的识别词命中详情
function getPreviewApplyWords(item: ManualTransferPreviewItem) {
return (item.apply_words ?? []).filter(Boolean)
}
// 手动整理识别词应用详情
const previewCustomWordDetails = computed(() => {
return filteredPreviewItems.value
.map(item => ({
sourceName: getFileName(item.source),
orgString: item.org_string,
applyWords: getPreviewApplyWords(item),
}))
.filter(item => item.applyWords.length > 0)
})
// 是否需要拓宽窗口 // 是否需要拓宽窗口
const previewNeedsWideLayout = computed(() => { const previewNeedsWideLayout = computed(() => {
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)] const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
@@ -620,7 +750,8 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
...transferForm, ...transferForm,
fileitem: sourceItem, fileitem: sourceItem,
logid: options.logid ?? 0, logid: options.logid ?? 0,
episode_group: transferForm.episode_group?.trim() || null, target_path: normalizeTargetPath(transferForm.target_path),
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
} }
if (options.items?.length) { if (options.items?.length) {
@@ -1099,8 +1230,9 @@ async function transfer(background: boolean = false) {
emit('done') emit('done')
} }
onMounted(() => { onMounted(async () => {
loadDirectories() await loadDirectories()
await autoSelectTargetPath()
loadStorages() loadStorages()
loadEpisodeFormatRuleConfiguration() loadEpisodeFormatRuleConfiguration()
}) })
@@ -1218,9 +1350,11 @@ onUnmounted(() => {
</VRow> </VRow>
<VRow v-show="transferForm.type_name === '电视剧'"> <VRow v-show="transferForm.type_name === '电视剧'">
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6"> <VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
<VCombobox <VSelect
v-model="transferForm.episode_group" v-model="transferForm.episode_group"
:items="episodeGroupOptions" :items="episodeGroupOptions"
item-title="title"
item-value="value"
:item-props="episodeGroupItemProps" :item-props="episodeGroupItemProps"
:loading="episodeGroupLoading" :loading="episodeGroupLoading"
:disabled="!transferForm.tmdbid" :disabled="!transferForm.tmdbid"
@@ -1439,6 +1573,36 @@ onUnmounted(() => {
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span> <span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
</div> </div>
</div> </div>
<div v-if="previewCustomWordDetails.length" class="preview-custom-words">
<div class="preview-custom-words__title">
<VIcon icon="mdi-tag-text-outline" size="16" />
<span>{{ t('dialog.reorganize.customWordsApplied') }}</span>
</div>
<div class="preview-custom-words__items">
<div
v-for="(detail, index) in previewCustomWordDetails"
:key="`${detail.sourceName}-${index}`"
class="preview-custom-words__item"
>
<div class="preview-custom-words__source">{{ detail.sourceName }}</div>
<div v-if="detail.orgString" class="preview-custom-words__original">
{{ detail.orgString }}
</div>
<div class="preview-custom-words__chips">
<VChip
v-for="(word, wordIndex) in detail.applyWords"
:key="`${word}-${wordIndex}`"
variant="outlined"
color="info"
size="small"
class="preview-custom-words__chip"
>
{{ word }}
</VChip>
</div>
</div>
</div>
</div>
</div> </div>
<div class="reorganize-preview-list"> <div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body"> <div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
@@ -1698,6 +1862,66 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.75rem;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
}
.preview-custom-words__title {
display: inline-flex;
align-items: center;
color: rgb(var(--v-theme-info));
font-size: 0.875rem;
font-weight: 600;
gap: 0.375rem;
}
.preview-custom-words__items {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-inline-size: 0;
}
.preview-custom-words__item {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__source {
overflow-wrap: anywhere;
color: rgb(var(--v-theme-on-surface));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.4;
}
.preview-custom-words__original {
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.4;
}
.preview-custom-words__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__chip {
max-inline-size: 100%;
white-space: normal;
}
.reorganize-preview-pane__scroll { .reorganize-preview-pane__scroll {
display: flex; display: flex;
overflow: hidden auto; overflow: hidden auto;
@@ -1797,11 +2021,13 @@ onUnmounted(() => {
} }
.preview-file-row__path { .preview-file-row__path {
overflow: hidden; overflow: visible;
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem; font-size: 0.8125rem;
text-overflow: ellipsis; line-height: 1.4;
white-space: nowrap; white-space: normal;
word-break: break-all;
} }
.preview-file-row__card--target .preview-file-row__name { .preview-file-row__card--target .preview-file-row__name {

View File

@@ -10,7 +10,7 @@ const props = defineProps({
type: Array as PropType<Site[]>, type: Array as PropType<Site[]>,
required: true, required: true,
}, },
selected: Array as PropType<Number[]>, selected: Array as PropType<number[]>,
}) })
// 定义事件 // 定义事件
@@ -20,38 +20,66 @@ const emit = defineEmits(['close', 'search', 'reload'])
const siteFilter = ref('') const siteFilter = ref('')
// 已选择站点 // 已选择站点
const selectedSites = ref<any[]>(props.selected || []) const selectedSites = ref<number[]>([])
// 根据当前可用站点清理选中项,避免停用或已删除站点参与计数。
function normalizeSelectedSites(selectedSiteIds: number[] = []) {
const availableSiteIds = new Set(props.sites.map((site: Site) => site.id))
const normalizedSiteIds: number[] = []
selectedSiteIds.forEach(siteId => {
if (availableSiteIds.has(siteId) && !normalizedSiteIds.includes(siteId)) {
normalizedSiteIds.push(siteId)
}
})
return normalizedSiteIds
}
watch( watch(
() => props.selected, [() => props.selected, () => props.sites],
value => { ([value]) => {
if (selectedSites.value.length == 0 && value) { selectedSites.value = normalizeSelectedSites(value || [])
selectedSites.value = value
}
}, },
{ immediate: true },
) )
// 全选/全不选按钮文字 // 全选/全不选按钮文字
const checkAllText = computed(() => { const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length return selectedSites.value.length < props.sites.length
? t('dialog.searchSite.selectAll') ? t('dialog.searchSite.selectAll')
: t('dialog.searchSite.deselectAll') : t('dialog.searchSite.deselectAll')
}) })
// 全选/全不选 // 全选/全不选
const checkAllSitesorNot = () => { const checkAllSitesorNot = () => {
if (selectedSites.value.length < props.sites?.length) { if (selectedSites.value.length < props.sites.length) {
selectedSites.value = props.sites?.map((item: Site) => item.id) selectedSites.value = props.sites.map((item: Site) => item.id)
} else { } else {
selectedSites.value = [] selectedSites.value = []
} }
} }
// 切换单个站点的选择状态。
function toggleSiteSelection(siteId: number) {
const index = selectedSites.value.indexOf(siteId)
if (index === -1) {
selectedSites.value.push(siteId)
} else {
selectedSites.value.splice(index, 1)
}
}
// 确认搜索时只提交当前可用站点。
function confirmSearch() {
emit('search', normalizeSelectedSites(selectedSites.value))
}
// 根据筛选条件过滤站点 // 根据筛选条件过滤站点
const filteredSites = computed(() => { const filteredSites = computed(() => {
if (!siteFilter.value) return props.sites if (!siteFilter.value) return props.sites
const filter = siteFilter.value.toLowerCase() const filter = siteFilter.value.toLowerCase()
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter)) return props.sites.filter((site: Site) => site.name.toLowerCase().includes(filter))
}) })
</script> </script>
<template> <template>
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
'site-hover': isHovering && !selectedSites.includes(site.id), 'site-hover': isHovering && !selectedSites.includes(site.id),
}, },
]" ]"
@click=" @click="toggleSiteSelection(site.id)"
() => {
const index = selectedSites.indexOf(site.id)
if (index === -1) {
selectedSites.push(site.id)
} else {
selectedSites.splice(index, 1)
}
}
"
> >
<VIcon <VIcon
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'" :icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
<VBtn <VBtn
color="primary" color="primary"
:disabled="selectedSites.length === 0" :disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)" @click="confirmSearch"
prepend-icon="mdi-magnify" prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5" class="d-flex align-center justify-center px-5"
> >

View File

@@ -50,23 +50,34 @@ async function updateSiteCookie() {
progressDialog.value = true progressDialog.value = true
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name }) progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, { const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
params: { username: userPwForm.value.username,
username: userPwForm.value.username, password: userPwForm.value.password,
password: userPwForm.value.password, code: userPwForm.value.code,
code: userPwForm.value.code,
},
}) })
if (result.success) { if (result.success) {
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name })) $toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
emit('done') emit('done')
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message })) } else {
$toast.error(
t('dialog.siteCookieUpdate.failed', {
site: cardProps.site?.name,
message: result.message || t('dialog.siteCookieUpdate.requestFailed'),
}),
)
}
} catch (error: any) {
console.error(error)
const detail = error?.response?.data?.detail
const message =
error?.response?.data?.message ||
(typeof detail === 'string' ? detail : error?.message) ||
t('dialog.siteCookieUpdate.requestFailed')
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
} finally {
progressDialog.value = false progressDialog.value = false
updateButtonDisable.value = false updateButtonDisable.value = false
} catch (error) {
console.error(error)
} }
} }
</script> </script>

View File

@@ -83,6 +83,7 @@ interface UseLlmProviderDirectoryOptions {
apiKey: Ref<string> apiKey: Ref<string>
baseUrl: Ref<string> baseUrl: Ref<string>
baseUrlPreset?: Ref<string> baseUrlPreset?: Ref<string>
useProxy?: Ref<boolean>
userAgent?: Ref<string> userAgent?: Ref<string>
model: Ref<string> model: Ref<string>
maxContextTokens?: Ref<number> maxContextTokens?: Ref<number>
@@ -254,6 +255,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
api_key: normalizeValue(options.apiKey.value) || undefined, api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined, base_url: normalizeValue(options.baseUrl.value) || undefined,
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined, base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
use_proxy: options.useProxy?.value,
user_agent: normalizeValue(options.userAgent?.value) || undefined, user_agent: normalizeValue(options.userAgent?.value) || undefined,
force_refresh: forceRefresh, force_refresh: forceRefresh,
}, },

View File

@@ -61,6 +61,7 @@ export interface WizardData {
supportAudioOutput: boolean supportAudioOutput: boolean
apiKey: string apiKey: string
baseUrl: string baseUrl: string
useProxy: boolean
baseUrlPreset: string baseUrlPreset: string
maxContextTokens: number maxContextTokens: number
userAgent: string userAgent: string
@@ -248,6 +249,7 @@ const wizardData = ref<WizardData>({
supportAudioOutput: false, supportAudioOutput: false,
apiKey: '', apiKey: '',
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
useProxy: true,
baseUrlPreset: '', baseUrlPreset: '',
maxContextTokens: 64, maxContextTokens: 64,
userAgent: '', userAgent: '',
@@ -1446,6 +1448,7 @@ export function useSetupWizard() {
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput, LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
LLM_API_KEY: wizardData.value.agent.apiKey, LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null, LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_USE_PROXY: wizardData.value.agent.useProxy,
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null, LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens, LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
LLM_USER_AGENT: wizardData.value.agent.userAgent || null, LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
@@ -1560,6 +1563,7 @@ export function useSetupWizard() {
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT) wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || '' wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || '' wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.useProxy = result.data.LLM_USE_PROXY ?? true
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || '' wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64 wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || '' wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''

View File

@@ -1456,6 +1456,9 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key', llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL', llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints', llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmUseProxy: 'Use System Proxy',
llmUseProxyHint:
'When enabled, Agent connections to the current LLM provider use the system proxy from advanced settings.',
llmUserAgent: 'User-Agent', llmUserAgent: 'User-Agent',
llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.', llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.',
llmProviderAuth: 'Provider Authorization', llmProviderAuth: 'Provider Authorization',
@@ -2449,6 +2452,7 @@ export default {
updating: 'Updating {site} Cookie & UA...', updating: 'Updating {site} Cookie & UA...',
success: '{site} Cookie & UA updated successfully!', success: '{site} Cookie & UA updated successfully!',
failed: '{site} update failed: {message}', failed: '{site} update failed: {message}',
requestFailed: 'Request failed, please try again later',
updateButton: 'Start Update', updateButton: 'Start Update',
}, },
siteAddEdit: { siteAddEdit: {
@@ -2640,6 +2644,7 @@ export default {
previewSeasonInfo: 'Season', previewSeasonInfo: 'Season',
previewSeasonLabel: 'Season', previewSeasonLabel: 'Season',
previewEpisodeCount: 'Episodes', previewEpisodeCount: 'Episodes',
customWordsApplied: 'Recognition Word Details',
previewAfterColumn: 'After', previewAfterColumn: 'After',
previewBeforeColumn: 'Before', previewBeforeColumn: 'Before',
previewFileNameColumn: 'Filename', previewFileNameColumn: 'Filename',

View File

@@ -1448,6 +1448,8 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥', llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL', llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点', llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmUseProxy: '使用系统代理',
llmUseProxyHint: '启用后Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
llmUserAgent: 'User-Agent', llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值', llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值',
llmProviderAuth: '提供商授权', llmProviderAuth: '提供商授权',
@@ -2404,6 +2406,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...', updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功', success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失败:{message}', failed: '{site} 更新失败:{message}',
requestFailed: '请求失败,请稍后重试',
updateButton: '开始更新', updateButton: '开始更新',
}, },
siteAddEdit: { siteAddEdit: {
@@ -2594,6 +2597,7 @@ export default {
previewSeasonInfo: '季信息', previewSeasonInfo: '季信息',
previewSeasonLabel: '季', previewSeasonLabel: '季',
previewEpisodeCount: '总集数', previewEpisodeCount: '总集数',
customWordsApplied: '识别词应用详情',
previewAfterColumn: '整理后', previewAfterColumn: '整理后',
previewBeforeColumn: '整理前', previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名', previewFileNameColumn: '文件名',

View File

@@ -1449,6 +1449,8 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰', llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL', llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點', llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmUseProxy: '使用系統代理',
llmUseProxyHint: '啟用後Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
llmUserAgent: 'User-Agent', llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent留空則使用 SDK 預設值', llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent留空則使用 SDK 預設值',
llmProviderAuth: '提供商授權', llmProviderAuth: '提供商授權',
@@ -2405,6 +2407,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...', updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功', success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失敗:{message}', failed: '{site} 更新失敗:{message}',
requestFailed: '請求失敗,請稍後重試',
updateButton: '開始更新', updateButton: '開始更新',
}, },
siteAddEdit: { siteAddEdit: {
@@ -2595,6 +2598,7 @@ export default {
previewSeasonInfo: '季資訊', previewSeasonInfo: '季資訊',
previewSeasonLabel: '季', previewSeasonLabel: '季',
previewEpisodeCount: '總集數', previewEpisodeCount: '總集數',
customWordsApplied: '識別詞應用詳情',
previewAfterColumn: '整理後', previewAfterColumn: '整理後',
previewBeforeColumn: '整理前', previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名', previewFileNameColumn: '文件名',

View File

@@ -148,13 +148,16 @@ registerRoute(
url.pathname.includes('/api/v1/') && url.pathname.includes('/api/v1/') &&
request.method === 'GET' && request.method === 'GET' &&
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效 !url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
!url.pathname.includes('/api/v1/site/cookie/') && // 站点 Cookie 更新是副作用请求,不能缓存
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流 !url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流 !url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流 !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
!url.pathname.includes('/api/v1/message/') && // 用户消息接口 !url.pathname.includes('/api/v1/message/') && // 用户消息接口
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口 !url.pathname.includes('/api/v1/system/global') && // 系统配置接口
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口 !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据 !url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
new NetworkFirst({ new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`, cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5, networkTimeoutSeconds: 5,

View File

@@ -64,6 +64,149 @@ interface DoubanAppParams {
fallbackUrl?: string fallbackUrl?: string
} }
// 媒体服务器卡片跳转所需的最小字段集合
interface MediaServerLinkTarget {
id?: string | number
item_id?: string | number
itemId?: string | number
server_id?: string
serverId?: string
link?: string
server_type?: string
}
// Emby hash 路由中的目标参数
interface EmbyHashTarget {
mediaId: string | null
serverId: string | null
}
/**
* 判断链接参数是否为有效值。
* @param value 待检查的链接参数
*/
function getValidLinkValue(value?: string | number | null): string | null {
if (value === undefined || value === null) return null
const stringValue = String(value).trim()
if (!stringValue || ['none', 'null', 'undefined'].includes(stringValue.toLowerCase())) return null
return stringValue
}
/**
* 获取媒体服务器条目的真实项目ID。
* @param target 媒体服务器跳转目标
*/
function getTargetItemId(target?: MediaServerLinkTarget): string | null {
return getValidLinkValue(target?.item_id ?? target?.itemId)
}
/**
* 获取媒体服务器条目的真实服务器ID。
* @param target 媒体服务器跳转目标
*/
function getTargetServerId(target?: MediaServerLinkTarget): string | null {
return getValidLinkValue(target?.server_id ?? target?.serverId)
}
/**
* 解析媒体服务器网页链接中的 hash 查询参数。
* @param playUrl 原始播放链接
*/
function getHashRouteParams(playUrl: string): { hashPath: string; params: URLSearchParams } | null {
const url = new URL(playUrl)
const hash = url.hash || ''
const queryIndex = hash.indexOf('?')
if (queryIndex === -1) return null
return {
hashPath: hash.slice(0, queryIndex),
params: new URLSearchParams(hash.slice(queryIndex + 1)),
}
}
/**
* 从 Emby 网页链接中提取 App 跳转需要的媒体ID和服务器ID。
* @param playUrl 原始播放链接
*/
function getEmbyHashTarget(playUrl: string): EmbyHashTarget {
const hashRoute = getHashRouteParams(playUrl)
if (!hashRoute) return { mediaId: null, serverId: null }
const serverId = getValidLinkValue(hashRoute.params.get('serverId'))
if (hashRoute.hashPath.includes('/videos')) {
return {
mediaId: getValidLinkValue(hashRoute.params.get('parentId')),
serverId,
}
}
return {
mediaId: getValidLinkValue(hashRoute.params.get('id')),
serverId,
}
}
/**
* 使用后端返回的真实ID修正Emby网页链接。
* @param playUrl 原始播放链接
* @param target 媒体服务器跳转目标
*/
function normalizeEmbyWebUrl(playUrl: string, target?: MediaServerLinkTarget): string {
try {
const url = new URL(playUrl)
const hashRoute = getHashRouteParams(playUrl)
if (!hashRoute) return playUrl
const { hashPath, params } = hashRoute
const itemId = getTargetItemId(target)
const serverId = getTargetServerId(target)
if (itemId && (hashPath.includes('/item') || params.has('id'))) {
params.set('id', itemId)
}
if (itemId && (hashPath.includes('/videos') || params.has('parentId'))) {
params.set('parentId', itemId)
}
if (serverId) {
params.set('serverId', serverId)
} else if (params.has('serverId') && !getValidLinkValue(params.get('serverId'))) {
params.delete('serverId')
}
url.hash = `${hashPath}?${params.toString()}`
return url.toString()
} catch (error) {
console.warn('修正Emby网页链接失败:', error)
return playUrl
}
}
/**
* 获取媒体服务器卡片可用的跳转链接。
* @param target 媒体服务器跳转目标
*/
function getMediaServerPlayUrl(target: MediaServerLinkTarget): string | null {
const playUrl = getValidLinkValue(target.link)
if (!playUrl) return null
const serverType = target.server_type?.toLowerCase()
if (serverType === 'emby' || serverType === 'zspace') {
return normalizeEmbyWebUrl(playUrl, target)
}
return playUrl
}
/**
* 打开媒体服务器卡片对应的播放页面。
* @param target 媒体服务器跳转目标
*/
export async function openMediaServerItem(target: MediaServerLinkTarget): Promise<void> {
const playUrl = getMediaServerPlayUrl(target)
if (!playUrl) return
await openMediaServerWithAutoDetect(playUrl, undefined, target.server_type)
}
/** /**
* 尝试跳转到APP如果失败则跳转到网页 * 尝试跳转到APP如果失败则跳转到网页
* @param appType APP类型 * @param appType APP类型
@@ -408,26 +551,27 @@ function buildEmbyDeepLink(playUrl: string): string {
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
// 尝试多种格式提取媒体ID // 尝试多种格式提取媒体ID
let mediaId: string | null = null const hashTarget = getEmbyHashTarget(playUrl)
let serverId: string | null = null let mediaId: string | null = hashTarget.mediaId
let serverId: string | null = hashTarget.serverId
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式) // 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/) const itemHashMatch = !mediaId ? playUrl.match(/\/item\?id=([^&]+)/) : null
if (itemHashMatch) { if (!mediaId && itemHashMatch) {
mediaId = itemHashMatch[1] mediaId = itemHashMatch[1]
// 提取serverId // 提取serverId
const serverIdMatch = playUrl.match(/serverId=([^&]+)/) const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
if (serverIdMatch) { if (serverIdMatch) {
serverId = serverIdMatch[1] serverId = getValidLinkValue(serverIdMatch[1])
} }
} }
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式) // 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) const videosHashMatch = !mediaId ? playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) : null
if (videosHashMatch) { if (!mediaId && videosHashMatch) {
// 对于videos格式我们使用parentId作为媒体ID // 对于videos格式我们使用parentId作为媒体ID
mediaId = videosHashMatch[2] mediaId = videosHashMatch[2]
serverId = videosHashMatch[1] serverId = getValidLinkValue(videosHashMatch[1])
} }
// 格式3: ?id=xxx (通用格式) // 格式3: ?id=xxx (通用格式)
@@ -464,22 +608,22 @@ function buildEmbyDeepLink(playUrl: string): string {
if (mediaId) { if (mediaId) {
let deepLink: string let deepLink: string
const encodedMediaId = encodeURIComponent(mediaId)
const encodedServerId = serverId ? encodeURIComponent(serverId) : null
// 根据设备类型使用不同的深度链接格式 // 根据设备类型使用不同的深度链接格式
if (isIOSDevice()) { if (isIOSDevice()) {
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID} // iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
if (serverId) { if (encodedServerId) {
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}` deepLink = `emby://items?serverId=${encodedServerId}&itemId=${encodedMediaId}`
} else { } else {
// 如果没有serverId尝试使用服务器地址作为serverId deepLink = `emby://items?itemId=${encodedMediaId}`
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
} }
} else if (encodedServerId) {
// Android格式: emby://items/{SERVER_ID}/{ITEM_ID}
deepLink = `emby://items/${encodedServerId}/${encodedMediaId}`
} else { } else {
// Android格式: emby://{服务器地址}/item/{媒体ID} deepLink = `emby://${serverAddress}/item/${encodedMediaId}`
deepLink = `emby://${serverAddress}/item/${mediaId}`
if (serverId) {
deepLink += `?serverId=${serverId}`
}
} }
console.log('Emby深度链接构建成功:', { console.log('Emby深度链接构建成功:', {

View File

@@ -80,6 +80,39 @@ export function getLogoUrl(logoName: string): string {
return logoMap[logoName] || '' return logoMap[logoName] || ''
} }
/**
* 判断是否为需要强制走后端代理的 Bangumi 图片。
* @param url 图片地址
* @returns 是否为 Bangumi 图片地址
*/
export function isBangumiImageUrl(url: string): boolean {
if (!url) return false
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'lain.bgm.tv' || hostname.endsWith('.lain.bgm.tv')
} catch {
return url.includes('lain.bgm.tv')
}
}
/**
* 将远程图片地址转换为前端可直接展示的地址。
* @param url 原始图片地址
* @param useCache 是否使用后端图片缓存
* @returns 转换后的图片地址
*/
export function getDisplayImageUrl(url: string, useCache = false): string {
if (!url || !/^https?:\/\//i.test(url)) return url
const encodedUrl = encodeURIComponent(url)
if (isBangumiImageUrl(url))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodedUrl}${useCache ? '&cache=true' : ''}`
if (useCache)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodedUrl}`
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodedUrl}`
return url
}
/** /**
* 获取所有可用的图标名称 * 获取所有可用的图标名称
* @returns 图标名称数组 * @returns 图标名称数组

View File

@@ -14,8 +14,9 @@ import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink' import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue')) const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue')) const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
@@ -417,31 +418,19 @@ function getEpisodeImage(stillPath: string) {
function getW500Image(url = '') { function getW500Image(url = '') {
if (!url) return '' if (!url) return ''
url = url.replace('original', 'w500') url = url.replace('original', 'w500')
// 使用图片缓存 return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
} }
// 计算Poster地址 // 计算Poster地址
const getPosterUrl: Ref<string> = computed(() => { const getPosterUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.poster_path ?? '' const url = mediaDetail.value.poster_path ?? ''
// 使用图片缓存 return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
}) })
// 计算backdrop地址 // 计算backdrop地址
const getBackdropUrl: Ref<string> = computed(() => { const getBackdropUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.backdrop_path ?? '' const url = mediaDetail.value.backdrop_path ?? ''
// 使用图片缓存 return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}) })
// 获取发行国家名称 // 获取发行国家名称
@@ -526,7 +515,12 @@ async function handlePlay() {
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`) const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
if (result?.success) { if (result?.success) {
// 使用深度链接工具优先跳转到APP失败后跳转到网页 // 使用深度链接工具优先跳转到APP失败后跳转到网页
await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type) await openMediaServerItem({
link: result.data.url,
item_id: result.data.item_id,
server_id: result.data.server_id,
server_type: result.data.server_type,
})
} else { } else {
$toast.error(`获取播放链接失败:${result.message}`) $toast.error(`获取播放链接失败:${result.message}`)
} }

View File

@@ -6,6 +6,7 @@ import type { Person } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -64,10 +65,7 @@ function getPersonImage() {
} else { } else {
return personIcon return personIcon
} }
// 使用图片缓存 return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
} }
// 将别名数组拆分为、分隔的字符串 // 将别名数组拆分为、分隔的字符串

View File

@@ -57,6 +57,7 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_OUTPUT: false, LLM_SUPPORT_AUDIO_OUTPUT: false,
LLM_API_KEY: null, LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com', LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null, LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64, LLM_MAX_CONTEXT_TOKENS: 64,
LLM_USER_AGENT: null, LLM_USER_AGENT: null,
@@ -214,6 +215,7 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string LLM_THINKING_LEVEL: string
LLM_API_KEY: string LLM_API_KEY: string
LLM_BASE_URL: string LLM_BASE_URL: string
LLM_USE_PROXY: boolean
LLM_BASE_URL_PRESET: string LLM_BASE_URL_PRESET: string
LLM_USER_AGENT: string LLM_USER_AGENT: string
} }
@@ -249,6 +251,13 @@ const llmBaseUrlPresetRef = computed({
}, },
}) })
const llmUseProxyRef = computed({
get: () => Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
set: value => {
SystemSettings.value.Basic.LLM_USE_PROXY = Boolean(value)
},
})
const llmUserAgentRef = computed({ const llmUserAgentRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''), get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
set: value => { set: value => {
@@ -301,6 +310,7 @@ const {
apiKey: llmApiKeyRef, apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef, baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef, baseUrlPreset: llmBaseUrlPresetRef,
useProxy: llmUseProxyRef,
userAgent: llmUserAgentRef, userAgent: llmUserAgentRef,
model: llmModelRef, model: llmModelRef,
maxContextTokens: llmMaxContextRef, maxContextTokens: llmMaxContextRef,
@@ -362,6 +372,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'), LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''), LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''), LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''), LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''), LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
} }
@@ -379,6 +390,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(), thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(), api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(), base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY,
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(), base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
user_agent: snapshot.LLM_USER_AGENT.trim(), user_agent: snapshot.LLM_USER_AGENT.trim(),
} }
@@ -1206,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</template> </template>
</VCombobox> </VCombobox>
</VCol> </VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12">
<VSwitch
v-model="SystemSettings.Basic.LLM_USE_PROXY"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6"> <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
<VTextField <VTextField
v-model="SystemSettings.Basic.LLM_API_KEY" v-model="SystemSettings.Basic.LLM_API_KEY"

View File

@@ -40,6 +40,13 @@ const baseUrlPresetRef = computed({
}, },
}) })
const useProxyRef = computed({
get: () => wizardData.value.agent.useProxy,
set: value => {
wizardData.value.agent.useProxy = Boolean(value)
},
})
const userAgentRef = computed({ const userAgentRef = computed({
get: () => wizardData.value.agent.userAgent, get: () => wizardData.value.agent.userAgent,
set: value => { set: value => {
@@ -99,6 +106,7 @@ const {
apiKey: apiKeyRef, apiKey: apiKeyRef,
baseUrl: baseUrlRef, baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef, baseUrlPreset: baseUrlPresetRef,
useProxy: useProxyRef,
userAgent: userAgentRef, userAgent: userAgentRef,
model: modelRef, model: modelRef,
maxContextTokens: maxContextTokensRef, maxContextTokens: maxContextTokensRef,
@@ -341,6 +349,16 @@ onMounted(async () => {
</VCombobox> </VCombobox>
</VCol> </VCol>
<VCol v-if="showBaseUrlField" cols="12">
<VSwitch
v-model="wizardData.agent.useProxy"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol v-if="showApiKeyField" cols="12" md="6"> <VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField <VTextField
v-model="wizardData.agent.apiKey" v-model="wizardData.agent.apiKey"

View File

@@ -15,7 +15,7 @@ const messages = ref<Message[]>([])
const currData = ref<Message[]>([]) const currData = ref<Message[]>([])
// 已加载消息的签名集合 // 已加载消息的签名集合
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息 // SSE 消息与数据库消息的字段来源不同date vs reg_time, null vs {}),签名已归一化处理
const messageKeys = new Set<string>() const messageKeys = new Set<string>()
// 是否完成加载 // 是否完成加载
@@ -42,26 +42,34 @@ const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined let scrollReleaseTimer: number | undefined
// 获取消息时间 // 生成消息去重签名
function getMessageTime(message: Message) { // SSE 消息只有 date 没有 reg_time数据库消息只有 reg_time 没有 date
return message.reg_time || message.date || '' // note 在 SSE 侧为 null数据库侧为 {},需要归一化。
function normalizeNote(note: Message['note']): string {
if (note == null) return ''
if (typeof note === 'string') return note
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
return JSON.stringify(note)
} }
// 生成消息签名
function getMessageKey(message: Message) { function getMessageKey(message: Message) {
return [ return [
message.action ?? '', message.action ?? '',
message.userid ?? '', message.userid ?? '',
message.reg_time ?? '', message.reg_time || message.date || '',
message.date ?? '',
message.title ?? '', message.title ?? '',
message.text ?? '', message.text ?? '',
message.image ?? '', message.image ?? '',
message.link ?? '', message.link ?? '',
message.note ?? '', normalizeNote(message.note),
].join('::') ].join('::')
} }
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 排序消息列表,确保最新消息始终位于底部 // 排序消息列表,确保最新消息始终位于底部
function sortMessages(items: Message[]) { function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b))) return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))