Compare commits

...

38 Commits

Author SHA1 Message Date
jxxghp
288e63ce68 更新 package.json 2025-12-28 17:46:37 +08:00
jxxghp
b3885584bb Merge pull request #415 from PKC278/v2 2025-12-28 17:17:49 +08:00
PKC278
968b24be1e feat(globalSetting): 添加版本检查与通知功能 2025-12-28 16:35:16 +08:00
jxxghp
5a23c1783a 更新 UserProfileView.vue 2025-12-23 23:18:11 +08:00
jxxghp
ddeeb5a7c3 更新 UserProfileView.vue 2025-12-23 23:16:28 +08:00
jxxghp
0b9bbcc7b8 Merge pull request #414 from PKC278/v2 2025-12-23 23:03:10 +08:00
PKC278
022c8b4515 fix(icon): 更新apple-touch-icon
refactor(html): 移除不必要的预加载链接
2025-12-23 22:43:42 +08:00
jxxghp
be04991928 Merge pull request #413 from PKC278/v2 2025-12-23 17:37:30 +08:00
PKC278
34770567a5 fix(mfa): 修复双重验证漏洞 2025-12-23 15:15:41 +08:00
jxxghp
6154fc2157 Merge pull request #412 from PKC278/v2 2025-12-23 14:40:56 +08:00
PKC278
e77dcdd3d4 feat(passkey): 添加PassKey支持并优化双重验证登录逻辑 2025-12-23 13:53:55 +08:00
jxxghp
58a3532c1b 更新 package.json 2025-12-23 12:53:07 +08:00
jxxghp
116a5eeb43 Merge pull request #411 from HankunYu/v2 2025-12-23 12:52:04 +08:00
HankunYu
decd50cb40 更新Discord模块支持互动消息 2025-12-22 20:00:06 +00:00
HankunYu
355563244c 通知渠道增加Discord 2025-12-22 02:11:09 +00:00
jxxghp
51aad628b5 fix path mapping ui 2025-12-10 14:20:59 +08:00
jxxghp
7dd7a2cf34 Merge pull request #409 from stkevintan/download_uri 2025-12-08 18:45:56 +08:00
Kevin Tan
4c0ff7c7f2 Delete vite.config.ts.timestamp-1765185924563-2ee2d81ca5c1a.mjs 2025-12-08 18:27:26 +08:00
stkevintan
8aba3cbe00 fix key index issue 2025-12-08 17:43:49 +08:00
stkevintan
e21c3ec507 update naming 2025-12-08 17:26:26 +08:00
stkevintan
fdbb0b2ca8 fix: recommended 2025-12-08 17:25:25 +08:00
stkevintan
180195ab7d refine the logic of update storage path 2025-12-08 16:21:41 +08:00
stkevintan
8add4e6b46 implement path mapping UI 2025-12-08 14:01:42 +08:00
stkevintan
3d622d2efe add path mapping 2025-12-08 09:15:51 +08:00
jxxghp
bb7ed7b963 Merge pull request #408 from stkevintan/download_uri 2025-12-06 20:05:02 +08:00
stkevintan
d541ea41ad filter out undefined options 2025-12-06 20:02:50 +08:00
stkevintan
7c7ebc9eb7 display storage type on the download path 2025-12-06 19:49:03 +08:00
jxxghp
22275c3b12 更新 package.json 2025-12-06 03:51:19 +08:00
jxxghp
8744a34e8e Merge pull request #407 from stkevintan/file-browser-initial 2025-12-06 03:50:51 +08:00
stkevintan
e98836fd0e simplify sort on FileNavigator 2025-12-06 00:00:29 +08:00
stkevintan
feb62196a2 keep sort in sync 2025-12-05 23:50:37 +08:00
stkevintan
9fd29a2958 enhance the file browser 2025-12-05 23:42:24 +08:00
jxxghp
546c82ca40 更新 package.json 2025-11-25 15:54:10 +08:00
jxxghp
f132dc38f4 Merge pull request #404 from wikrin/heartbeat 2025-11-25 15:53:04 +08:00
jxxghp
58c70b8ca6 chore: bump version to 2.8.6 in package.json and add global AI assistant settings in locales and AccountSettingSystem.vue 2025-11-23 13:50:35 +08:00
Attente
147f55eefe feat(App): 添加心跳机制通过后端刷新资源访问令牌 2025-11-23 13:40:34 +08:00
jxxghp
229b7b0c12 chore: bump version to 2.8.5 in package.json 2025-11-20 19:37:58 +08:00
jxxghp
4b7b5ff8a4 fix #397 2025-11-20 19:37:33 +08:00
26 changed files with 1571 additions and 220 deletions

View File

@@ -34,7 +34,7 @@
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 -->
@@ -91,10 +91,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<style>
#app {
block-size: 100%;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
return outputArray
}
// Uint8Array 转 Base64URL
export function bufferToBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Base64URL 转 Uint8Array
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {

View File

@@ -38,6 +38,9 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
// 心跳检测
let heartbeatInterval: number | null = null
// ApexCharts 全局配置
declare global {
interface Window {
@@ -45,6 +48,33 @@ declare global {
}
}
// 启动心跳
const startHeartbeat = () => {
// 如果已经有心跳,则先停止
if (heartbeatInterval) {
stopHeartbeat()
}
// 开始心跳任务
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('dashboard/cpu')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)
}
}, 5 * 60 * 1000)
}
// 停止心跳
const stopHeartbeat = () => {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
}
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
@@ -234,11 +264,15 @@ onMounted(async () => {
ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck)
})
// 启动心跳
startHeartbeat()
})
onUnmounted(() => {
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
// 停止心跳
stopHeartbeat()
})
</script>

View File

@@ -1084,6 +1084,8 @@ export interface DownloaderConf {
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 路径映射
path_mapping?: Array<[storagePath: string, downloadPath: string]>
}
// 通知配置

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -5,6 +5,11 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
// 输入参数
const props = defineProps({
storages: Array as PropType<StorageConf[]>,
@@ -119,22 +124,33 @@ const fileIcons = {
// 加载次数
const loading = ref(0)
// 当前存储
const activeStorage = ref('local')
// 刷新
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 排序 - 从localStorage恢复
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
// 是否显示目录树
const showDirTree = ref(false)
// 是否显示目录树 - 从localStorage恢复
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
// 拖动分隔条相关
const navigatorWidth = ref(280) // 初始宽度
// 拖动分隔条相关 - 从localStorage恢复宽度
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
watch(sort, (val) => {
localStorage.setItem(SORT_KEY, val)
})
watch(showDirTree, (val) => {
localStorage.setItem(SHOW_TREE_KEY, String(val))
})
watch(navigatorWidth, (val) => {
localStorage.setItem(NAV_WIDTH_KEY, String(val))
})
// 计算属性
const storagesArray = computed(() => {
return props.storages?.map(item => ({
@@ -144,15 +160,15 @@ const storagesArray = computed(() => {
}))
})
// 方法
function loadingChanged(loading: number) {
if (loading) loading++
else if (loading > 0) loading--
function loadingChanged(isLoading: number) {
if (isLoading) loading.value++
else if (loading.value > 0) loading.value--
}
// 存储切换
async function storageChanged(storage: string) {
activeStorage.value = storage
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
}
@@ -235,12 +251,12 @@ function stopDrag() {
<template>
<div class="mx-auto" :loading="loading > 0">
<div v-if="activeStorage && item">
<div v-if="item">
<FileToolbar
:sort="sort"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axios"
@storagechanged="storageChanged"
@@ -251,7 +267,7 @@ function stopDrag() {
<div class="flex">
<FileNavigator
v-if="showDirTree"
:storage="activeStorage"
:storage="item.storage"
:currentPath="item.path"
:items="fileListItems"
:endpoints="endpoints"
@@ -266,7 +282,6 @@ function stopDrag() {
</div>
<FileList
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"

View File

@@ -7,7 +7,7 @@ import type { DownloaderInfo } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { downloaderDict, storageAttributes } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
@@ -52,6 +52,54 @@ const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 表单
const downloaderForm = ref()
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
function getStorageType(path: string) {
if (!path) return 'local'
// 查找匹配的存储类型
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
// 获取存储路径前后缀
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
// 更新存储路径前缀
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
// 更新存储路径后缀
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
@@ -59,8 +107,24 @@ const downloaderInfo = ref<DownloaderConf>({
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 生成随机ID
function generateId() {
return Math.random().toString(36).substring(2, 9)
}
// 下载器是否应该刷新数据的计算属性
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
@@ -92,11 +156,24 @@ async function loadDownloaderInfo() {
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
// 初始化路径映射行数据
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
download: item[1],
}))
downloaderInfoDialog.value = true
}
// 保存详情数据
function saveDownloaderInfo() {
async function saveDownloaderInfo() {
// 表单校验
const { valid } = await downloaderForm.value?.validate()
if (!valid) return
// 同步路径映射数据
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
@@ -134,6 +211,20 @@ const getIcon = computed(() => {
}
})
// 添加路径映射
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
// 移除路径映射
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
// 按钮点击
function onClose() {
emit('close')
@@ -152,6 +243,7 @@ onUnmounted(() => {
stopRefresh()
})
</script>
<template>
<div>
<VHover v-slot="hover">
@@ -212,7 +304,7 @@ onUnmounted(() => {
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
@@ -373,6 +465,89 @@ onUnmounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="pe-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">

View File

@@ -49,6 +49,7 @@ const notificationTypeNames: { [key: string]: string } = {
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
discord: t('notification.discord.name'),
webpush: t('notification.webpush.name'),
custom: t('setting.notification.custom'),
}
@@ -102,6 +103,8 @@ const getIcon = computed(() => {
return getLogoUrl('synologychat')
case 'slack':
return getLogoUrl('slack')
case 'discord':
return getLogoUrl('discord')
case 'webpush':
return getLogoUrl('chrome')
default:
@@ -350,6 +353,47 @@ function onClose() {
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'discord'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
:label="t('notification.discord.botToken')"
:hint="t('notification.discord.botTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_GUILD_ID"
:label="t('notification.discord.guildId')"
:placeholder="t('notification.discord.guildIdPlaceholder')"
:hint="t('notification.discord.guildIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
:label="t('notification.discord.channelId')"
:placeholder="t('notification.discord.channelIdPlaceholder')"
:hint="t('notification.discord.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound-box"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { numberValidator } from '@/@validators'
import { useGlobalSettingsStore } from '@/stores'
// 多语言支持
const { t } = useI18n()
// 从 provide 中获取全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
// 输入参数
const props = defineProps({
title: String,
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 是否显示高级选项
const showAdvancedOptions = ref(false)
// TMDB ID
const tmdbid = ref<number | undefined>(undefined)
// 豆瓣ID
const doubanId = ref<string | undefined>(undefined)
// TMDB选择对话框
const mediaSelectorDialog = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
@@ -56,9 +78,21 @@ async function loadDirectories() {
}
}
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
}
if (item.storage === 'local') {
return item.download_path
}
return item.storage + ':' + item.download_path
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value.map(item => item.download_path)
const downloadDirectories = directories.value
.map(item => convertToUri(item))
.filter((item): item is string => item !== undefined)
return [...new Set(downloadDirectories)]
})
@@ -96,6 +130,14 @@ async function addDownload() {
payload.media_in = props.media
}
// 添加媒体ID辅助识别
if (tmdbid.value) {
payload.tmdbid = tmdbid.value
}
if (doubanId.value) {
payload.doubanid = doubanId.value
}
const endpoint = props.media ? 'download/' : 'download/add'
result = await api.post(endpoint, payload)
@@ -202,6 +244,56 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow class="px-5 mt-2">
<VCol cols="12">
<VBtn
variant="text"
size="small"
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showAdvancedOptions = !showAdvancedOptions"
>
{{
showAdvancedOptions
? t('dialog.addDownload.hideAdvancedOptions')
: t('dialog.addDownload.showAdvancedOptions')
}}
</VBtn>
</VCol>
</VRow>
<VRow v-show="showAdvancedOptions" class="px-5">
<VCol cols="12">
<VTextField
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
:label="t('dialog.reorganize.tmdbId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
v-else
v-model="doubanId"
:label="t('dialog.reorganize.doubanId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
@@ -209,5 +301,15 @@ onMounted(() => {
</VBtn>
</VCardText>
</VCard>
<!-- 媒体ID选择器 -->
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
@close="mediaSelectorDialog = false"
:type="mediaSource"
/>
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
</VDialog>
</VDialog>
</template>

View File

@@ -26,7 +26,6 @@ const { appMode } = usePWA()
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
@@ -183,6 +182,8 @@ function changeSelectMode() {
// 调API加载文件夹内的内容
async function list_files() {
loading.value = true
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
const prevURI = takeURISnapshot();
emit('loading', true)
// 参数
@@ -195,7 +196,12 @@ async function list_files() {
}
// 加载数据
items.value = (await inProps.axios.request(config)) ?? []
const data = (await inProps.axios.request(config)) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return;
}
items.value = data
emit('loading', false)
loading.value = false
@@ -446,9 +452,9 @@ watch(
},
)
// 监听item变化或者storage变化
// 监听item变化
watch(
[() => inProps.item, () => inProps.storage],
[() => inProps.item],
async () => {
// 清空列表
items.value = []
@@ -550,7 +556,7 @@ async function scrape(item: FileItem, confirm: boolean = true) {
progressDialog.value = true
progressText.value = t('file.scraping', { path: item.path })
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
// 关闭进度条
progressDialog.value = false
@@ -808,7 +814,7 @@ onMounted(() => {
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
:target_storage="inProps.item.storage"
@done="transferDone"
@close="transferPopper = false"
/>

View File

@@ -42,7 +42,7 @@ const availableHeight = computed(() => {
const props = defineProps({
storage: {
type: String,
default: 'local',
required: true,
},
currentPath: {
type: String,
@@ -223,7 +223,7 @@ watch(
watch(
() => props.items,
newItems => {
if (newItems && newItems.length > 0) {
if (newItems) {
// 过滤出目录项
const dirs = newItems.filter(item => item.type === 'dir')
@@ -283,9 +283,6 @@ onMounted(async () => {
await loadRootDirectories()
})
onActivated(() => {
updateHeight()
})
</script>
<template>
@@ -309,7 +306,6 @@ onActivated(() => {
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 加载根目录 -->
<div v-if="loading['/']" class="tree-loading">
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />

View File

@@ -13,7 +13,6 @@ const display = useDisplay()
// 输入参数
const inProps = defineProps({
storages: Array as PropType<any[]>,
storage: String,
item: {
type: Object as PropType<FileItem>,
required: true,
@@ -27,6 +26,10 @@ const inProps = defineProps({
type: Function,
required: true,
},
sort: {
type: String,
default: 'name',
},
})
// 对外事件
@@ -38,15 +41,10 @@ const newFolderPopper = ref(false)
// 新建文件名称
const newFolderName = ref('')
// 排序方式
const sort = ref('name')
// 调整排序方式
function changeSort() {
if (sort.value === 'name') sort.value = 'time'
else sort.value = 'name'
emit('sortchanged', sort.value)
const newSort = inProps.sort === 'name' ? 'time' : 'name'
emit('sortchanged', newSort)
}
// 计算PATH面包屑
@@ -67,12 +65,12 @@ const pathSegments = computed(() => {
// 当前存储
const storageObject = computed(() => {
return inProps.storages?.find(item => item.value === inProps.storage)
return inProps.storages?.find(item => item.value === inProps.item.storage)
})
// 切换存储
function changeStorage(code: string) {
if (inProps.storage !== code) {
if (inProps.item.storage!== code) {
emit('storagechanged', code)
}
}
@@ -113,7 +111,7 @@ async function mkdir() {
// 计算排序图标
const sortIcon = computed(() => {
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending'
})
</script>

View File

@@ -66,6 +66,7 @@ export default {
serviceUnavailable: 'Service Unavailable',
status: 'Status',
preset: 'Preset',
newVersionAvailable: 'New version detected, please refresh the page to get the latest features',
},
mediaType: {
movie: 'Movie',
@@ -248,6 +249,22 @@ export default {
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or two-factor authentication code!',
twoFactorAuth: 'Two-Factor Authentication',
loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification',
verifyWithPasskey: 'Verify with Passkey',
otpPlaceholder: 'Enter 6-digit code',
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
passkeyNotSelected: 'No Passkey selected',
passkeyLoginFailed: 'Passkey login failed',
passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyLoginRetry: 'Passkey login failed, please try again',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: {
selectVerificationMethod: 'Please select a verification method',
},
},
menu: {
start: 'Start',
@@ -380,7 +397,7 @@ export default {
username: 'Username',
usernameHint: 'Username for system login',
password: 'Password',
passwordHint: 'Password for system login',
passwordHint: 'Please enter your login password',
confirmPassword: 'Confirm Password',
confirmPasswordHint: 'Please enter the password again to confirm',
role: 'Role',
@@ -458,6 +475,18 @@ export default {
channelHint: 'Channel to send messages, default is "all"',
channelRequired: 'Channel Name cannot be empty',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token (enable Message Content Intent in Dev Portal)',
botTokenRequired: 'Bot Token is required',
guildId: 'Guild ID',
guildIdHint: 'Optional, restrict to a specific guild; leave blank to use any joined guild',
guildIdPlaceholder: '123456789012345678',
channelId: 'Channel ID',
channelIdHint: 'Optional, default broadcast channel; leave blank to auto-pick a writable channel',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: 'Webhook URL',
@@ -1256,6 +1285,8 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
aiAgentGlobal: 'Global AI Assistant',
aiAgentGlobalHint: 'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
advancedSettings: 'Advanced Settings',
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
downloaders: 'Downloaders',
@@ -1914,6 +1945,8 @@ export default {
startDownload: 'Start Download',
downloadSuccess: '{site} {title} downloaded successfully!',
downloadFailed: '{site} {title} download failed: {message}!',
showAdvancedOptions: 'Show Advanced Options',
hideAdvancedOptions: 'Hide Advanced Options',
},
subscribeShare: {
shareSubscription: 'Share Subscription',
@@ -2544,15 +2577,47 @@ export default {
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
twoFactorAuthentication: 'Two-Factor Authentication',
setupAuthenticator: 'Setup Authenticator',
authenticatorManagement: 'Authenticator Management',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
clearAuthenticator: 'Clear Authenticator',
enableTwoFactor: 'Enable Two-Factor Authentication',
disableTwoFactor: 'Disable Two-Factor Authentication',
setupMfa: 'Setup Two-Factor Authentication',
enableMfa: 'Enable Two-Factor Authentication',
useAuthenticator: 'Use Authenticator',
usePasskey: 'Use Passkey',
enabled: 'Enabled',
keysCount: '{count} keys',
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey',
registeredPasskeys: 'Registered Passkeys',
createdAt: 'Created At',
noPasskeys: 'You have not registered any passkeys yet',
passkeyNameRequired: 'Please enter a passkey name',
passkeyRegisterSuccess: 'Passkey registered successfully',
passkeyRegisterFailed: 'Registration failed',
passkeyRegisterCancelled: 'Registration cancelled',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed',
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
otpDisableFailed: 'Failed to disable OTP: {message}!',
otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
otpEnableFailed: 'Failed to enable OTP: {message}!',
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorApp: 'Authenticator App',
authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
@@ -2672,6 +2737,11 @@ export default {
hostRequired: 'Host cannot be empty',
usernameRequired: 'Username cannot be empty',
passwordRequired: 'Password cannot be empty',
pathMapping: 'Path Mapping',
pathMappingRequired: 'Path cannot be empty',
pathMappingError: 'Must start with /',
storagePath: 'Storage Path',
downloadPath: 'Download Path',
},
filterRule: {
title: 'Filter Rule',

View File

@@ -66,6 +66,7 @@ export default {
serviceUnavailable: '服务不可用',
status: '状态',
preset: '预设',
newVersionAvailable: '检测到新版本,请刷新页面以获取最新功能',
},
mediaType: {
movie: '电影',
@@ -247,6 +248,22 @@ export default {
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
twoFactorAuth: '双重验证',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证',
verifyWithPasskey: '使用通行密钥验证',
otpPlaceholder: '请输入6位验证码',
passkeyLoginStartFailed: '启动通行密钥认证失败',
passkeyNotSelected: '未选择通行密钥',
passkeyLoginFailed: '通行密钥登录失败',
passkeyAuthCanceled: '通行密钥认证被取消',
passkeyLoginRetry: '通行密钥登录失败,请重试',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: {
selectVerificationMethod: '请选择验证方式',
},
},
menu: {
start: '开始',
@@ -379,7 +396,7 @@ export default {
username: '用户名',
usernameHint: '用于登录系统的用户名',
password: '密码',
passwordHint: '用于登录系统的密码',
passwordHint: '请输入登录密码',
confirmPassword: '确认密码',
confirmPasswordHint: '请再次输入密码以确认',
role: '角色',
@@ -456,6 +473,18 @@ export default {
channelHint: '消息发送频道,默认`全体`',
channelRequired: '频道名称不能为空',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在开发者后台开启 Message Content Intent',
botTokenRequired: 'Bot Token不能为空',
guildId: '服务器 ID',
guildIdHint: '可选,限制使用的服务器;为空则使用已加入的任意服务器',
guildIdPlaceholder: '123456789012345678',
channelId: '频道 ID',
channelIdHint: '可选,默认广播频道;为空则自动选择可发送消息的频道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '机器人传入URL',
@@ -1252,6 +1281,8 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
advancedSettings: '高级设置',
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
downloaders: '下载器',
@@ -1890,6 +1921,8 @@ export default {
startDownload: '开始下载',
downloadSuccess: '{site} {title} 下载成功!',
downloadFailed: '{site} {title} 下载失败:{message}',
showAdvancedOptions: '显示高级选项',
hideAdvancedOptions: '隐藏高级选项',
},
subscribeShare: {
shareSubscription: '分享订阅',
@@ -2513,15 +2546,47 @@ export default {
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
twoFactorAuthentication: '登录双重验证',
setupAuthenticator: '设置身份验证',
authenticatorManagement: '身份验证器管理',
authenticatorEnabled: '您已启用身份验证器双重验证',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
clearAuthenticator: '清除身份验证器',
enableTwoFactor: '开启双重验证',
disableTwoFactor: '关闭双重验证',
setupMfa: '设置双重验证',
enableMfa: '开启双重验证',
useAuthenticator: '使用身份验证器',
usePasskey: '使用通行密钥',
enabled: '已启用',
keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
registeredPasskeys: '已注册的通行密钥',
createdAt: '创建时间',
noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功',
passkeyRegisterFailed: '注册失败',
passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
passkeyDomainWarning: '通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
otpDisableSuccess: '关闭登录双重验证成功!',
otpDisableFailed: '关闭otp失败{message}',
otpCodeRequired: '请填写6位验证码',
otpEnableSuccess: '开启登录双重验证成功!',
otpEnableFailed: '开启otp失败{message}',
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorApp: '身份验证器',
authenticatorAppDescription:
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序扫描二维码。它将为您生成一个6位数的代码供您在下方输入。',
@@ -2640,6 +2705,11 @@ export default {
hostRequired: '地址不能为空',
usernameRequired: '用户名不能为空',
passwordRequired: '密码不能为空',
pathMapping: '路径映射',
pathMappingRequired: '路径不能为空',
pathMappingError: '必须以 / 开头',
storagePath: '存储路径',
downloadPath: '下载路径',
},
filterRule: {
title: '过滤规则',

View File

@@ -66,6 +66,7 @@ export default {
serviceUnavailable: '服務不可用',
status: '狀態',
preset: '預設',
newVersionAvailable: '檢測到新版本,請刷新頁面以獲取最新功能',
},
mediaType: {
movie: '電影',
@@ -248,6 +249,22 @@ export default {
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗',
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
twoFactorAuth: '雙重驗證',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
verifyWithPasskey: '使用通行密鑰驗證',
otpPlaceholder: '請輸入6位驗證碼',
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
passkeyNotSelected: '未選擇通行密鑰',
passkeyLoginFailed: '通行密鑰登錄失敗',
passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: {
selectVerificationMethod: '請選擇驗证方式',
},
},
menu: {
start: '開始',
@@ -380,7 +397,7 @@ export default {
username: '用戶名',
usernameHint: '用於登入系統的用戶名',
password: '密碼',
passwordHint: '用於登入系統的密碼',
passwordHint: '請輸入登入密碼',
confirmPassword: '確認密碼',
confirmPasswordHint: '請再次輸入密碼以確認',
role: '角色',
@@ -447,6 +464,18 @@ export default {
channel: '頻道名稱',
channelHint: '消息發送頻道,默認`全體`',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在開發者後台開啟 Message Content Intent',
botTokenRequired: 'Bot Token不能為空',
guildId: '伺服器 ID',
guildIdHint: '可選,限制使用的伺服器;空白則使用已加入的任意伺服器',
guildIdPlaceholder: '123456789012345678',
channelId: '頻道 ID',
channelIdHint: '可選,預設廣播頻道;空白則自動選擇可發送消息的頻道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '機器人傳入URL',
@@ -1240,6 +1269,8 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
advancedSettings: '高級設置',
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
downloaders: '下載器',
@@ -1876,6 +1907,8 @@ export default {
startDownload: '開始下載',
downloadSuccess: '{site} {title} 下載成功!',
downloadFailed: '{site} {title} 下載失敗:{message}',
showAdvancedOptions: '顯示高級選項',
hideAdvancedOptions: '隱藏高級選項',
},
subscribeShare: {
shareSubscription: '分享訂閱',
@@ -2499,15 +2532,47 @@ export default {
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
twoFactorAuthentication: '登錄雙重驗證',
setupAuthenticator: '設置身份驗證',
authenticatorManagement: '身份驗證器管理',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
clearAuthenticator: '清除身份驗證器',
enableTwoFactor: '開啟雙重驗證',
disableTwoFactor: '關閉雙重驗證',
setupMfa: '設置雙重驗證',
enableMfa: '開啟雙重驗證',
useAuthenticator: '使用身份驗證器',
usePasskey: '使用通行密鑰',
enabled: '已啟用',
keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
registeredPasskeys: '已註冊的通行密鑰',
createdAt: '建立時間',
noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功',
passkeyRegisterFailed: '註冊失敗',
passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗',
passkeyDomainWarning: '通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
otpDisableSuccess: '關閉登錄雙重驗證成功!',
otpDisableFailed: '關閉otp失敗{message}',
otpCodeRequired: '請填寫6位驗證碼',
otpEnableSuccess: '開啟登錄雙重驗證成功!',
otpEnableFailed: '開啟otp失敗{message}',
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorApp: '身份驗證器',
authenticatorAppDescription:
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序掃描二維碼。它將為您生成一個6位數的代碼供您在下方輸入。',
@@ -2626,6 +2691,11 @@ export default {
hostRequired: '地址不能為空',
usernameRequired: '用戶名不能為空',
passwordRequired: '密碼不能為空',
pathMapping: '路徑映射',
pathMappingRequired: '路徑不能為空',
pathMappingError: '必須以 / 開頭',
storagePath: '存儲路徑',
downloadPath: '下載路徑',
},
filterRule: {
title: '過濾規則',

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
@@ -7,7 +6,7 @@ import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify'
@@ -43,6 +42,12 @@ const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
// 双重验证对话框
const mfaDialog = ref(false)
// MFA PassKey loading
const mfaPasskeyLoading = ref(false)
// 用户名称输入框
const usernameInput = ref()
@@ -66,6 +71,79 @@ const locales = Object.values(SUPPORTED_LOCALES)
// 登录按钮 loading
const loading = ref(false)
// PassKey 登录按钮 loading
const passkeyLoading = ref(false)
// 使用PassKey登录
async function loginWithPassKey() {
errorMessage.value = ''
passkeyLoading.value = true
try {
// 1. 开始认证流程
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {})
if (!startResponse.success) {
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
errorMessage.value = t('login.passkeyNotSelected')
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
signature: bufferToBase64Url((credential as any).response.signature),
userHandle: (credential as any).response.userHandle
? bufferToBase64Url((credential as any).response.userHandle)
: null,
},
}
// 4. 完成认证
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
await handleLoginSuccess(finishResponse)
} catch (error: any) {
console.error('PassKey login failed:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
} else if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else {
errorMessage.value = t('login.passkeyLoginRetry')
}
} finally {
passkeyLoading.value = false
}
}
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -74,21 +152,21 @@ async function switchLanguage(locale: SupportedLocale) {
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
if (!userid) {
async function fetchOTP(): Promise<boolean> {
if (!form.value.username) {
isOTP.value = false
return
return false
}
api
.get(`/user/otp/${userid}`)
.then((response: any) => {
isOTP.value = response.success
})
.catch((error: any) => {
console.log(error)
})
}, 500)
try {
const response: any = await api.get(`/mfa/status/${form.value.username}`)
isOTP.value = response.success
return response.success
} catch (error: any) {
console.log(error)
isOTP.value = false
return false
}
}
// 订阅推送通知
async function subscribeForPushNotifications() {
@@ -132,84 +210,181 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
// 登录按钮 loading
loading.value = false
}
// 处理登录成功
async function handleLoginSuccess(response: any) {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.wizard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
if (filteredMenus.length === 0) {
errorMessage.value = t('login.noPermission')
return
}
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
// 登录获取token事件
function login() {
async function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
if (!form.value.username || !form.value.password) {
return
}
// 登录按钮 loading
loading.value = true
// 用户名密码
const formData = new FormData()
try {
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
// 请求token
api
.post('/login/access-token', formData, {
// 请求token
const response: any = await api.post('/login/access-token', formData, {
headers: {
Accept: 'application/json', // 设置 Accept 类型
},
})
.then((response: any) => {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.widzard,
}
// 在保存用户信息之前检查权限
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
// 如果用户没有任何可用菜单,拒绝登录
if (filteredMenus.length === 0) {
// 显示错误信息
errorMessage.value = t('login.noPermission')
loading.value = false
await handleLoginSuccess(response)
} catch (error: any) {
// 登录失败,显示错误提示
if (!error.response) {
errorMessage.value = t('login.networkError')
} else if (error.response.status === 401) {
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true'
if (mfaRequired && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
mfaDialog.value = true
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
} else if (error.response.status === 403) {
errorMessage.value = t('login.permissionDenied')
} else if (error.response.status === 500) {
errorMessage.value = t('login.serverError')
} else {
errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
}
} finally {
loading.value = false
}
}
// 权限检查通过,保存用户信息
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
// 使用OTP码继续登录
function loginWithOTP() {
mfaDialog.value = false
login()
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 使用PassKey进行MFA验证
async function verifyWithPassKey() {
if (!form.value.username) return
// 登录后处理
afterLogin(userPayload.superUser, userPayload, filteredMenus)
mfaPasskeyLoading.value = true
errorMessage.value = ''
try {
// 1. 开始认证流程(指定用户名)
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {
username: form.value.username,
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = t('login.networkError')
else if (error.response.status === 401) errorMessage.value = t('login.authFailure')
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
// 登录按钮 loading
loading.value = false
if (!startResponse.success) {
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
errorMessage.value = t('login.passkeyNotSelected')
return
}
// 3. 转换credential
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
signature: bufferToBase64Url((credential as any).response.signature),
userHandle: (credential as any).response.userHandle
? bufferToBase64Url((credential as any).response.userHandle)
: null,
},
}
// 4. 完成认证(直接登录,不需要密码)
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
// 关闭MFA对话框
mfaDialog.value = false
await handleLoginSuccess(finishResponse)
} catch (error: any) {
console.error('PassKey MFA verification failed:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed')
} else if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else {
errorMessage.value = t('login.passkeyVerifyFailedRetry')
}
} finally {
mfaPasskeyLoading.value = false
}
}
// 自动登录
@@ -274,7 +449,7 @@ onMounted(async () => {
</template>
</VCardItem>
<VCardText>
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
<VForm ref="refForm" autocomplete="on" @submit.prevent="login">
<VRow>
<!-- username -->
<VCol cols="12">
@@ -286,7 +461,7 @@ onMounted(async () => {
name="username"
autocomplete="username"
:rules="[requiredValidator]"
@input="fetchOTP"
hide-details
/>
</VCol>
<!-- password -->
@@ -299,11 +474,11 @@ onMounted(async () => {
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
hide-details
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" :label="t('login.otpCode')" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -311,9 +486,21 @@ onMounted(async () => {
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login" :loading="loading">
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading">
{{ t('login.login') }}
</VBtn>
<!-- passkey login button -->
<VBtn
block
variant="tonal"
color="success"
class="mt-3"
prepend-icon="mdi-key-variant"
:loading="passkeyLoading"
@click="loginWithPassKey"
>
{{ t('login.loginWithPasskey') }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -323,6 +510,56 @@ onMounted(async () => {
</VCardText>
</VCard>
</div>
<!-- MFA双重验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle>
<VCardText>
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<!-- TOTP验证 -->
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="loginWithOTP">
<VTextField
v-model="form.otp_password"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- PassKey验证 -->
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
prepend-icon="mdi-key-variant"
:loading="mfaPasskeyLoading"
@click="verifyWithPassKey"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,8 +1,58 @@
import api from '@/api'
import { useToast } from 'vue-toastification'
import i18n from '@/plugins/i18n'
// 创建一个专用的AbortController用于globalSetting请求
const globalSettingController = new AbortController()
// 声明全局变量类型
declare const __APP_VERSION__: string
// 当前前端版本号(由 Vite 在编译时注入)
const CURRENT_FRONTEND_VERSION = __APP_VERSION__
console.log(`[VersionChecker] 当前前端版本: ${CURRENT_FRONTEND_VERSION}`)
// 标记是否已经显示过版本更新通知
let versionNotificationShown = false
/**
* 检查版本并在需要时显示更新通知
*/
async function checkVersionAndNotify(serverVersion: string): Promise<void> {
// 版本不同,且尚未显示通知
if (serverVersion !== CURRENT_FRONTEND_VERSION && !versionNotificationShown) {
versionNotificationShown = true
console.log(`[VersionChecker] 检测到版本更新: ${CURRENT_FRONTEND_VERSION} -> ${serverVersion}`)
try {
// 1. 清除所有缓存
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
console.log('[VersionChecker] 已清除所有缓存')
}
// 2. 注销Service Worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(registration => registration.unregister()))
console.log('[VersionChecker] 已注销所有 Service Worker')
}
} catch (error) {
console.error('[VersionChecker] 清除缓存失败:', error)
}
// 3. 显示持久化通知,提示用户刷新
const toast = useToast()
toast.info(i18n.global.t('common.newVersionAvailable'), {
timeout: false, // 不自动消失
closeButton: false,
closeOnClick: false,
draggable: false,
})
}
}
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global', {
@@ -12,7 +62,15 @@ export async function fetchGlobalSettings() {
// 手动设置signal防止reqestOptimizer添加可中断的controller
signal: globalSettingController.signal,
})
return result.data || {}
const data = result.data || {}
// 检查版本更新
if (data.FRONTEND_VERSION) {
await checkVersionAndNotify(data.FRONTEND_VERSION)
}
return data
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error

View File

@@ -13,6 +13,7 @@ import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
import wechatLogo from '@/assets/images/logos/wechat.png'
import telegramLogo from '@/assets/images/logos/telegram.webp'
import slackLogo from '@/assets/images/logos/slack.webp'
import discordLogo from '@/assets/images/logos/discord.png'
import synologychatLogo from '@/assets/images/logos/synologychat.png'
import vocechatLogo from '@/assets/images/logos/vocechat.png'
import downloaderLogo from '@/assets/images/logos/downloader.png'
@@ -40,6 +41,7 @@ const logoMap: Record<string, string> = {
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,
discord: discordLogo,
synologychat: synologychatLogo,
vocechat: vocechatLogo,
downloader: downloaderLogo,

View File

@@ -32,40 +32,13 @@ const endpoints = {
// 所有存储
const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
storages.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
const storageTypes = computed(() => storages.value.map(s => s.type))
// 当前文件项
const operItem = ref<FileItem>({
storage: 'local',
type: 'dir',
name: '/',
path: '/',
fileid: 'root',
})
const operItem = ref<FileItem | undefined>(undefined)
// fileid的堆栈
const itemstack = ref<FileItem[]>([
{
storage: 'local',
type: 'dir',
name: '/',
path: '/',
fileid: 'root',
},
])
// 下载目录列表
const downloadDirectories = ref<TransferDirectoryConf[]>([])
const itemstack = ref<FileItem[]>([])
// 计算公共路径
function findCommonPath(paths: string[]): string {
@@ -101,29 +74,97 @@ function findCommonPath(paths: string[]): string {
return commonPath
}
const STORAGE_KEY = 'fileBrowserView.activeStorage'
interface BrowserInitialParams {
storage: string;
path: string;
name: string;
}
// determine which entry to select initially
function determineBrowserInitialParams(downloadDirectories: TransferDirectoryConf[]): BrowserInitialParams {
const isAvailable = (storage: string) => storageTypes.value.includes(storage);
const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => {
// filter out directories whose storage is not available
if (!isAvailable(item.storage)) {
return dict
}
if (item.download_path == undefined) {
return dict
}
if (!dict.has(item.storage)) {
dict.set(item.storage, [item.download_path])
} else {
dict.get(item.storage)!.push(item.download_path)
}
return dict
}, new Map());
const cachedStorage = localStorage.getItem(STORAGE_KEY) || '';
// if no download directories are configured, fall back to cached storage or first available storage
if (buckets.size === 0) {
return {
storage: isAvailable(cachedStorage)
? cachedStorage
: (storageTypes.value[0] || 'local'),
path: '/',
name: '/',
}
}
let selectedEntry: [string, string[]];
if (cachedStorage && buckets.has(cachedStorage)) {
selectedEntry = [cachedStorage, buckets.get(cachedStorage)!];
} else {
// if no storage selected previously, use the most populous one
selectedEntry = Array.from(buckets.entries()).reduce((prev, curr) => {
return curr[1].length > prev[1].length ? curr : prev;
});
}
const path = findCommonPath(selectedEntry[1]);
return {
storage: selectedEntry[0],
path,
name: path.split('/').filter(Boolean).pop() ?? '',
}
}
// 查询下载目录
async function loadDownloadDirectories() {
try {
// fetch available storages
const storageResult: { [key: string]: any } = await api.get('system/setting/Storages')
storages.value = storageResult.data?.value ?? []
const result: { [key: string]: any } = await api.get('system/setting/Directories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
const path = findCommonPath(downloadDirectories.value.map(item => item.download_path) as string[])
const name = path.split('/').filter(Boolean).pop() ?? ''
const { storage, path, name } = determineBrowserInitialParams(result.data.value);
// operItem初始化
operItem.value = {
storage: 'local',
type: 'dir',
storage,
name: name,
path: path,
}
// itemstack初始化
itemstack.value = [
{
storage: storage,
type: 'dir',
name: '/',
path: '/',
fileid: 'root',
}
];
// 将初始数据拆分到堆栈中
const paths = path.split('/').filter(Boolean)
paths.map((name, index) => {
const path = '/' + paths.slice(0, index + 1).join('/') + '/'
itemstack.value.push({
storage: 'local',
storage,
type: 'dir',
name: name,
path: path,
name,
path,
})
})
}
@@ -134,6 +175,11 @@ async function loadDownloadDirectories() {
// 目录变化
function pathChanged(item: FileItem) {
// save storage to localStorage
if (item.storage !== operItem.value?.storage) {
localStorage.setItem(STORAGE_KEY, item.storage)
}
operItem.value = item
if (item.path == '/') {
itemstack.value = [
@@ -156,16 +202,13 @@ function pathChanged(item: FileItem) {
}
// 加载初始目录
onBeforeMount(loadDownloadDirectories)
onMounted(() => {
loadStorages()
})
onMounted(loadDownloadDirectories)
</script>
<template>
<div class="file-browser-view">
<FileBrowser
v-if="operItem"
:storages="storages"
:tree="false"
:itemstack="itemstack"

View File

@@ -294,6 +294,9 @@ onMounted(() => {
<VListItem @click="addNotification('slack')">
<VListItemTitle>{{ t('setting.notification.slack') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('discord')">
<VListItemTitle>Discord</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('synologychat')">
<VListItemTitle>{{ t('setting.notification.synologyChat') }}</VListItemTitle>
</VListItem>

View File

@@ -32,6 +32,7 @@ const SystemSettings = ref<any>({
OCR_HOST: null,
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
LLM_PROVIDER: 'deepseek',
LLM_MODEL: 'deepseek-chat',
LLM_API_KEY: null,
@@ -707,6 +708,14 @@ onDeactivated(() => {
</template>
</VCombobox>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
:hint="t('setting.system.aiAgentGlobalHint')"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
import { useToast } from 'vue-toastification'
import QrcodeVue from 'qrcode.vue'
import { VForm } from 'vuetify/lib/components/index.mjs'
@@ -10,7 +11,7 @@ import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const { t, locale } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -67,6 +68,54 @@ const accountInfo = ref<User>({
// 二维码信息
const qrCode = ref('')
// PassKey类型
interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// PassKey对话框
const passkeyDialog = ref(false)
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
// 双重验证菜单
const mfaMenu = ref(false)
// 密码验证对话框
const verifyPasswordDialog = ref(false)
// 验证密码
const verifyPassword = ref('')
// 验证后的回调
const verifyCallback = ref<(() => void) | null>(null)
// 验证对话框标题
const verifyTitle = ref('')
// 验证对话框提示
const verifyText = ref('')
// 检查是否已启用任何双重验证
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
@@ -116,13 +165,15 @@ async function fetchUserInfo() {
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
await fetchPassKeyList()
}
} catch (error) {
console.log(error)
}
}
// 保存户信息
// 保存户信息
async function saveAccountInfo() {
if (isSaving.value) {
$toast.error(t('profile.savingInProgress'))
@@ -197,8 +248,16 @@ async function saveAccountInfo() {
// 为当前用户获取Otp Uri
async function getOtpUri() {
// 如果已经启用OTP只打开对话框不生成新的二维码
if (accountInfo.value.is_otp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
otpDialog.value = true
return
}
// 未启用OTP生成新的二维码
try {
const result: { [key: string]: any } = await api.post('user/otp/generate')
const result: { [key: string]: any } = await api.post('mfa/otp/generate')
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
@@ -212,19 +271,49 @@ async function getOtpUri() {
}
}
// 密码验证并执行回调
function withPasswordVerification(title: string, text: string, callback: () => void) {
verifyTitle.value = title
verifyText.value = text
verifyCallback.value = callback
verifyPassword.value = ''
verifyPasswordDialog.value = true
}
// 确认密码验证
async function confirmVerifyPassword() {
if (!verifyPassword.value) {
$toast.error(t('user.passwordHint'))
return
}
if (verifyCallback.value) {
verifyCallback.value()
}
verifyPasswordDialog.value = false
}
// 关闭当前用户的双重验证
async function disableOtp() {
try {
const result: { [key: string]: any } = await api.post('user/otp/disable')
if (result.success) {
accountInfo.value.is_otp = false
$toast.success(t('profile.otpDisableSuccess'))
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
if (passkeyList.value.length > 0) {
$toast.error(t('profile.otpDisableRestrictedByPasskey'))
return
}
withPasswordVerification(t('profile.disableTwoFactor'), t('profile.confirmToDisableOtp'), async () => {
try {
const result: { [key: string]: any } = await api.post('mfa/otp/disable', {
password: verifyPassword.value,
})
if (result.success) {
accountInfo.value.is_otp = false
$toast.success(t('profile.otpDisableSuccess'))
otpDialog.value = false
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
}
})
}
// 启用Otp
@@ -234,7 +323,7 @@ async function judgeOtpPassword() {
return
}
try {
const result: { [key: string]: any } = await api.post('user/otp/judge', {
const result: { [key: string]: any } = await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})
@@ -251,6 +340,128 @@ async function judgeOtpPassword() {
}
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
if (result.success) {
passkeyList.value = result.data || []
}
} catch (error) {
console.log(error)
}
}
// 打开PassKey注册对话框
async function openPassKeyDialog() {
passkeyName.value = ''
passkeyDialog.value = true
await fetchPassKeyList()
}
// 注册PassKey
async function registerPassKey() {
if (!passkeyName.value) {
$toast.error(t('profile.passkeyNameRequired'))
return
}
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult: { [key: string]: any } = await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
passkeyRegistering.value = false
return
}
const { options, challenge } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge
// 2. 调用WebAuthn API
const credential = await navigator.credentials.create({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
user: {
...publicKeyOptions.user,
id: base64UrlToUint8Array(publicKeyOptions.user.id),
},
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64Url((credential as any).response.attestationObject),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
transports: (credential as any).response.getTransports ? (credential as any).response.getTransports() : [],
},
}
// 4. 完成注册
const finishResult: { [key: string]: any } = await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
name: passkeyName.value,
})
if (finishResult.success) {
$toast.success(t('profile.passkeyRegisterSuccess'))
passkeyName.value = ''
await fetchPassKeyList()
} else {
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
}
} catch (error: any) {
console.error('PassKey注册失败:', error)
if (error.name === 'NotAllowedError') {
$toast.error(t('profile.passkeyRegisterCancelled'))
} else {
$toast.error(t('profile.passkeyRegisterFailed'))
}
}
passkeyRegistering.value = false
}
// 删除PassKey
async function deletePassKey(passkeyId: number) {
withPasswordVerification(t('common.delete') + t('profile.usePasskey'), t('profile.confirmToDeletePasskey'), async () => {
try {
const result: { [key: string]: any } = await api.post('mfa/passkey/delete', {
passkey_id: passkeyId,
password: verifyPassword.value,
})
if (result.success) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
} catch (error) {
console.log(error)
$toast.error(t('profile.passkeyDeleteFailed'))
}
})
}
// 加载当前用户数据
onMounted(() => {
fetchUserInfo()
@@ -301,16 +512,42 @@ watch(
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn>
<VBtn
:color="accountInfo.is_otp ? 'warning' : 'success'"
variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>
<VIcon icon="mdi-account-key" />
<span v-if="display.mdAndUp.value" class="ms-2">{{
accountInfo.is_otp ? t('profile.disableTwoFactor') : t('profile.enableTwoFactor')
}}</span>
</VBtn>
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn
:color="hasMfaEnabled ? 'warning' : 'success'"
variant="tonal"
v-bind="props"
>
<VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
</span>
<VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn>
</template>
<VList>
<VListItem @click="getOtpUri(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success">
{{ t('profile.enabled') }}
</VListItemSubtitle>
</VListItem>
<VListItem @click="openPassKeyDialog(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-key-variant" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</div>
<p class="text-body-1 mb-0">{{ t('profile.avatarFormatTip') }}</p>
@@ -455,37 +692,201 @@ watch(
<!-- 双重验证弹窗 -->
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
<!-- 开启双重验证弹窗内容 -->
<VCard>
<VDialogCloseBtn @click="otpDialog = false" />
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.twoFactorAuthentication') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm>
<VTextField
v-model="otpPassword"
type="text"
:label="t('profile.enterVerificationCode')"
autocomplete=""
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<!-- 如果已启用OTP显示清除界面 -->
<template v-if="accountInfo.is_otp && !qrCode">
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.authenticatorManagement') }}</h4>
<VAlert type="success" variant="tonal" class="mb-4">
{{ t('profile.authenticatorEnabled') }}
</VAlert>
<p class="mb-6">
{{ t('profile.clearAuthenticatorTip') }}
</p>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false"> {{ t('common.cancel') }} </VBtn>
<VBtn @click="judgeOtpPassword">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="error" @click="disableOtp(); otpDialog = false">
<template #prepend>
<VIcon icon="mdi-check" />
<VIcon icon="mdi-delete" />
</template>
{{ t('profile.clearAuthenticator') }}
</VBtn>
</div>
</template>
<!-- 设置新的OTP -->
<template v-else>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.setupAuthenticator') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
<VTextField
v-model="otpPassword"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:label="t('profile.enterVerificationCode')"
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</template>
</VCardText>
<VDialogCloseBtn @click="otpDialog = false" />
</VCard>
</VDialog>
<!-- PassKey管理对话框 -->
<VDialog v-if="passkeyDialog" v-model="passkeyDialog" max-width="45rem" scrollable>
<VCard>
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.passkeyManagement') }}</h4>
<!-- 安全警告 -->
<VAlert
type="warning"
variant="tonal"
class="mb-6"
icon="mdi-alert"
>
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
<template #domain>
<b>{{ t('profile.accessDomain') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 注册新通行密钥 -->
<VCard v-if="accountInfo.is_otp" variant="tonal" class="mb-6">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
<VForm @submit.prevent="registerPassKey">
<VTextField
v-model="passkeyName"
:label="t('profile.passkeyName')"
:placeholder="t('profile.passkeyNamePlaceholder')"
class="mb-4"
variant="outlined"
prepend-inner-icon="mdi-form-textbox"
/>
<VBtn
color="primary"
type="submit"
:loading="passkeyRegistering"
prepend-icon="mdi-plus"
>
{{ t('profile.registerPasskey') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- 未启用 OTP 提示 -->
<VAlert
v-else
type="error"
variant="tonal"
class="mb-6"
icon="mdi-shield-lock"
>
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
<template #otp>
<b>{{ t('profile.otpAuthenticator') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 已注册的通行密钥列表 -->
<VCard variant="tonal">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registeredPasskeys') }}</h5>
<VList v-if="passkeyList.length > 0" class="mt-4">
<VListItem
v-for="passkey in passkeyList"
:key="passkey.id"
class="mb-2 py-4"
rounded="lg"
border
>
<template #prepend>
<VIcon icon="mdi-key-variant" size="32" class="me-4" />
</template>
<VListItemTitle class="font-weight-medium">
{{ passkey.name }}
</VListItemTitle>
<VListItemSubtitle>
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString(locale) }}
</VListItemSubtitle>
<template #append>
<VBtn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="deletePassKey(passkey.id)"
/>
</template>
</VListItem>
</VList>
<VAlert v-else type="info" variant="tonal" class="mt-4">
{{ t('profile.noPasskeys') }}
</VAlert>
</VCardText>
</VCard>
<div class="d-flex justify-end mt-6">
<VBtn variant="outlined" @click="passkeyDialog = false">{{ t('common.close') }}</VBtn>
</div>
</VCardText>
<VDialogCloseBtn @click="passkeyDialog = false" />
</VCard>
</VDialog>
<!-- 密码验证对话框 -->
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ verifyTitle }}</VCardTitle>
<VCardText>
<p class="mb-4">{{ verifyText }}</p>
<VForm @submit.prevent="confirmVerifyPassword">
<VTextField
v-model="verifyPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:label="t('user.password')"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
variant="outlined"
prepend-inner-icon="mdi-lock"
autocomplete="current-password"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
<div class="d-flex justify-end gap-4 mt-4">
<VBtn variant="outlined" color="secondary" @click="verifyPasswordDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit" color="primary">
{{ t('common.confirm') }}
</VBtn>
</div>

View File

@@ -10,6 +10,10 @@ import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'node:path'
import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await'
import { readFileSync } from 'node:fs'
// 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
// https://vitejs.dev/config/
export default defineConfig({
@@ -277,7 +281,10 @@ export default defineConfig({
promiseImportName: i => `__mp_tla_${i}`,
}),
],
define: { 'process.env': {} },
define: {
'process.env': {},
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`)
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),