Compare commits

...

56 Commits

Author SHA1 Message Date
jxxghp
5382108ee7 Merge pull request #439 from DemoJameson/fix-nickname-2 2026-01-29 11:58:39 +08:00
DemoJameson
514063d3fb fix: 删除昵称后保存无效 2026-01-29 11:31:51 +08:00
jxxghp
b08f396fec Merge pull request #437 from DemoJameson/fix-nickname 2026-01-26 18:42:10 +08:00
DemoJameson
d37a7f06f1 fix: 个人信息页面不显示昵称 2026-01-26 13:34:22 +08:00
jxxghp
ad7bca3aae feat: 更新了分类配置的API端点,并为对话框添加了小屏幕全屏显示功能。 2026-01-26 12:52:46 +08:00
jxxghp
4fb70ba80e 更新 package.json 2026-01-25 15:57:11 +08:00
jxxghp
1225b2eb9e Merge pull request #435 from z-henry/v2
修复:按照指定剧集组订阅,日历读取不到对应的日期
2026-01-25 15:50:01 +08:00
jxxghp
24b2f103b9 Merge branch 'v2' into v2 2026-01-25 15:49:15 +08:00
jxxghp
0d304b58ca feat:二级分类设置界面 2026-01-25 09:40:50 +08:00
HenryZZZZZ
f419dbd794 Merge branch 'jxxghp:v2' into v2 2026-01-24 16:04:45 +08:00
jxxghp
7854cc81a8 fix message ui 2026-01-24 11:52:49 +08:00
jxxghp
9ad1bd29bd fix markdown ui 2026-01-23 22:46:25 +08:00
jxxghp
b88d4f0ecb feat:LLM上下文窗口设置 2026-01-23 22:35:03 +08:00
HenryZZZZZ
44168b62d2 Merge pull request #1 from z-henry/codex/update-api-call-for-episode-group
Add episode_group query param for TMDB season episode requests
2026-01-23 11:09:50 +08:00
HenryZZZZZ
1dab013436 Add episode_group param to TMDB episode requests 2026-01-23 11:08:55 +08:00
jxxghp
64a4a7aff5 feat: Add file transfer threads setting with UI and localization. 2026-01-21 20:25:54 +08:00
jxxghp
e43b545c89 更新 package.json 2026-01-20 21:21:42 +08:00
jxxghp
69fcde250e Merge pull request #434 from PKC278/v2 2026-01-20 21:21:24 +08:00
PKC278
63d6290166 fix(otp): 修正 OTP 关闭逻辑 2026-01-20 19:54:27 +08:00
PKC278
c1d759f3f3 fix(passkey): 加强无OTP注册PassKey的逻辑判断 2026-01-20 19:37:58 +08:00
PKC278
3a782bc69c fix(locales): 以zh-CN为基准,补充其他语言缺失字段 2026-01-20 18:33:08 +08:00
PKC278
bea752879c feat(passkey): 添加环境变量配置项,允许注册passkey时跳过验证是否已注册otp 2026-01-20 00:34:33 +08:00
jxxghp
a48fcb3819 Merge pull request #433 from PKC278/v2 2026-01-17 19:03:18 +08:00
PKC278
68a07bc952 fix(layout): 移除筛选栏sticky定位 2026-01-17 18:58:15 +08:00
jxxghp
828dba09b0 Merge pull request #432 from PKC278/v2 2026-01-17 07:49:46 +08:00
PKC278
0d2189e9e8 fix(layout): 修复Chrome 144+无法滚动问题 2026-01-17 02:13:29 +08:00
jxxghp
f0f0ab81e4 Merge pull request #431 from PKC278/v2 2026-01-16 23:28:52 +08:00
PKC278
64b5fa7038 fix(layout): 修复Chrome 144+滚动锁定问题,调整overflow属性 2026-01-16 22:45:43 +08:00
jxxghp
1d04c9b9c9 Merge pull request #430 from cddjr/fix_5364 2026-01-15 21:44:41 +08:00
景大侠
dee719ac25 修复 媒体整理按标题、大小无法正确排序的问题 2026-01-15 21:31:07 +08:00
jxxghp
ea676876f1 Merge pull request #429 from PKC278/v2 2026-01-15 16:47:26 +08:00
PKC278
c1a4d5d81e fix(resource): 修正Safari渲染抖动问题 2026-01-15 15:47:43 +08:00
jxxghp
95d88804e4 Merge pull request #428 from PKC278/v2 2026-01-15 11:04:10 +08:00
PKC278
1fa072790f fix: 修正部分样式 2026-01-15 10:57:26 +08:00
jxxghp
fe19c1183c Merge pull request #427 from PKC278/v2 2026-01-15 07:07:10 +08:00
PKC278
be40f55bd9 feat(search): 添加AI推荐功能并优化相关逻辑 2026-01-15 03:04:58 +08:00
PKC278
30a10eaf6d fix(passkey): 修复PassKey注册时的错误提示逻辑 2026-01-14 10:25:55 +08:00
jxxghp
3bc0c86df4 Merge pull request #426 from PKC278/v2 2026-01-12 11:28:41 +08:00
jxxghp
03c8726e6e Merge pull request #425 from HankunYu/v2 2026-01-12 11:28:13 +08:00
PKC278
de47491ded fix(login): 修复PassKey认证错误信息提示逻辑 2026-01-12 10:14:08 +08:00
HankunYu
c691cdaa0e Merge branch 'jxxghp:v2' into v2 2026-01-12 00:52:28 +00:00
HankunYu
53efdc2802 用户设置页面新增discord id设置 2026-01-12 00:48:24 +00:00
jxxghp
9644076463 更新 package.json 2026-01-12 06:59:30 +08:00
jxxghp
cb4e88f8aa Merge pull request #424 from PKC278/v2 2026-01-12 06:59:04 +08:00
PKC278
adc16fc58d fix(locales): 改进通行密钥描述 2026-01-12 00:58:13 +08:00
jxxghp
d6860a3e24 Merge pull request #423 from PKC278/v2 2026-01-11 20:29:31 +08:00
PKC278
7e6116de45 feat: 优化通行密钥错误提示与代码结构
- feat(login): 优化通行密钥(Passkey)登录逻辑,支持 Conditional UI 自动填充,并改进错误提示。
- feat(userProfile): 优化双重验证弹窗样式。
- feat(qrcode): 优化二维码生成逻辑与显示。
- feat(passkey): 优化通行密钥错误提示,添加最后使用时间显示。
2026-01-11 20:02:34 +08:00
jxxghp
1688a2ca25 feat:消息中心Markdown渲染 2026-01-10 10:16:13 +08:00
jxxghp
fe57acfce0 Merge pull request #422 from PKC278/v2 2026-01-09 16:15:12 +08:00
PKC278
1ae49b28b1 fix(login): 移除密码输入框 autocomplete 中冗余的 webauthn 选项 2026-01-09 12:31:37 +08:00
jxxghp
ef4e9c8b40 更新 package.json 2026-01-09 07:51:44 +08:00
jxxghp
5da0758e89 Merge pull request #421 from PKC278/v2 2026-01-09 07:51:26 +08:00
PKC278
816cab252d feat(login): 添加手动点击Passkey按钮的 AbortController 以防止重复点击 2026-01-08 23:42:52 +08:00
PKC278
843f638835 feat(auth): 添加 Passkey 条件 UI(conditional ui) 支持 2026-01-08 23:05:12 +08:00
PKC278
e4684b2e12 fix(login): 修改浅色主题下PassKey按钮样式,提高文字对比度 2026-01-08 18:51:53 +08:00
PKC278
c17365b6c9 feat(login): 优化表单自动填充 2026-01-08 16:24:09 +08:00
37 changed files with 4447 additions and 2649 deletions

View File

@@ -93,8 +93,7 @@
<style>
#app {
block-size: 100%;
overflow: auto;
min-block-size: 100%;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}

View File

@@ -1,11 +1,12 @@
{
"name": "moviepilot",
"version": "2.9.2",
"version": "2.9.7",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
@@ -51,11 +52,13 @@
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
@@ -69,6 +72,9 @@
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51",
"@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0",
@@ -77,9 +83,12 @@
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",

View File

@@ -92,6 +92,9 @@ const sources: BundleScriptConfig = {
// 'mdi:logout',
// 'octicon:book-24',
// 'octicon:code-square-24',
'lucide:sparkles',
'material-symbols:passkey',
'line-md:loading-twotone-loop',
],
json: [
@@ -154,7 +157,13 @@ const target = join(__dirname, 'icons-bundle.js');
// Sort icons by prefix
const organizedList = organizeIconsList(sources.icons)
for (const prefix in organizedList) {
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
let filename
try {
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
}
catch (err) {
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
}
sourcesJSON.push({
filename,

View File

@@ -142,7 +142,7 @@ export default defineComponent({
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
min-block-size: 100%;
.layout-content-wrapper {
display: flex;
@@ -224,7 +224,9 @@ export default defineComponent({
.layout-page-content {
// display: flex;
overflow: hidden;
// 使用 clip 替代 hidden避免 Chrome 144+ 滚动锁定问题
overflow-x: clip;
overflow-y: auto;
.page-content-container {
inline-size: 100%;

View File

@@ -13,6 +13,8 @@ html {
body {
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
overflow: visible !important;
--webkit-overflow-scrolling: touch;
}
@@ -35,7 +37,9 @@ body,
.layout-page-content {
@include mixins.boxed-content(true);
overflow: hidden;
// Chrome 144+ 兼容性:使用 clip 替代 hidden避免滚动锁定问题
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
overflow: clip;
flex-grow: 1;
// TODO: Use grid gutter variable here;

View File

@@ -192,7 +192,11 @@ async function removeLoadingWithStateCheck() {
// 并行加载关键资源
await Promise.all([
globalSettingsStore.initialize().then(() => {
globalSettingsStore.initialize().then(async () => {
// 如果已登录,加载用户相关设置
if (isLogin.value) {
await globalSettingsStore.loadUserSettings()
}
globalLoadingStateManager.setLoadingState('global-settings', false)
}),
new Promise(resolve => {

View File

@@ -272,6 +272,8 @@ export interface MediaInfo {
vote_average?: number
// 描述
overview?: string
// 自定义剧集组
episode_group?: string
// 二级分类
category?: string
// 详情页面
@@ -861,6 +863,16 @@ export interface User {
nickname?: string
}
// 通行密钥
export interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// 存储空间
export interface Storage {
// 总空间
@@ -1429,3 +1441,25 @@ export interface SubscribeShareStatistics {
// 总复用人次
total_reuse_count?: number
}
// 通用API响应
export interface ApiResponse<T = any> {
success: boolean
message?: string
data: T
}
// 分类规则
export interface CategoryRule {
genre_ids?: string
original_language?: string
production_countries?: string
origin_country?: string
release_year?: string
}
// 分类配置
export interface CategoryConfig {
movie?: { [key: string]: CategoryRule }
tv?: { [key: string]: CategoryRule }
}

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
@@ -19,6 +21,21 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -42,10 +59,10 @@ function noteToJson() {
return {}
}
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
// 渲染 Markdown
function renderMarkdown(value: string) {
if (!value) return ''
return value.replace(/\n/g, '<br/>')
return md.render(value)
}
</script>
@@ -85,19 +102,23 @@ function replaceNewLine(value: string) {
</VCardTitle>
<div
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
>
<p class="mb-0">{{ props.message?.text }}</p>
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
</div>
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
<VCardText
v-if="props.message?.text && props.message?.action === 1"
class="markdown-body"
v-html="renderMarkdown(props.message?.text)"
/>
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<VList>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title_year }}
{{ Number(key) + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型:{{ value.type }} 评分:{{ value.vote_average }}
@@ -116,3 +137,89 @@ function replaceNewLine(value: string) {
}}</span>
</div>
</template>
<style lang="scss">
.markdown-body {
word-break: break-all;
p {
margin-block-end: 0.5rem;
}
p:last-child {
margin-block-end: 0;
}
a {
color: inherit;
text-decoration: underline;
}
ul {
list-style-type: disc;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
ol {
list-style-type: decimal;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
li {
display: list-item;
margin-block-end: 0.25rem;
}
code {
border-radius: 4px;
background-color: rgba(var(--v-border-color), 0.1);
font-family: monospace;
padding-block: 0.2rem;
padding-inline: 0.4rem;
}
pre {
overflow: auto;
padding: 1rem;
border-radius: 8px;
background-color: rgba(var(--v-border-color), 0.1);
margin-block-end: 0.5rem;
code {
padding: 0;
background-color: transparent;
}
}
blockquote {
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
font-style: italic;
margin-block-end: 0.5rem;
padding-inline-start: 1rem;
}
table {
border-collapse: collapse;
inline-size: 100%;
margin-block-end: 1rem;
th,
td {
padding: 0.5rem;
border: 1px solid rgba(var(--v-border-color), 0.1);
text-align: start;
}
th {
background-color: rgba(var(--v-border-color), 0.05);
}
}
img {
block-size: auto;
max-inline-size: 100%;
}
}
</style>

View File

@@ -136,8 +136,8 @@ onMounted(() => {
<!-- 媒体标题 -->
<VCardItem class="pt-3 pb-0">
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
<span class="text-h6 font-weight-bold text-truncate me-2">
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
<span class="text-h6 font-weight-bold me-2">
{{ media?.title ?? meta?.name }}
</span>
<VChip
@@ -183,14 +183,14 @@ onMounted(() => {
<!-- 种子内容 -->
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
<!-- 种子标题 -->
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
{{ torrent?.title }}
</div>
<!-- 种子描述 -->
<div
v-if="meta?.subtitle || torrent?.description"
class="text-body-2 text-medium-emphasis mb-2"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="meta?.subtitle || torrent?.description"
>
{{ meta?.subtitle || torrent?.description }}

View File

@@ -140,7 +140,7 @@ onMounted(() => {
</div>
</template>
<VListItemTitle>
<VListItemTitle class="whitespace-normal">
<div class="d-flex flex-row flex-wrap align-center mb-2">
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
<VChip
@@ -153,12 +153,12 @@ onMounted(() => {
</VChip>
</div>
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div
class="text-body-2 text-medium-emphasis mb-2"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="meta?.subtitle || torrent?.description || '暂无描述'"
>
{{ meta?.subtitle || torrent?.description || '暂无描述' }}

View File

@@ -223,7 +223,6 @@ onMounted(() => {
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
:label="t('dialog.addDownload.downloader')"
variant="underlined"
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
@@ -236,7 +235,6 @@ onMounted(() => {
v-model="selectedDirectory"
:items="targetDirectories"
:label="t('dialog.addDownload.saveDirectory')"
size="small"
:placeholder="t('dialog.addDownload.autoPlaceholder')"
variant="underlined"
density="comfortable"
@@ -248,7 +246,6 @@ onMounted(() => {
<VCol cols="12">
<VBtn
variant="text"
size="small"
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showAdvancedOptions = !showAdvancedOptions"
>
@@ -272,7 +269,6 @@ onMounted(() => {
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
@@ -287,7 +283,6 @@ onMounted(() => {
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"

View File

@@ -0,0 +1,663 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import api from '@/api'
import type { CategoryConfig } from '@/api/types'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义输入参数
defineProps<{
modelValue?: boolean
}>()
// 定义事件
const emit = defineEmits(['close', 'save'])
const activeTab = ref('movie')
const loading = ref(false)
const saving = ref(false)
const toast = useToast()
const { t } = useI18n()
const generateId = () => {
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
}
interface CategoryItem {
id: string
name: string
rule: any
}
const movieList = ref<CategoryItem[]>([])
const tvList = ref<CategoryItem[]>([])
// TMDB 类型映射
const genreOptions = [
{ title: '动作 (Action)', value: '28' },
{ title: '冒险 (Adventure)', value: '12' },
{ title: '动画 (Animation)', value: '16' },
{ title: '喜剧 (Comedy)', value: '35' },
{ title: '犯罪 (Crime)', value: '80' },
{ title: '纪录 (Documentary)', value: '99' },
{ title: '剧情 (Drama)', value: '18' },
{ title: '家庭 (Family)', value: '10751' },
{ title: '奇幻 (Fantasy)', value: '14' },
{ title: '历史 (History)', value: '36' },
{ title: '恐怖 (Horror)', value: '27' },
{ title: '音乐 (Music)', value: '10402' },
{ title: '悬疑 (Mystery)', value: '9648' },
{ title: '爱情 (Romance)', value: '10749' },
{ title: '科幻 (SF)', value: '878' },
{ title: '电视电影', value: '10770' },
{ title: '惊悚 (Thriller)', value: '53' },
{ title: '战争 (War)', value: '10752' },
{ title: '西部 (Western)', value: '37' },
{ title: '儿童 (Kids)', value: '10762' },
{ title: '新闻 (News)', value: '10763' },
{ title: '真人秀 (Reality)', value: '10764' },
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
{ title: '肥皂剧 (Soap)', value: '10766' },
{ title: '访谈 (Talk)', value: '10767' },
{ title: '战争/政治', value: '10768' },
]
// 语种选项 (original_language)
const languageOptions = [
{ title: '中文', value: 'zh' },
{ title: '中文', value: 'cn' },
{ title: '英语 (English)', value: 'en' },
{ title: '日语 (Japanese)', value: 'ja' },
{ title: '韩语 (Korean)', value: 'ko' },
{ title: '法语 (French)', value: 'fr' },
{ title: '德语 (German)', value: 'de' },
{ title: '西班牙语 (Spanish)', value: 'es' },
{ title: '意大利语 (Italian)', value: 'it' },
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
{ title: '俄语 (Russian)', value: 'ru' },
{ title: '阿拉伯语', value: 'ar' },
{ title: '泰语 (Thai)', value: 'th' },
{ title: '越南语 (Vietnamese)', value: 'vi' },
{ title: '印地语 (Hindi)', value: 'hi' },
{ title: '土耳其语 (Turkish)', value: 'tr' },
{ title: '荷兰语 (Dutch)', value: 'nl' },
{ title: '波兰语 (Polish)', value: 'pl' },
{ title: '瑞典语 (Swedish)', value: 'sv' },
{ title: '丹麦语 (Danish)', value: 'da' },
{ title: '挪威语 (Norwegian)', value: 'nb' },
{ title: '芬兰语 (Finnish)', value: 'fi' },
{ title: '希腊语 (Greek)', value: 'el' },
{ title: '捷克语 (Czech)', value: 'cs' },
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
{ title: '马来语 (Malay)', value: 'ms' },
{ title: '希伯来语 (Hebrew)', value: 'he' },
]
// 国家/地区选项 (origin_country/production_countries)
const countryOptions = [
{ title: '中国大陆 (CN)', value: 'CN' },
{ title: '中国香港 (HK)', value: 'HK' },
{ title: '中国台湾 (TW)', value: 'TW' },
{ title: '美国 (US)', value: 'US' },
{ title: '英国 (GB)', value: 'GB' },
{ title: '日本 (JP)', value: 'JP' },
{ title: '韩国 (KR)', value: 'KR' },
{ title: '法国 (FR)', value: 'FR' },
{ title: '德国 (DE)', value: 'DE' },
{ title: '意大利 (IT)', value: 'IT' },
{ title: '西班牙 (ES)', value: 'ES' },
{ title: '加拿大 (CA)', value: 'CA' },
{ title: '澳大利亚 (AU)', value: 'AU' },
{ title: '俄罗斯 (RU)', value: 'RU' },
{ title: '印度 (IN)', value: 'IN' },
{ title: '泰国 (TH)', value: 'TH' },
{ title: '新加坡 (SG)', value: 'SG' },
{ title: '马来西亚 (MY)', value: 'MY' },
{ title: '越南 (VN)', value: 'VN' },
{ title: '菲律宾 (PH)', value: 'PH' },
{ title: '巴西 (BR)', value: 'BR' },
{ title: '墨西哥 (MX)', value: 'MX' },
{ title: '阿根廷 (AR)', value: 'AR' },
{ title: '荷兰 (NL)', value: 'NL' },
{ title: '比利时 (BE)', value: 'BE' },
{ title: '瑞士 (CH)', value: 'CH' },
{ title: '瑞典 (SE)', value: 'SE' },
{ title: '挪威 (NO)', value: 'NO' },
{ title: '丹麦 (DK)', value: 'DK' },
{ title: '波兰 (PL)', value: 'PL' },
{ title: '捷克 (CZ)', value: 'CZ' },
{ title: '土耳其 (TR)', value: 'TR' },
{ title: '以色列 (IL)', value: 'IL' },
{ title: '埃及 (EG)', value: 'EG' },
{ title: '南非 (ZA)', value: 'ZA' },
{ title: '新西兰 (NZ)', value: 'NZ' },
]
const fetchConfig = async () => {
loading.value = true
try {
const res: any = await api.get('media/category/config')
if (res && res.data) {
parseConfig(res.data)
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.loadFailed'))
} finally {
loading.value = false
}
}
const parseConfig = (data: CategoryConfig) => {
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
movieList.value = []
if (data.movie) {
for (const [key, value] of Object.entries(data.movie)) {
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理制片国家/地区
if (rule.production_countries && typeof rule.production_countries === 'string') {
// @ts-ignore
rule.production_countries = rule.production_countries.split(',')
} else {
// @ts-ignore
rule.production_countries = []
}
movieList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
tvList.value = []
if (data.tv) {
for (const [key, value] of Object.entries(data.tv)) {
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理发行国家/地区
if (rule.origin_country && typeof rule.origin_country === 'string') {
// @ts-ignore
rule.origin_country = rule.origin_country.split(',')
} else {
// @ts-ignore
rule.origin_country = []
}
tvList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
}
const addMovieItem = () => {
movieList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeMovieItem = (index: number) => {
movieList.value.splice(index, 1)
}
const addTvItem = () => {
tvList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeTvItem = (index: number) => {
tvList.value.splice(index, 1)
}
const saveConfig = async () => {
saving.value = true
try {
// 将数组转换回对象
const payload: CategoryConfig = {
movie: {},
tv: {},
}
movieList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
// 将 genre_ids 数组转换回字符串
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 production_countries 数组转换回字符串
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
rule.production_countries = rule.production_countries.join(',')
} else {
rule.production_countries = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.movie[item.name] = rule
}
})
tvList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 origin_country 数组转换回字符串
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
rule.origin_country = rule.origin_country.join(',')
} else {
rule.origin_country = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.tv[item.name] = rule
}
})
const res: any = await api.post('media/category/config', payload)
if (res && res.success) {
toast.success(t('setting.category.saveSuccess'))
emit('save')
emit('close')
} else {
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
} finally {
saving.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-shape-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('setting.category.title') }}
</VCardTitle>
<VCardSubtitle>
{{ t('setting.category.subtitle') }}
</VCardSubtitle>
</VCardItem>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="mb-4">
<VTab value="movie">
<VIcon icon="mdi-movie-outline" class="me-2" />
{{ t('setting.category.movie') }}
</VTab>
<VTab value="tv">
<VIcon icon="mdi-television" class="me-2" />
{{ t('setting.category.tv') }}
</VTab>
</VTabs>
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
<VProgressCircular indeterminate color="primary" size="64" />
</div>
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="movie">
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeMovieItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.production_countries"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addMovieItem"
>
{{ t('setting.category.addMovie') }}
</VBtn>
</VWindowItem>
<VWindowItem value="tv">
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeTvItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.origin_country"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addTvItem"
>
{{ t('setting.category.addTv') }}
</VBtn>
</VWindowItem>
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="text" @click="emit('close')">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.category-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.category-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.add-category-btn {
border-style: dashed !important;
transition: all 0.2s ease;
}
.add-category-btn:hover {
border-style: solid !important;
transform: translateY(-1px);
}
.disable-tab-transition > * {
transition: none !important;
}
</style>

View File

@@ -0,0 +1,235 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import QRCode from 'qrcode'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
modelValue: boolean
isOtp: boolean
passkeyList?: PassKey[]
}
const props = withDefaults(defineProps<Props>(), {
passkeyList: () => [],
})
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
const { t } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// otp uri
const otpUri = ref('')
// otp secret
const secret = ref('')
// 确认双重验证密码
const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// 二维码图片 base64
const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 为当前用户获取Otp Uri
async function getOtpUri() {
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return
}
// 未启用OTP生成新的二维码
try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string
secret: string
}>
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
width: 200,
margin: 1,
})
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
}
}
// 启用Otp
async function judgeOtpPassword() {
if (!otpPassword.value) {
$toast.error(t('profile.otpCodeRequired'))
return
}
try {
const result = (await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})) as ApiResponse
if (result.success) {
$toast.success(t('profile.otpEnableSuccess'))
show.value = false
emit('update:isOtp', true)
} else {
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
}
}
// 关闭当前用户的双重验证
function disableOtp() {
// 如果已绑定PassKey不允许关闭OTP
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
$toast.error(t('profile.disableOtpWithPasskeyError'))
return
}
emit('verifyPassword', {
title: t('profile.disableTwoFactor'),
text: t('profile.confirmToDisableOtp'),
callback: async (password: string) => {
try {
const result = (await api.post('mfa/otp/disable', {
password,
})) as ApiResponse
if (result.success) {
emit('update:isOtp', false)
$toast.success(t('profile.otpDisableSuccess'))
show.value = false
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
}
},
})
}
// 监听弹窗打开,自动获取 URI
watch(
() => props.modelValue,
val => {
if (val) {
getOtpUri()
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpPassword.value = ''
}
},
)
</script>
<template>
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-cellphone-key" class="me-2" />
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
</VCardTitle>
<VDialogCloseBtn @click="show = false" />
</VCardItem>
<VDivider />
<VCardText>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<!-- 如果已启用OTP显示清除界面 -->
<template v-if="props.isOtp && !qrCode">
<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="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="error" @click="disableOtp">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
{{ t('profile.clearAuthenticator') }}
</VBtn>
</div>
</template>
<!-- 设置新的OTP -->
<template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</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="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</template>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,321 @@
<script lang="ts" setup>
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { formatDateDifference } from '@core/utils/formatters'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
modelValue: boolean
isOtp: boolean
}
// WebAuthn 相关接口定义
interface PublicKeyCredentialDescriptorJSON {
id: string
type: 'public-key'
transports?: AuthenticatorTransport[]
}
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
const { t, locale } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
// 格式化日期
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(locale.value)
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
if (result.success) {
passkeyList.value = result.data || []
emit('update:passkeyList', passkeyList.value)
}
} catch (error) {
console.error(error)
}
}
// 注册PassKey
async function registerPassKey() {
if (!passkeyName.value) {
$toast.error(t('profile.passkeyNameRequired'))
return
}
// 检查浏览器环境
if (!window.PublicKeyCredential) {
if (!window.isSecureContext) {
$toast.error(t('login.passkeySecureContextRequired'))
} else {
$toast.error(t('login.passkeyNotSupported'))
}
return
}
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult = (await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})) as ApiResponse<{ options: string; challenge: string }>
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
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: PublicKeyCredentialDescriptorJSON) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})) as PublicKeyCredential
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
return
}
// 3. 转换credential为可传输格式
const response = credential.response as AuthenticatorAttestationResponse
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
},
}
// 4. 完成注册
const finishResult = (await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
name: passkeyName.value,
})) as ApiResponse
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 if (error.name === 'NotSupportedError') {
$toast.error(t('login.passkeyNotSupported'))
} else if (error.message?.includes('start failed')) {
$toast.error(t('login.passkeyLoginStartFailed'))
} else if (error.response) {
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
} else {
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
}
} finally {
passkeyRegistering.value = false
}
}
// 删除PassKey
async function deletePassKey(passkeyId: number) {
emit('verifyPassword', {
title: t('profile.deletePasskey'),
text: t('profile.confirmToDeletePasskey'),
callback: async (password: string) => {
try {
const result = (await api.post('mfa/passkey/delete', {
passkey_id: passkeyId,
password,
})) as ApiResponse
if (result.success) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.passkeyDeleteFailed'))
}
},
})
}
// 监听弹窗打开,自动加载列表
watch(
() => props.modelValue,
val => {
if (val) {
fetchPassKeyList()
passkeyName.value = ''
} else {
// 弹窗关闭时,清空数据
passkeyName.value = ''
passkeyChallenge.value = ''
passkeyList.value = []
}
},
)
</script>
<template>
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="material-symbols:passkey" class="me-2" />
{{ t('profile.passkeyManagement') }}
</VCardTitle>
<VDialogCloseBtn @click="show = false" />
</VCardItem>
<VDivider />
<VCardText>
<p class="mb-6">
{{ t('profile.passkeyAppDescription') }}
</p>
<!-- 安全警告 -->
<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="canRegisterPasskey" 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>
<!-- 已注册的通行密钥列表 -->
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
<div
v-for="passkey in passkeyList"
:key="passkey.id"
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
>
<div>
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
<span v-if="passkey.last_used_at">
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
</span>
</div>
</div>
<div>
<VBtn
variant="flat"
color="error"
size="small"
class="rounded delete-btn"
@click="deletePassKey(passkey.id)"
>
<VIcon icon="mdi-trash-can-outline" size="20" />
</VBtn>
</div>
</div>
</div>
<VAlert v-else type="info" variant="tonal" class="mt-6">
{{ t('profile.noPasskeys') }}
</VAlert>
</VCardText>
<VCardActions class="justify-end px-6 pb-4">
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.v-btn.delete-btn {
min-width: 45px;
padding: 0;
background-color: rgba(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
transition: all 0.2s ease;
}
.v-btn.delete-btn:hover {
background-color: rgba(var(--v-theme-error), 0.2);
color: rgb(var(--v-theme-error));
}
</style>

View File

@@ -138,7 +138,7 @@ function getMenus(): NavMenu[] {
item =>
item &&
menus.push({
title: t('setting') + ' -> ' + item.title,
title: t('navItems.setting') + ' -> ' + item.title,
icon: item.icon,
to: `/setting?tab=${item.tab}`,
header: '',

View File

@@ -140,7 +140,7 @@ onMounted(async () => {
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
}
await loadDownloaderSetting()
})
@@ -224,15 +224,15 @@ onMounted(async () => {
</VCol>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<VTab value="cookie" selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
<VIcon size="20" start icon="mdi-cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<VTab value="api" selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
<VIcon size="20" start icon="mdi-api" />
API
</div>
</VTab>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import QRCode from 'qrcode'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -24,6 +24,9 @@ const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// 二维码图片 base64
const qrCodeImage = ref('')
// 下方的提示信息
const text = ref(t('dialog.u115Auth.scanQrCode'))
@@ -61,6 +64,11 @@ async function getQrcode() {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
width: 200,
margin: 1,
})
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
@@ -129,7 +137,13 @@ onUnmounted(() => {
<VDivider />
<VCardText class="pt-2 flex flex-col items-center justify-center">
<div class="mt-6 rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</div>
<div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">

View File

@@ -93,6 +93,7 @@ const userForm = ref<ExtendedUser>({
wechat_userid: null,
telegram_userid: null,
slack_userid: null,
discord_userid: null,
vocechat_userid: null,
synologychat_userid: null,
},
@@ -198,6 +199,7 @@ async function fetchUserInfo() {
userForm.value = await api.get(`user/${props.username}`)
if (userForm.value) {
userForm.value.avatar = userForm.value.avatar || avatar1
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
currentAvatar.value = userForm.value.avatar
currentUserName.value = userForm.value.name
userName.value = userForm.value.name
@@ -272,12 +274,10 @@ async function updateUser() {
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (userForm.value.nickname) {
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname ?? ''
const oldUserName = userForm.value.name
userForm.value.name = currentUserName.value
@@ -521,6 +521,15 @@ onMounted(() => {
prepend-inner-icon="mdi-slack"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.discord_userid"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.discord')"
prepend-inner-icon="mdi-discord"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.vocechat_userid"

View File

@@ -0,0 +1,817 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useEventListener } from '@vueuse/core'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入参数
const props = defineProps<{
// 筛选表单
filterForm: Record<string, string[]>
// 筛选选项
filterOptions: Record<string, string[]>
// 排序字段
sortField: string
// 排序方向
sortType: 'asc' | 'desc'
// 筛选后的总数量
totalFilteredCount: number
// 过滤项标题映射
filterTitles: Record<string, string>
// 排序标题映射
sortTitles: Record<string, string>
// 是否启用滚动动画
enableAnimation?: boolean
}>()
// 定义事件
const emit = defineEmits<{
'update:sortField': [value: string]
'update:sortType': [value: 'asc' | 'desc']
'update:filterForm': [key: string, values: string[]]
'selectAll': [key: string]
'clearFilter': [key: string]
'clearAllFilters': []
'removeFilter': [key: string, value: string]
}>()
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return props.filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in props.filterForm) {
count += props.filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in props.filterForm) {
if (props.filterForm[key].length > 0) {
filters[key] = [...props.filterForm[key]]
}
}
return filters
})
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 添加toggleFilterMenu函数
function toggleFilterMenu(key: string) {
if (currentFilter.value === key && filterMenuOpen.value) {
filterMenuOpen.value = false
} else {
currentFilter.value = key
filterMenuOpen.value = true
}
}
// 处理筛选值变化
function handleFilterChange(key: string, values: string[]) {
emit('update:filterForm', key, values)
}
// 全选某个过滤项
function selectAll(key: string) {
emit('selectAll', key)
}
// 清除某个过滤项
function clearFilter(key: string) {
emit('clearFilter', key)
}
// 清除所有过滤条件
function clearAllFilters() {
emit('clearAllFilters')
}
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
emit('removeFilter', key, value)
}
// 滚动条引用
const filterBarRef = ref<HTMLElement>()
/**
* 自定义平滑滚动
* @param element 元素
* @param target 目标位置
* @param duration 持续时间(ms)
*/
function smoothScroll(element: HTMLElement, target: number, duration: number) {
const start = element.scrollLeft
const change = target - start
let startTime: number | null = null
function animate(currentTime: number) {
if (startTime === null) startTime = currentTime
const timeElapsed = currentTime - startTime
const progress = Math.min(timeElapsed / duration, 1)
// 使用 ease-in-out 缓动函数
const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
element.scrollLeft = start + change * ease
if (timeElapsed < duration) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
// 初始滚动动画
onMounted(() => {
if (filterBarRef.value) {
useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault()
filterBarRef.value!.scrollLeft += e.deltaY
}
})
}
if (props.enableAnimation === false) return
nextTick(() => {
setTimeout(() => {
const el = filterBarRef.value
if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {
// 检查当前视口范围内的最后一个元素(即右侧边缘处的元素)
const containerRect = el.getBoundingClientRect()
const children = Array.from(el.children) as HTMLElement[]
const lastInViewport = children
.filter(c => {
const rect = c.getBoundingClientRect()
return rect.left < containerRect.right
})
.pop()
if (lastInViewport) {
const rect = lastInViewport.getBoundingClientRect()
const visibleWidth = Math.min(rect.right, containerRect.right) - rect.left
const visibleRatio = visibleWidth / rect.width
// 判断是否是列表最后一个元素
const isLastItem = lastInViewport === children[children.length - 1]
// 1. 如果是最后一个元素且显示比例超过80%,说明基本已经展示完了,不需要动画
if (isLastItem && visibleRatio > 0.8) {
return
}
// 2. 如果视口内最后一个元素显示比例在30%到80%之间(明显的截断状态),用户能感知到后面还有内容,不需要滚动提示
// 比例过小(<0.3)可能看不清,非最后一个元素且比例过大(>0.8)可能误以为是结尾,这两种情况都需要提示
if (visibleRatio > 0.3 && visibleRatio < 0.8) {
return
}
}
// 滚动到底部 (1100ms)
smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)
// 短暂停止后滚动回顶部 (1100ms)
setTimeout(() => {
smoothScroll(el, 0, 1100)
}, 1600)
}
}, 500)
})
})
</script>
<template>
<!-- PC端头部和筛选栏 -->
<div class="search-header d-none d-sm-block">
<VCard class="view-header mb-3">
<div class="d-flex align-center pa-3">
<!-- 固定位置资源数量和排序 -->
<div class="d-flex align-center flex-shrink-0">
<VChip
color="primary"
variant="flat"
size="small"
class="search-count me-3 flex-shrink-0"
prepend-icon="mdi-magnify"
>
{{ totalFilteredCount }} {{ t('torrent.resources') }}
</VChip>
<VBtn variant="text" size="small" class="sort-btn" :color="undefined">
<template #prepend>
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
</template>
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
<VMenu activator="parent" transition="slide-y-transition">
<VList density="compact" min-width="120" class="sort-menu-list">
<!-- 升序/降序 选项 -->
<VListItem
value="asc"
:active="sortType === 'asc'"
color="primary"
@click="emit('update:sortType', 'asc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
</VListItem>
<VListItem
value="desc"
:active="sortType === 'desc'"
color="primary"
@click="emit('update:sortType', 'desc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
</VListItem>
<VDivider class="my-1" />
<!-- 排序字段选项 -->
<VListItem
v-for="(title, key) in sortTitles"
:key="key"
:value="key"
:active="sortField === key"
color="primary"
@click="emit('update:sortField', key as string)"
class="px-3"
>
<VListItemTitle>{{ title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
<div class="filter-divider"></div>
</div>
<!-- 滚动区域筛选条件 -->
<div class="filter-bar" ref="filterBarRef">
<!-- 筛选按钮 -->
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
>
{{ title }}
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ filterForm[key].length }}
</VChip>
<VMenu activator="parent" :close-on-content-click="false" scrim>
<VCard max-width="20rem">
<VCardText class="filter-menu-content">
<div class="flex justify-between">
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</div>
<VChipGroup
:model-value="filterForm[key]"
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn me-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
</div>
</div>
<div v-if="getFilterCount > 0" class="selected-filters">
<div class="d-flex align-center">
<div class="d-flex flex-wrap align-center flex-grow-1">
<template v-for="(values, key) in getSelectedFilters" :key="key">
<VChip
v-for="(value, index) in values"
:key="`${key}-${index}`"
color="primary"
size="small"
closable
variant="elevated"
class="me-1 mb-1 mt-1 filter-tag"
@click:close="removeFilter(key as string, value)"
>
<VIcon size="small" :icon="getFilterIcon(key as string)" class="me-1"></VIcon>
<strong>{{ filterTitles[key as string] }}:</strong> {{ value }}
</VChip>
</template>
</div>
<VSpacer />
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
class="ms-2 flex-shrink-0"
prepend-icon="mdi-close-circle-outline"
>
{{ t('torrent.clearFilters') }}
</VBtn>
</div>
</div>
</VCard>
</div>
<!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3">
<div class="view-header">
<div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100">
<VChip
color="primary"
variant="elevated"
size="small"
class="search-count me-auto"
prepend-icon="mdi-magnify"
>
{{ totalFilteredCount }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
<VBtn variant="text" size="small" class="sort-btn mobile-sort-btn" :color="undefined">
<template #prepend>
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
</template>
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
<VMenu activator="parent" transition="slide-y-transition">
<VList density="compact" min-width="120" class="sort-menu-list">
<!-- 升序/降序 选项 -->
<VListItem
value="asc"
:active="sortType === 'asc'"
color="primary"
@click="emit('update:sortType', 'asc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
</VListItem>
<VListItem
value="desc"
:active="sortType === 'desc'"
color="primary"
@click="emit('update:sortType', 'desc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
</VListItem>
<VDivider class="my-1" />
<!-- 排序字段选项 -->
<VListItem
v-for="(title, key) in sortTitles"
:key="key"
:value="key"
:active="sortField === key"
color="primary"
@click="emit('update:sortField', key as string)"
class="px-3"
>
<VListItemTitle>{{ title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100 mt-2">
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="text"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
>
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ title }}
</span>
<VBadge
v-if="filterForm[key].length > 0"
:content="filterForm[key].length"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
</div>
</div>
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog
v-model="allFilterMenuOpen"
max-width="50rem"
location="center"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup
:model-value="filterForm[key]"
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
<span>{{ currentFilterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterForm[currentFilter].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(currentFilter)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup
:model-value="filterForm[currentFilter]"
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.search-header,
.search-header-mobile {
width: 100%;
max-width: 100%;
}
.view-header {
overflow: hidden;
}
.search-count {
font-weight: 500;
}
.sort-btn {
height: 32px !important;
font-weight: 500;
padding-inline: 12px 6px !important;
}
.sort-btn .v-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
}
.sort-btn :deep(.v-btn__prepend) {
margin-inline-end: 2px !important;
}
.sort-menu-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
margin-inline-end: 0px !important;
}
.filter-bar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
overflow-x: auto;
flex: 1;
width: 0;
min-width: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.filter-bar::-webkit-scrollbar {
display: none;
}
.filter-bar > * {
flex-shrink: 0;
}
.filter-divider {
background-color: rgba(var(--v-theme-on-surface), 0.12);
block-size: 24px;
inline-size: 1px;
margin-block: 0;
margin-inline: 8px;
}
.filter-btn {
min-inline-size: 0;
transition: opacity 0.2s;
}
.filter-btn:hover {
opacity: 0.8;
}
.filter-menu-content {
max-block-size: 50vh;
overflow-y: auto;
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
.filter-tag {
font-weight: 500;
transition: all 0.2s;
}
.filter-tag:hover {
opacity: 0.8;
}
.selected-filters {
overflow: hidden;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
padding-block: 8px;
padding-inline: 12px;
}
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
}
.filter-btn-mobile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
padding-inline: 0;
}
.filter-icon {
font-size: 18px;
margin-block-end: 2px;
}
.filter-label {
font-size: 0.8rem;
text-align: center;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -0,0 +1,60 @@
import type { Ref } from 'vue'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
/**
* 无限滚动 composable
* 用于管理分页显示和无限滚动加载
* @param sourceData - 源数据(响应式引用)
* @param pageSize - 每页显示数量默认20
*/
export function useInfiniteScroll<T>(
sourceData: Ref<T[]>,
pageSize: number = 20
) {
// 显示用的数据列表
const displayDataList = ref<T[]>([])
// 剩余数据列表(用于无限滚动)
const remainingDataList = ref<T[]>([]) as Ref<T[]>
// 初始化数据
function initData() {
if (sourceData.value?.length) {
// 显示前 pageSize 个
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
// 保存剩余数据
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
} else {
displayDataList.value = []
remainingDataList.value = []
}
}
// 加载更多
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
// 从 remainingDataList 中获取最前面的 pageSize 个元素
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
;(displayDataList.value as T[]).push(...itemsToMove)
done('ok')
}
// 重置数据
function reset() {
displayDataList.value = []
remainingDataList.value = []
}
// 监听源数据变化,重新初始化
watch(sourceData, () => {
initData()
}, { deep: true, immediate: true })
return {
displayDataList,
remainingDataList,
initData,
loadMore,
reset,
}
}

View File

@@ -0,0 +1,502 @@
import type { Context } from '@/api/types'
import { cloneDeepWith } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 卡片视图的分组数据类型
interface SearchTorrent extends Context {
more?: Array<Context>
}
interface GroupedItem {
data: SearchTorrent
originalIndex: number
}
// 筛选状态类型
export interface FilterState {
filterForm: Record<string, string[]>
filterOptions: Record<string, string[]>
sortField: string
sortType: 'asc' | 'desc'
}
// useTorrentFilter composable
export function useTorrentFilter() {
const { t } = useI18n()
// 过滤表单
const filterForm: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
releaseGroup: [] as string[],
videoCode: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
})
// 统一存储过滤选项
const filterOptions: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
videoCode: [] as string[],
releaseGroup: [] as string[],
})
// 排序字段
const sortField = ref('default')
// 排序方向
const sortType = ref<'asc' | 'desc'>('desc')
// 过滤项映射
const filterTitles: Record<string, string> = {
site: t('torrent.filterSite'),
season: t('torrent.filterSeason'),
freeState: t('torrent.filterFreeState'),
videoCode: t('torrent.filterVideoCode'),
edition: t('torrent.filterEdition'),
resolution: t('torrent.filterResolution'),
releaseGroup: t('torrent.filterReleaseGroup'),
}
// 排序中文名
const sortTitles: Record<string, string> = {
default: t('torrent.sortDefault'),
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 筛选后数据的原始索引列表
const filteredIndices = ref<number[]>([])
// 筛选后的总数量
const totalFilteredCount = ref(0)
// 初始化过滤选项
function initOptions(data: Context) {
const { torrent_info, meta_info } = data
const optionValue = (options: Array<string>, value: string | undefined) => {
if (value && !options.includes(value)) {
options.push(value)
// 如果是season选项立即触发重新计算
if (options === filterOptions.season) {
sortSeasonOptions()
}
}
}
optionValue(filterOptions.site, torrent_info?.site_name)
optionValue(filterOptions.season, meta_info?.season_episode)
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
optionValue(filterOptions.videoCode, meta_info?.video_encode)
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
optionValue(filterOptions.edition, meta_info?.edition)
optionValue(filterOptions.resolution, meta_info?.resource_pix)
}
// 直接对季集选项进行排序的函数
function sortSeasonOptions() {
if (filterOptions.season.length <= 1) {
return
}
const parsedOptions = filterOptions.season.map((option, index) => {
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
if (!match) {
return {
original: option,
seasonNum: 0,
episodeNum: 0,
maxEpisodeNum: 0,
isWholeSeason: false,
index,
}
}
const seasonNum = parseInt(match[1], 10)
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
const isWholeSeason = !match[3]
return {
original: option,
seasonNum,
episodeNum,
maxEpisodeNum,
isWholeSeason,
index,
}
})
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
wholeSeasons.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum
}
return a.index - b.index
})
episodes.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum
}
const aMaxEp = a.maxEpisodeNum || a.episodeNum
const bMaxEp = b.maxEpisodeNum || b.episodeNum
if (aMaxEp !== bMaxEp) {
return bMaxEp - aMaxEp
}
if (a.episodeNum !== b.episodeNum) {
return b.episodeNum - a.episodeNum
}
return a.index - b.index
})
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
filterOptions.season = sortedOptions
}
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 筛选列表视图数据(不分组)
function filterRowData(items: Context[] | undefined): Context[] {
// 重置状态
filteredIndices.value = []
// 清空并重新初始化过滤选项
for (const key in filterOptions) {
filterOptions[key] = []
}
if (!items?.length) {
totalFilteredCount.value = 0
return []
}
// 首先收集所有过滤选项
items.forEach(data => {
initOptions(data)
})
// 筛选数据
let filteredData: Context[] = []
items.forEach((data, index) => {
const { meta_info, torrent_info } = data
if (
match(filterForm.site, torrent_info.site_name) &&
match(filterForm.freeState, torrent_info.volume_factor) &&
match(filterForm.season, meta_info.season_episode) &&
match(filterForm.releaseGroup, meta_info.resource_team) &&
match(filterForm.videoCode, meta_info.video_encode) &&
match(filterForm.resolution, meta_info.resource_pix) &&
match(filterForm.edition, meta_info.edition)
) {
filteredData.push(data)
filteredIndices.value.push(index)
}
})
totalFilteredCount.value = filteredData.length
// 排序
filteredData = sortData(filteredData)
// 确保季集选项排序
if (filterOptions.season.length > 0) {
sortSeasonOptions()
}
return filteredData
}
// 筛选卡片视图数据(分组)
function filterCardData(items: Context[] | undefined): SearchTorrent[] {
// 重置状态
filteredIndices.value = []
// 清空并重新初始化过滤选项
for (const key in filterOptions) {
filterOptions[key] = []
}
if (!items?.length) {
totalFilteredCount.value = 0
return []
}
// 数据分组
const groupMap = new Map<string, GroupedItem[]>()
items.forEach((item, index) => {
const { torrent_info, meta_info } = item
// init options
initOptions(item)
// group data
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
const groupedItem = { data: item, originalIndex: index }
if (groupMap.has(key)) {
const group = groupMap.get(key)
group?.push(groupedItem)
} else {
groupMap.set(key, [groupedItem])
}
})
// 筛选数据
const filteredData: SearchTorrent[] = []
let matchCount = 0
// 临时存储:每个分组的第一个原始索引
const groupIndexMap = new Map<SearchTorrent, number>()
groupMap.forEach(value => {
if (value.length > 0) {
const matchData = value.filter(item => {
const { meta_info, torrent_info } = item.data
return (
match(filterForm.site, torrent_info.site_name) &&
match(filterForm.freeState, torrent_info.volume_factor) &&
match(filterForm.season, meta_info.season_episode) &&
match(filterForm.releaseGroup, meta_info.resource_team) &&
match(filterForm.videoCode, meta_info.video_encode) &&
match(filterForm.resolution, meta_info.resource_pix) &&
match(filterForm.edition, meta_info.edition)
)
})
if (matchData.length > 0) {
matchCount += matchData.length
const firstItem = matchData[0]
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
filteredData.push(firstData)
// 存储该分组的第一个原始索引
groupIndexMap.set(firstData, firstItem.originalIndex)
}
}
})
totalFilteredCount.value = matchCount
// 排序数据
const sortedData = sortCardData(filteredData)
// 在排序后重新构建 filteredIndices保持与排序后顺序一致
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
// 确保季集选项排序
if (filterOptions.season.length > 0) {
sortSeasonOptions()
}
return sortedData
}
// 排序列表数据
function sortData(data: Context[]): Context[] {
const sortOrder = sortType.value === 'asc' ? 1 : -1
return data.sort((a, b) => {
let result = 0
switch (sortField.value) {
case 'site':
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
break
case 'size':
result = a.torrent_info.size - b.torrent_info.size
break
case 'seeder':
result = a.torrent_info.seeders - b.torrent_info.seeders
break
case 'publishTime':
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
break
case 'default':
default:
result = a.torrent_info.pri_order - b.torrent_info.pri_order
break
}
return result * sortOrder
})
}
// 排序卡片数据
function sortCardData(data: SearchTorrent[]): SearchTorrent[] {
if (sortField.value === 'default') {
return data
}
const sortOrder = sortType.value === 'asc' ? 1 : -1
return data.sort((a, b) => {
let result = 0
switch (sortField.value) {
case 'site':
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
break
case 'size':
result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
break
case 'seeder':
result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
break
case 'publishTime':
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
break
}
return result * sortOrder
})
}
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in filterForm) {
count += filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
if (filterForm[key].length > 0) {
filters[key] = [...filterForm[key]]
}
}
return filters
})
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
const index = filterForm[key].indexOf(value)
if (index !== -1) {
filterForm[key].splice(index, 1)
}
}
// 清除所有过滤条件
function clearAllFilters() {
for (const key in filterForm) {
filterForm[key] = []
}
}
// 清除某个过滤项
function clearFilter(key: string) {
filterForm[key] = []
}
// 全选某个过滤项
function selectAll(key: string) {
filterForm[key] = [...filterOptions[key]]
}
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 处理排序图标点击
const handleSortIconClick = () => {
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
// 获取筛选后的原始索引列表
function getFilteredIndices() {
return filteredIndices.value
}
// 检查是否有活动的筛选条件
function hasActiveFilters() {
for (const key in filterForm) {
if (filterForm[key] && filterForm[key].length > 0) {
return true
}
}
return false
}
// 获取当前筛选条件
function getFilterForm() {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
filters[key] = [...filterForm[key]]
}
return filters
}
// 设置筛选条件
function setFilterForm(filters: Record<string, string[]>) {
for (const key in filterForm) {
filterForm[key] = filters[key] ? [...filters[key]] : []
}
}
// 获取完整的筛选状态
function getFilterState(): FilterState {
return {
filterForm: getFilterForm(),
filterOptions: { ...filterOptions },
sortField: sortField.value,
sortType: sortType.value,
}
}
// 设置完整的筛选状态
function setFilterState(state: FilterState) {
setFilterForm(state.filterForm)
sortField.value = state.sortField
sortType.value = state.sortType
}
return {
// 状态
filterForm,
filterOptions,
sortField,
sortType,
filteredIndices,
totalFilteredCount,
// 标题映射
filterTitles,
sortTitles,
// 计算属性
getFilterCount,
getSelectedFilters,
// 筛选方法
filterRowData,
filterCardData,
// 操作方法
removeFilter,
clearAllFilters,
clearFilter,
selectAll,
getFilterIcon,
handleSortIconClick,
// 状态管理方法
getFilteredIndices,
hasActiveFilters,
getFilterForm,
setFilterForm,
getFilterState,
setFilterState,
sortSeasonOptions,
}
}

View File

@@ -69,7 +69,9 @@ export default {
preset: 'Preset',
refresh: 'Refresh',
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
versionMismatch: 'Browser cache version does not match server version, please try clearing cache',
ascending: 'Ascending',
descending: 'Descending',
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
clearCache: 'Clear Cache',
},
mediaType: {
@@ -244,17 +246,16 @@ export default {
wallpapers: 'Wallpapers',
username: 'Username',
password: 'Password',
otpCode: 'Two-Factor Code',
otpCode: 'Verification Code',
stayLoggedIn: 'Stay Logged In',
login: 'Login',
networkError: 'Login failed, please check your network connection!',
authFailure: 'Login failed, please check your username, password or two-factor authentication!',
authFailure: 'Login failed, please check your username, password or secondary verification!',
permissionDenied: 'Login failed, you do not have permission to access!',
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or two-factor authentication code!',
twoFactorAuth: 'Two-Factor Authentication',
secondaryVerification: 'Secondary Verification',
loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification',
@@ -264,7 +265,8 @@ export default {
passkeyNotSelected: 'No Passkey selected',
passkeyLoginFailed: 'Passkey login failed',
passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyLoginRetry: 'Passkey login failed, please try again',
passkeyNotSupported: 'Current browser does not support Passkeys',
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: {
@@ -958,6 +960,9 @@ export default {
searching: 'Searching, please wait...',
noData: 'No Data',
noResourceFound: 'No resources found',
aiRecommend: 'AI Recommendation',
reRecommend: 'Regenerate Recommendation',
aiRecommendError: 'AI Recommendation Failed',
},
browse: {
actor: 'Actor',
@@ -1287,17 +1292,29 @@ export default {
llmProviderHint: 'Select the LLM service provider to use',
llmModel: 'LLM Model Name',
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
llmApiKey: 'LLM API Key',
llmApiKeyHint: 'API key from the LLM service provider for authentication',
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',
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',
downloadersDesc: 'Only the default downloader will be used by default.',
aiRecommendEnabled: 'AI Search Recommendation',
aiRecommendEnabledHint:
'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
aiRecommendUserPreference: 'User Preference',
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
aiRecommendMaxItemsHint:
'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
mediaServers: 'Media Servers',
mediaServersDesc: 'All enabled media servers will be used.',
trimeMedia: 'TrimeMedia',
@@ -1399,6 +1416,8 @@ export default {
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
transferThreads: 'File Transfer Threads',
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
tokenizedSearch: 'Tokenized Search',
tokenizedSearchHint:
'Improve organization history search precision, but may increase performance overhead and unexpected results',
@@ -1659,6 +1678,7 @@ export default {
storage: 'Storage',
storageDesc: 'Set up local or cloud storage.',
directory: 'Directory',
mediaType: 'Media Type',
directoryDesc: 'Set up media file organization directory structure, matching in sequence.',
organizeAndScrap: 'Organization & Scraping',
organizeAndScrapDesc: 'Set rename format, scraping options, etc.',
@@ -1680,6 +1700,25 @@ export default {
storageSaveSuccess: 'Storage settings saved successfully',
storageSaveFailed: 'Failed to save storage settings!',
},
category: {
title: 'Category Policy',
subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',
movie: 'Movies',
tv: 'TV Shows',
name: 'Category Name (Directory)',
genre: 'Genre',
language: 'Language',
languagePlaceholder: 'e.g., en,fr,zh (comma separated)',
country: 'Country/Region',
countryPlaceholder: 'e.g., US,CN,JP',
year: 'Year',
yearPlaceholder: 'e.g., 2023, 2020-2024',
addMovie: 'Add Movie Category',
addTv: 'Add TV Category',
saveSuccess: 'Category policy saved successfully',
loadFailed: 'Failed to load category configuration',
saveFailed: 'Save failed: {message}',
},
rule: {
customRules: 'Custom Rules',
customRulesDesc: 'Custom priority rule items',
@@ -1775,7 +1814,7 @@ export default {
},
cache: {
title: 'Cache Management',
subtitle: 'Manage torrent cache data',
subtitle: 'Manage cached site resources',
totalCount: 'Total Count',
siteCount: 'Site Count',
filterByTitle: 'Filter by Title',
@@ -1871,6 +1910,7 @@ export default {
wechat: 'WeChat UserID',
telegram: 'Telegram UserID',
slack: 'Slack UserID',
discord: 'Discord UserID',
vocechat: 'VoceChat UserID',
synologyChat: 'SynologyChat UserID',
webPush: 'WebPush',
@@ -2561,6 +2601,7 @@ export default {
noRecentPlugins: 'None',
},
profile: {
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
personalInfo: 'Personal Information',
uploadNewAvatar: 'Upload New Avatar',
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
@@ -2581,6 +2622,7 @@ export default {
wechatUser: 'WeChat User',
telegramUser: 'Telegram User',
slackUser: 'Slack User',
discordUser: 'Discord User',
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
@@ -2600,20 +2642,25 @@ export default {
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyAppDescription:
'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
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',
createdAt: 'Created',
lastUsedAt: 'Last used',
noPasskeys: 'You havent 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.',
deletePasskey: 'Delete Passkey',
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}!',
@@ -2622,12 +2669,13 @@ export default {
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.',
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.',
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
secretKeyTip:
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.",
enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
@@ -3033,6 +3081,26 @@ export default {
unsupportedDownloaderType: 'Unsupported downloader type: {type}',
unsupportedMediaServerType: 'Unsupported media server type: {type}',
unsupportedNotificationType: 'Unsupported notification type: {type}',
storageTestFailed: 'Storage test failed',
downloaderTestFailed: 'Downloader test failed',
downloaderNotSelected: 'No downloader selected',
mediaServerTestFailed: 'Media server test failed',
mediaServerNotSelected: 'No media server selected',
notificationTestFailed: 'Notification test failed',
notificationNotSelected: 'No notification type selected',
saveStepFailed: 'Failed to save step settings',
basicSettingsSaved: 'Basic settings saved successfully',
saveBasicSettingsFailed: 'Failed to save basic settings',
storageSettingsSaved: 'Storage settings saved successfully',
saveStorageSettingsFailed: 'Failed to save storage settings',
downloaderSettingsSaved: 'Downloader settings saved successfully',
saveDownloaderSettingsFailed: 'Failed to save downloader settings',
mediaServerSettingsSaved: 'Media server settings saved successfully',
saveMediaServerSettingsFailed: 'Failed to save media server settings',
notificationSettingsSaved: 'Notification settings saved successfully',
saveNotificationSettingsFailed: 'Failed to save notification settings',
preferenceSettingsSaved: 'Preference settings saved successfully',
savePreferenceSettingsFailed: 'Failed to save preference settings',
passwordUpdateSuccess: 'Password updated successfully',
userCreateSuccess: 'User created successfully',
passwordUpdateFailed: 'Failed to update password',

View File

@@ -69,6 +69,8 @@ export default {
preset: '预设',
refresh: '刷新',
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
ascending: '升序',
descending: '降序',
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
clearCache: '清除缓存',
},
@@ -243,17 +245,16 @@ export default {
wallpapers: '壁纸',
username: '用户名',
password: '密码',
otpCode: '双重验证码',
otpCode: '验证码',
stayLoggedIn: '保持登录',
login: '登录',
networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
permissionDenied: '登录失败,您没有权限访问!',
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
twoFactorAuth: '双重验证',
secondaryVerification: '二次验证',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证',
@@ -263,7 +264,8 @@ export default {
passkeyNotSelected: '未选择通行密钥',
passkeyLoginFailed: '通行密钥登录失败',
passkeyAuthCanceled: '通行密钥认证被取消',
passkeyLoginRetry: '通行密钥登录失败,请重试',
passkeyNotSupported: '当前浏览器不支持通行密钥',
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: {
@@ -955,6 +957,9 @@ export default {
searching: '正在搜索,请稍候...',
noData: '没有数据',
noResourceFound: '未搜索到任何资源',
aiRecommend: '智能推荐',
reRecommend: '重新生成推荐',
aiRecommendError: '智能推荐失败',
},
browse: {
actor: '演员',
@@ -1283,6 +1288,9 @@ export default {
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密钥',
llmApiKeyHint: 'LLM服务提供商的API密钥用于身份验证',
llmApiKeyPlaceholder: '请输入API密钥',
@@ -1294,6 +1302,14 @@ export default {
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
downloaders: '下载器',
downloadersDesc: '只有默认下载器才会被默认使用。',
aiRecommendEnabled: '搜索结果智能推荐',
aiRecommendEnabledHint:
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
aiRecommendUserPreference: '用户偏好',
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推荐分析条目上限',
aiRecommendMaxItemsHint:
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
mediaServers: '媒体服务器',
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
trimeMedia: '飞牛影视',
@@ -1390,6 +1406,8 @@ export default {
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
transferThreads: '文件整理线程数',
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
tokenizedSearch: '分词搜索',
tokenizedSearchHint: '提升整理历史记录搜索精度,但可能增加性能开销和意外结果',
tmdbLanguage: {
@@ -1659,6 +1677,25 @@ export default {
storageSaveSuccess: '存储设置保存成功',
storageSaveFailed: '存储设置保存失败!',
},
category: {
title: '分类策略',
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
movie: '电影 (Movie)',
tv: '电视剧 (TV)',
name: '分类名称 (目录名)',
genre: '内容类型 (Genre)',
language: '语种 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
country: '国家/地区 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加电影分类',
addTv: '添加电视剧分类',
saveSuccess: '分类策略保存成功',
loadFailed: '加载分类配置失败',
saveFailed: '保存失败: {message}',
},
rule: {
customRules: '自定义规则',
customRulesDesc: '自定义优先级规则项',
@@ -1847,6 +1884,7 @@ export default {
wechat: '微信ID',
telegram: 'Telegram ID',
slack: 'Slack ID',
discord: 'Discord ID',
vocechat: 'VoceChat ID',
synologyChat: 'SynologyChat ID',
webPush: 'WebPush',
@@ -2530,6 +2568,7 @@ export default {
noRecentPlugins: '无',
},
profile: {
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
personalInfo: '个人信息',
uploadNewAvatar: '上传新头像',
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
@@ -2550,6 +2589,7 @@ export default {
wechatUser: '微信用户',
telegramUser: 'Telegram用户',
slackUser: 'Slack用户',
discordUser: 'Discord用户',
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
@@ -2569,11 +2609,13 @@ export default {
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyAppDescription:
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
registeredPasskeys: '已注册的通行密钥',
createdAt: '创建时间',
createdAt: '创建于',
lastUsedAt: '最后使用时间',
noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功',
@@ -2581,8 +2623,11 @@ export default {
passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
passkeyDomainWarning: '通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
deletePasskey: '删除通行密钥',
passkeyDomainWarning:
'通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey:
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
@@ -2594,9 +2639,8 @@ export default {
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorApp: '身份验证器',
authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码供您在下方输入。',
'使用 Google Authenticator、Microsoft Authenticator、Authy1Password验证器应用扫描二维码,获取 6 位验证码。',
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
enterVerificationCode: '输入验证码以确认开启双重验证',
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
@@ -3144,3 +3188,7 @@ export default {
},
},
}
// Apply patch to add category strings
// This is a temporary placeholder command to show intent.
// I will use replace_file_content to actually edit the file safely.

View File

@@ -69,7 +69,9 @@ export default {
preset: '預設',
refresh: '刷新',
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
versionMismatch: '瀏覽器快取版本與伺服器版本不一致,請嘗試清除快取',
ascending: '升序',
descending: '降序',
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
clearCache: '清除快取',
},
mediaType: {
@@ -157,7 +159,6 @@ export default {
subscribeMovie: '電影訂閱',
subscribeTv: '電視劇訂閱',
settings: '設置',
language: '語言設置',
selectLanguage: '選擇語言',
logout: '退出登錄',
restarting: '正在重啟...',
@@ -244,17 +245,16 @@ export default {
wallpapers: '壁紙',
username: '用戶名',
password: '密碼',
otpCode: '雙重驗證碼',
otpCode: '驗證碼',
stayLoggedIn: '保持登錄',
login: '登錄',
networkError: '登錄失敗,請檢查網絡連接!',
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!',
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
permissionDenied: '登錄失敗,您沒有權限訪問!',
serverError: '登錄失敗,服務器錯誤!',
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗',
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
twoFactorAuth: '雙重驗證',
secondaryVerification: '二次驗證',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
@@ -264,7 +264,8 @@ export default {
passkeyNotSelected: '未選擇通行密鑰',
passkeyLoginFailed: '通行密鑰登錄失敗',
passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: {
@@ -433,10 +434,13 @@ export default {
name: '企業微信',
corpId: '企業ID',
corpIdHint: '企業微信後台企業信息中的企業ID',
corpIdRequired: '企業ID不能為空',
appId: '應用 AgentId',
appIdHint: '企業微信自建應用的AgentId',
appIdRequired: '應用AgentId不能為空',
appSecret: '應用 Secret',
appSecretHint: '企業微信自建應用的Secret',
appSecretRequired: '應用Secret不能為空',
proxy: '代理地址',
proxyHint: '微信消息的轉發代理地址2022年6月20日後創建的自建應用才需要不使用代理時需要保留默認值',
token: 'Token',
@@ -451,23 +455,30 @@ export default {
name: 'Telegram',
token: 'Bot Token',
tokenHint: 'Telegram機器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
tokenRequired: 'Bot Token不能為空',
chatId: 'Chat ID',
chatIdHint: '接受消息通知的用戶、群組或頻道Chat ID',
chatIdRequired: 'Chat ID不能為空',
users: '用戶白名單',
usersHint: '可使用Telegram機器人的用戶ID清單多個用戶用,分隔,不填寫則所有用戶都能使用',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
usersPlaceholder: '用戶ID列表多個ID使用,分隔',
apiUrl: '代理API地址',
apiUrlHint: '自定義代理API地址格式https://api.telegram.org',
apiUrlPlaceholder: 'https://api.telegram.org',
},
slack: {
name: 'Slack',
oauthToken: 'Slack Bot User OAuth Token',
oauthTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`Bot User OAuth Token`',
oauthTokenRequired: 'OAuth Token不能為空',
appToken: 'Slack App-Level Token',
appTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`App-Level Token`',
channel: '頻道名稱',
channelHint: '消息發送頻道,默認`全體`',
channelRequired: '頻道名稱不能為空',
},
discord: {
name: 'Discord',
@@ -485,6 +496,7 @@ export default {
name: 'Synology Chat',
webhook: '機器人傳入URL',
webhookHint: 'Synology Chat機器人傳入URL',
webhookRequired: 'Webhook URL不能為空',
token: '令牌',
tokenHint: 'Synology Chat機器人令牌',
},
@@ -492,8 +504,10 @@ export default {
name: 'VoceChat',
host: '地址',
hostHint: 'VoceChat服務端地址格式http(s)://ip:port',
hostRequired: '地址不能為空',
apiKey: '機器人密鑰',
apiKeyHint: 'VoceChat機器人密鑰',
apiKeyRequired: 'API密鑰不能為空',
channelId: '頻道ID',
channelIdHint: 'VoceChat的頻道ID不包含#號',
},
@@ -501,6 +515,7 @@ export default {
name: 'WebPush',
username: '登錄用戶名',
usernameHint: '只有對應的用戶登錄後才會推送消息',
usernameRequired: '用戶名不能為空',
},
},
shortcut: {
@@ -942,6 +957,9 @@ export default {
searching: '正在搜索,請稍候...',
noData: '沒有數據',
noResourceFound: '未搜索到任何資源',
aiRecommend: '智能推薦',
reRecommend: '重新生成推薦',
aiRecommendError: '智能推薦失敗',
},
browse: {
actor: '演員',
@@ -1271,6 +1289,9 @@ export default {
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密鑰',
llmApiKeyHint: 'LLM服務提供商的API密鑰用於身份驗證',
llmApiKeyPlaceholder: '請輸入API密鑰',
@@ -1282,6 +1303,14 @@ export default {
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
downloaders: '下載器',
downloadersDesc: '只有默認下載器才會被默認使用。',
aiRecommendEnabled: '搜索結果智能推薦',
aiRecommendEnabledHint:
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
aiRecommendUserPreference: '用戶偏好',
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推薦分析條目上限',
aiRecommendMaxItemsHint:
'限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
mediaServers: '媒體服務器',
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
trimeMedia: '飛牛影視',
@@ -1378,6 +1407,8 @@ export default {
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
transferThreads: '文件整理線程數',
transferThreadsHint: '多線程整理文件可以提高速度,但可能增加系統資源佔用',
tokenizedSearch: '分詞搜索',
tokenizedSearchHint: '提升整理歷史記錄搜索精度,但可能增加性能開銷和意外結果',
tmdbLanguage: {
@@ -1626,6 +1657,7 @@ export default {
storage: '存儲',
storageDesc: '設置本地或網盤存儲',
directory: '目錄',
mediaType: '媒體類型',
directoryDesc: '設置媒體文件整理目錄結構,按先後順序依次匹配。',
organizeAndScrap: '整理 & 刮削',
organizeAndScrapDesc: '設置重命名格式、刮削選項等。',
@@ -1646,6 +1678,25 @@ export default {
storageSaveSuccess: '存儲設置保存成功',
storageSaveFailed: '存儲設置保存失敗!',
},
category: {
title: '分類策略',
subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',
movie: '電影 (Movie)',
tv: '電視劇 (TV)',
name: '分類名稱 (目錄名)',
genre: '內容類型 (Genre)',
language: '語種 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',
country: '國家/地區 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加電影分類',
addTv: '添加電視劇分類',
saveSuccess: '分類策略保存成功',
loadFailed: '加載分類配置失敗',
saveFailed: '保存失敗: {message}',
},
rule: {
customRules: '自定義規則',
customRulesDesc: '自定義優先級規則項',
@@ -1684,8 +1735,8 @@ export default {
importHasId: '導入失敗發現有規則存在相同ID可能屬於自定義規則',
},
scheduler: {
scheduledTasks: '定時作業',
scheduledTasksDesc: '包含系統內置服務以及插件提供的服務',
title: '定時作業',
subtitle: '包含系統內置服務以及插件提供的服務',
provider: '提供者',
taskName: '任務名稱',
taskStatus: '任務狀態',
@@ -1737,9 +1788,10 @@ export default {
settingsSaveFailed: '訂閱基礎設置保存失敗!',
},
cache: {
title: '緩存',
description: '種子緩存、圖片文件緩存管理',
title: '緩存管理',
subtitle: '管理緩存的站點資源',
totalCount: '總條數',
siteCount: '站點數',
filterByTitle: '按標題篩選',
filterBySite: '按站點篩選',
selectSite: '選擇站點',
@@ -1833,6 +1885,7 @@ export default {
wechat: '微信UserID',
telegram: 'Telegram UserID',
slack: 'Slack UserID',
discord: 'Discord UserID',
vocechat: 'VoceChat UserID',
synologyChat: 'SynologyChat UserID',
webPush: 'WebPush',
@@ -2516,6 +2569,7 @@ export default {
noRecentPlugins: '無',
},
profile: {
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
personalInfo: '個人信息',
uploadNewAvatar: '上傳新頭像',
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
@@ -2536,6 +2590,7 @@ export default {
wechatUser: '微信用戶',
telegramUser: 'Telegram用戶',
slackUser: 'Slack用戶',
discordUser: 'Discord用戶',
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
@@ -2555,11 +2610,13 @@ export default {
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyAppDescription:
'通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
registeredPasskeys: '已註冊的通行密鑰',
createdAt: '建立時間',
createdAt: '建立於',
lastUsedAt: '最後使用時間',
noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功',
@@ -2567,8 +2624,11 @@ export default {
passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗',
passkeyDomainWarning: '通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
deletePasskey: '刪除通行密鑰',
passkeyDomainWarning:
'通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey:
'為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
@@ -2580,9 +2640,8 @@ export default {
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorApp: '身份驗證器',
authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password這樣的身份驗證器應用程掃描二維碼。它將為您生成一個6位數的代碼供您在下方輸入。',
'使用 Google Authenticator、Microsoft Authenticator、Authy1Password驗證器應用程式掃描 QR Code取得 6 位數驗證碼。',
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
@@ -2986,6 +3045,26 @@ export default {
unsupportedDownloaderType: '不支援的下載器類型: {type}',
unsupportedMediaServerType: '不支援的媒體服務器類型: {type}',
unsupportedNotificationType: '不支援的通知類型: {type}',
storageTestFailed: '存儲目錄測試失敗',
downloaderTestFailed: '下載器測試失敗',
downloaderNotSelected: '未選擇下載器',
mediaServerTestFailed: '媒體服務器測試失敗',
mediaServerNotSelected: '未選擇媒體服務器',
notificationTestFailed: '消息通知測試失敗',
notificationNotSelected: '未選擇通知類型',
saveStepFailed: '保存步驟設置失敗',
basicSettingsSaved: '基礎設置保存成功',
saveBasicSettingsFailed: '保存基礎設置失敗',
storageSettingsSaved: '存儲設置保存成功',
saveStorageSettingsFailed: '保存存儲設置失敗',
downloaderSettingsSaved: '下載器設置保存成功',
saveDownloaderSettingsFailed: '保存下載器設置失敗',
mediaServerSettingsSaved: '媒體服務器設置保存成功',
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
notificationSettingsSaved: '通知設置保存成功',
saveNotificationSettingsFailed: '保存通知設置失敗',
preferenceSettingsSaved: '偏好設置保存成功',
savePreferenceSettingsFailed: '保存偏好設置失敗',
passwordUpdateSuccess: '密碼更新成功',
userCreateSuccess: '使用者建立成功',
passwordUpdateFailed: '密碼更新失敗',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import { requiredValidator } from '@/@validators'
import api from '@/api'
@@ -12,6 +12,7 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify'
import { getNavMenus } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
// 国际化
const { t } = useI18n()
@@ -19,6 +20,8 @@ const { t } = useI18n()
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 获取有权限的菜单
const navMenus = computed(() => getNavMenus(t))
@@ -42,7 +45,7 @@ const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
// 双重验证对话框
// 二次验证对话框
const mfaDialog = ref(false)
// MFA PassKey loading
@@ -74,76 +77,220 @@ const loading = ref(false)
// PassKey 登录按钮 loading
const passkeyLoading = ref(false)
// 使用PassKey登录
async function loginWithPassKey() {
// Conditional UI 的 AbortController
let conditionalAbortController: AbortController | null = null
// 手动模式的 AbortController用于防止重复点击
let manualAbortController: AbortController | null = null
// 标记当前是否有手动模式的 PassKey 请求正在进行
let isManualPassKeyActive = false
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
interface PassKeyAuthOptions {
username?: string // 可选的用户名,用于 MFA 场景
isConditional?: boolean // 是否为 Conditional UI 模式
signal?: AbortSignal // AbortController 信号
}
// PassKey API 响应类型
interface PassKeyStartResponse {
options: string // JSON 字符串
challenge: string
}
interface PassKeyFinishResponse {
access_token: string
super_user: boolean
user_id: number
user_name: string
avatar: string
level: number
permissions: Record<string, boolean>
wizard: boolean
}
async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise<PassKeyFinishResponse> {
const { username, isConditional = false, signal } = options
// 1. 开始认证流程
const startResponse = (await api.post(
'/mfa/passkey/authenticate/start',
username ? { username } : {},
)) as ApiResponse<PassKeyStartResponse>
if (!startResponse.success) {
throw new Error(startResponse.message || 'PassKey start failed')
}
const { options: optionsStr, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(optionsStr)
// 2. 调用WebAuthn API
const credentialRequestOptions: CredentialRequestOptions = {
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
}
// 如果是 Conditional UI 模式,添加 mediation 和 signal
if (isConditional) {
credentialRequestOptions.mediation = 'conditional'
if (signal) {
credentialRequestOptions.signal = signal
}
}
const credential = await navigator.credentials.get(credentialRequestOptions)
// Conditional UI 模式下,用户选择通行密钥后才显示 loading
if (isConditional) {
passkeyLoading.value = true
}
if (!credential) {
throw new Error('No credential selected')
}
// 3. 转换credential为可传输格式
const publicKeyCredential = credential as PublicKeyCredential
const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse
const credentialJSON = {
id: publicKeyCredential.id,
rawId: bufferToBase64Url(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData),
clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON),
signature: bufferToBase64Url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null,
},
}
// 4. 完成认证
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) {
throw new Error('PassKey finish failed: No access token')
}
return finishResponse
}
// 统一处理 PassKey 认证流程
async function handlePassKeyAuth(
authOptions: PassKeyAuthOptions,
setLoading: (loading: boolean) => void,
onSuccess: (response: PassKeyFinishResponse) => Promise<void>,
) {
const { isConditional = false } = authOptions
errorMessage.value = ''
passkeyLoading.value = true
// 检查浏览器环境
if (!window.PublicKeyCredential) {
if (!isConditional) {
if (!window.isSecureContext) {
errorMessage.value = t('login.passkeySecureContextRequired')
} else {
errorMessage.value = t('login.passkeyNotSupported')
}
}
return
}
// 如果是手动触发(非 Conditional UI)
if (!isConditional) {
// 取消之前的 Conditional UI 请求
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
// 取消之前的手动请求(防止重复点击)
if (manualAbortController) {
manualAbortController.abort()
}
// 创建新的 AbortController
manualAbortController = new AbortController()
// 标记手动请求为活跃状态,并立即设置 loading
isManualPassKeyActive = true
setLoading(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),
})),
},
const finishResponse = await authenticateWithPassKey({
...authOptions,
signal:
isConditional && conditionalAbortController
? conditionalAbortController.signal
: !isConditional && manualAbortController
? manualAbortController.signal
: undefined,
})
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)
await onSuccess(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') {
// Conditional UI 模式下:
// 1. 如果 loading 为 false说明错误发生在用户选择密钥之前如初始化失败、用户取消等此时应静默
// 2. 如果是 AbortError始终静默
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
console.warn('[PassKey] Conditional UI silenced error:', error)
return
}
// 手动模式下的 AbortError 也应该静默(用户重复点击导致)
if (!isConditional && error.name === 'AbortError') {
console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)
return
}
// 设置错误信息
if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else if (error.name === 'NotSupportedError') {
errorMessage.value = t('login.passkeyNotSupported')
} else if (error.message?.includes('start failed')) {
errorMessage.value = t('login.passkeyLoginStartFailed')
} else {
errorMessage.value = t('login.passkeyLoginRetry')
errorMessage.value = t('login.authFailure')
}
} finally {
passkeyLoading.value = false
// 清除 loading 状态
if (!isConditional) {
// 手动模式:始终清除,并取消手动活跃标记
isManualPassKeyActive = false
setLoading(false)
manualAbortController = null
} else {
// Conditional UI 模式:只有在没有手动请求活跃时才清除
if (!isManualPassKeyActive && passkeyLoading.value) {
passkeyLoading.value = false
}
}
}
}
// 使用PassKey登录 (支持 Conditional UI)
async function loginWithPassKey(isConditional = false) {
await handlePassKeyAuth(
{ isConditional },
val => (passkeyLoading.value = val),
async response => {
await handleLoginSuccess(response)
},
)
}
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -151,23 +298,6 @@ async function switchLanguage(locale: SupportedLocale) {
langMenu.value = false
}
// 查询是否开启双重验证
async function fetchOTP(): Promise<boolean> {
if (!form.value.username) {
isOTP.value = false
return false
}
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() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
@@ -188,7 +318,7 @@ async function subscribeForPushNotifications() {
try {
await api.post('/message/webpush/subscribe', subscription)
} catch (e) {
console.log(e)
console.error(e)
}
}
}
@@ -243,6 +373,9 @@ async function handleLoginSuccess(response: any) {
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 登录后加载用户相关的全局设置
await globalSettingsStore.loadUserSettings()
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
@@ -278,26 +411,32 @@ async function login() {
// 登录失败,显示错误提示
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')}`
return
}
switch (error.response.status) {
case 401:
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
mfaDialog.value = true
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
break
case 403:
errorMessage.value = t('login.permissionDenied')
break
case 500:
errorMessage.value = t('login.serverError')
break
default:
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`
}
} finally {
loading.value = false
@@ -314,77 +453,15 @@ function loginWithOTP() {
async function verifyWithPassKey() {
if (!form.value.username) return
mfaPasskeyLoading.value = true
errorMessage.value = ''
try {
// 1. 开始认证流程(指定用户名)
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {
username: form.value.username,
})
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
}
await handlePassKeyAuth(
{ username: form.value.username },
val => (mfaPasskeyLoading.value = val),
async response => {
// 关闭MFA对话框
mfaDialog.value = false
await handleLoginSuccess(response)
},
)
}
// 自动登录
@@ -396,6 +473,51 @@ onMounted(async () => {
// 如果token存在且保持登录状态为true则跳转到首页
if (token && remember) {
router.push('/')
return
}
// 初始化 Conditional UI 的 PassKey 自动填充
await initConditionalPasskey()
})
// 初始化 Conditional UI 的 PassKey 自动填充
async function initConditionalPasskey() {
// 检查浏览器是否支持 WebAuthn 和 Conditional UI
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
return
}
try {
const available = await PublicKeyCredential.isConditionalMediationAvailable()
if (!available) {
return
}
// 安全防御:如果已存在 controller先 abort 掉旧的,防止重复调用产生幽灵请求
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
// 创建 AbortController 用于取消请求
conditionalAbortController = new AbortController()
// 启动 Conditional UI 模式的 PassKey 认证
await loginWithPassKey(true)
} catch (error) {
console.error('[PassKey] Failed to initialize Conditional UI:', error)
}
}
// 组件卸载时清理
onUnmounted(() => {
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
if (manualAbortController) {
manualAbortController.abort()
manualAbortController = null
}
})
</script>
@@ -404,7 +526,7 @@ onMounted(async () => {
<!-- 登录页面容器 -->
<div class="relative flex min-h-screen flex-col items-center justify-center">
<!-- 登录表单 -->
<div class="auth-wrapper d-flex align-center justify-center">
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card px-7 py-3 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }"
@@ -459,7 +581,8 @@ onMounted(async () => {
:label="t('login.username')"
type="text"
name="username"
autocomplete="username"
id="username"
autocomplete="username webauthn"
:rules="[requiredValidator]"
hide-details
/>
@@ -470,7 +593,8 @@ onMounted(async () => {
v-model="form.password"
:label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'"
name="current-password"
name="password"
id="password"
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@@ -494,10 +618,10 @@ onMounted(async () => {
block
variant="tonal"
color="success"
class="mt-3"
prepend-icon="mdi-key-variant"
class="mt-3 passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="passkeyLoading"
@click="loginWithPassKey"
@click="loginWithPassKey(false)"
>
{{ t('login.loginWithPasskey') }}
</VBtn>
@@ -511,13 +635,13 @@ onMounted(async () => {
</VCard>
</div>
<!-- MFA双重验证对话框 -->
<!-- MFA二次验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle>
<VCardText>
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<!-- TOTP验证 -->
<VCard variant="tonal" class="mb-3">
<VCardText>
@@ -527,8 +651,10 @@ onMounted(async () => {
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
inputmode="numeric"
name="otp"
id="otp"
autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
@@ -547,7 +673,8 @@ onMounted(async () => {
block
variant="tonal"
color="success"
prepend-icon="mdi-key-variant"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="mfaPasskeyLoading"
@click="verifyWithPassKey"
>
@@ -556,6 +683,11 @@ onMounted(async () => {
</VCardText>
</VCard>
<!-- 错误提示 -->
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
@@ -585,4 +717,10 @@ onMounted(async () => {
backdrop-filter: blur(10px) !important;
background: rgba(var(--v-theme-surface), 0.7) !important;
}
.v-theme--light {
.passkey-btn.v-btn--variant-tonal {
color: rgb(86, 170, 0) !important;
}
}
</style>

View File

@@ -3,15 +3,29 @@ import { debounce } from 'lodash-es'
import NoDataFound from '@/components/NoDataFound.vue'
import api from '@/api'
import type { Context } from '@/api/types'
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores/global'
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
import { useToast } from 'vue-toastification'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 提示框
const toast = useToast()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 使用筛选 composable
const torrentFilter = useTorrentFilter()
// 路由参数
const route = useRoute()
@@ -39,11 +53,46 @@ const sites = route.query?.sites?.toString() ?? ''
// 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
// 视图切换中
const isViewChanging = ref(false)
// 智能推荐相关
// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态
const aiRecommendEnabled = computed(() => {
return globalSettingsStore.get('AI_RECOMMEND_ENABLED') === true
})
const isRecommending = ref(false)
const isReRecommending = ref(false) // 是否正在重新推荐
const aiRecommended = ref(false) // 是否已执行过智能推荐
const showingAiResults = ref(false) // 是否正在显示智能推荐结果
const originalDataList = ref<Array<Context>>([]) // 原始搜索结果
const aiRecommendedList = ref<Array<Context>>([]) // 智能推荐结果
const savedFilterState = ref<FilterState | null>(null) // 保存的筛选状态
const aiStatusChecked = ref(false) // 是否已完成首次AI状态检查
let aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状态检查定时器
// 数据列表
const dataList = ref<Array<Context>>([])
// 是否有搜索标签
const hasSearchTags = computed(() => {
return !!(keyword || title || year || season)
})
// 是否启用筛选栏动画
const enableFilterAnimation = ref(true)
// 原始数据列表(未筛选)
const rawDataList = ref<Array<Context>>([])
// 筛选后的数据列表(用于行视图)
const filteredRowDataList = ref<Array<Context>>([])
// 筛选后的数据列表(用于卡片视图)
interface SearchTorrent extends Context {
more?: Array<Context>
}
const filteredCardDataList = ref<Array<SearchTorrent>>([])
// 使用无限滚动 composable行视图
const rowScroll = useInfiniteScroll(filteredRowDataList)
// 使用无限滚动 composable卡片视图
const cardScroll = useInfiniteScroll(filteredCardDataList)
// 是否刷新过
const isRefreshed = ref(false)
@@ -66,6 +115,49 @@ const errorTitle = ref(t('resource.noData'))
// 错误描述
const errorDescription = ref(t('resource.noResourceFound'))
// 监听筛选条件变化,重新筛选数据
watch(
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
() => {
applyFilter()
},
{ deep: true },
)
// 应用筛选
function applyFilter() {
if (viewType.value === 'row') {
filteredRowDataList.value = torrentFilter.filterRowData(rawDataList.value)
} else {
filteredCardDataList.value = torrentFilter.filterCardData(rawDataList.value)
}
}
// 处理筛选表单更新
function handleFilterFormUpdate(key: string, values: string[]) {
torrentFilter.filterForm[key] = values
}
// 处理全选
function handleSelectAll(key: string) {
torrentFilter.selectAll(key)
}
// 处理清除筛选
function handleClearFilter(key: string) {
torrentFilter.clearFilter(key)
}
// 处理清除所有筛选
function handleClearAllFilters() {
torrentFilter.clearAllFilters()
}
// 处理移除单个筛选
function handleRemoveFilter(key: string, value: string) {
torrentFilter.removeFilter(key, value)
}
// 添加安全超时,确保进度条不会永远卡住
const watchProgressValue = watch(
progressValue,
@@ -116,29 +208,30 @@ function stopLoadingProgress() {
setTimeout(() => {
progressValue.value = 0
progressEnabled.value = false
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
}, 1500)
}
// 设置视图类型
function changeViewType(newType: string) {
if (viewType.value !== newType) {
isViewChanging.value = true
// 立即更新视图类型
viewType.value = newType
localStorage.setItem('MPTorrentsViewType', newType)
// 模拟视图切换的加载过程
setTimeout(() => {
isViewChanging.value = false
}, 600)
// 切换视图时重新应用筛选
applyFilter()
}
}
// 获取搜索列表数据
async function fetchData() {
try {
enableFilterAnimation.value = true
if (!keyword) {
// 查询上次搜索结果
dataList.value = await api.get('search/last')
const results = await api.get('search/last')
rawDataList.value = (results as unknown as Context[]) || []
originalDataList.value = (results as unknown as Context[]) || []
} else {
startLoadingProgress()
let result: { [key: string]: any }
@@ -164,7 +257,12 @@ async function fetchData() {
})
}
if (result && result.success) {
dataList.value = result.data || []
rawDataList.value = result.data || []
originalDataList.value = result.data || []
// 重置智能推荐状态
aiRecommended.value = false
showingAiResults.value = false
aiRecommendedList.value = []
} else if (result && result.message) {
errorDescription.value = result.message
}
@@ -172,6 +270,8 @@ async function fetchData() {
// 从浏览器历史中删除当前搜索
window.history.replaceState(null, '', window.location.pathname)
}
// 应用筛选
applyFilter()
// 标记已刷新
isRefreshed.value = true
} catch (error) {
@@ -182,14 +282,280 @@ async function fetchData() {
}
}
// 切换到智能推荐结果(自动保存筛选条件)
async function switchToAiResults() {
if (showingAiResults.value) {
console.log('已经在显示AI结果')
return
}
// 保存当前筛选状态
savedFilterState.value = torrentFilter.getFilterState()
// 切换数据
rawDataList.value = [...aiRecommendedList.value]
showingAiResults.value = true
console.log('已切换到智能推荐结果')
// 清空智能推荐筛选条件
torrentFilter.clearAllFilters()
// 重新应用筛选
applyFilter()
}
// 切换回原始结果(自动还原筛选条件)
async function switchToOriginalResults() {
if (!showingAiResults.value) {
console.log('已经在显示原始结果')
return
}
// 切换数据
rawDataList.value = [...originalDataList.value]
showingAiResults.value = false
console.log('已切换到原始结果')
// 恢复原始筛选条件
if (savedFilterState.value) {
torrentFilter.setFilterState(savedFilterState.value)
}
// 重新应用筛选
applyFilter()
}
// 智能推荐/切换结果
async function toggleAiRecommend() {
// 如果当前显示AI结果则切换回原始结果
if (showingAiResults.value) {
await switchToOriginalResults()
return
}
// 如果已经有智能推荐结果,直接切换
if (aiRecommended.value && aiRecommendedList.value.length > 0) {
await switchToAiResults()
return
}
// 否则启动智能推荐
// 保存当前筛选状态,以便切换回原始结果时恢复
savedFilterState.value = torrentFilter.getFilterState()
console.log('首次智能推荐,已保存筛选状态:', savedFilterState.value)
startAiRecommend()
}
// 启动智能推荐(开始轮询)
async function startAiRecommend(force: boolean = false) {
isRecommending.value = true
console.log('启动智能推荐', force ? '(强制)' : '')
// 首次或强制时,先发送一个启动任务的请求
await sendInitialRequest(force)
// 然后开始 check_only 轮询
startAiRecommendPolling()
}
// 发送初始请求以启动智能推荐任务
async function sendInitialRequest(force: boolean = false) {
try {
const requestBody: any = {}
// 检查是否有筛选条件
const hasFilters = torrentFilter.hasActiveFilters()
if (hasFilters) {
const indices = torrentFilter.getFilteredIndices()
if (indices && indices.length > 0) {
requestBody.filtered_indices = indices
}
}
// 如果是强制模式,添加 force 标志
if (force) {
requestBody.force = true
}
console.log('发送初始请求以启动任务', force ? '(force)' : '')
await api.post('search/recommend', requestBody)
} catch (error) {
console.error('发送初始请求失败:', error)
isRecommending.value = false
}
}
// 开始轮询智能推荐(使用 check_only 模式)
function startAiRecommendPolling() {
// 停止可能存在的轮询
stopAiRecommendPolling()
// 立即发送一次 check_only 请求
pollAiRecommend()
// 然后每2秒轮询一次check_only
aiStatusCheckInterval = setInterval(() => {
pollAiRecommend()
}, 2000)
}
// 轮询智能推荐状态(始终使用 check_only 模式)
async function pollAiRecommend() {
try {
const result: { [key: string]: any } = await api.post('search/recommend', {
check_only: true,
})
const { success, data } = result
const status = data?.status
// 正在运行,继续轮询
if (success && status === 'running') {
console.log('AI推理中...')
return
}
// 其他所有状态均停止轮询
stopAiRecommendPolling()
isRecommending.value = false
if (success && status === 'completed') {
// 推荐完成
if (data.results?.length > 0) {
// 加载智能推荐结果
loadAiRecommendedResults(data.results)
// 自动切换到智能推荐结果(会自动保存筛选条件)
await switchToAiResults()
}
} else if (success && status === 'disabled') {
// 功能停用
console.error('AI功能未启用')
} else {
// 错误情况status === 'error' 或 success 为 false
const errMsg = result.message || data?.error || data?.message || 'Unknown error'
console.error('智能推荐错误:', errMsg)
toast.error(`${t('resource.aiRecommendError')}: ${errMsg}`)
}
} catch (error) {
console.error('智能推荐轮询失败:', error)
stopAiRecommendPolling()
isRecommending.value = false
}
}
// 停止轮询智能推荐
function stopAiRecommendPolling() {
if (aiStatusCheckInterval) {
clearInterval(aiStatusCheckInterval)
aiStatusCheckInterval = null
console.log('停止智能推荐轮询')
}
}
// 加载智能推荐结果(从索引数组提取数据)
function loadAiRecommendedResults(indices: number[]) {
if (!indices || indices.length === 0) {
return
}
// 从原始数据中根据索引提取结果
aiRecommendedList.value = indices.map((index: number) => originalDataList.value[index]).filter(Boolean)
aiRecommended.value = true
console.log(`加载智能推荐结果: ${aiRecommendedList.value.length}`)
}
// 重新推荐
async function reRecommend() {
try {
isReRecommending.value = true
console.log('重新推荐:重置状态')
// 重置状态
aiRecommended.value = false
aiRecommendedList.value = []
// 切换回原始结果(会自动还原筛选条件)
await switchToOriginalResults()
// 等待筛选数据还原完成nextTick确保DOM更新完成
await nextTick()
// 再等待一个微任务,确保筛选逻辑完全执行
await new Promise(resolve => setTimeout(resolve, 0))
// 重新启动智能推荐(带 force 标志)
startAiRecommend(true)
} catch (error) {
console.error('重新推荐失败:', error)
} finally {
isReRecommending.value = false
}
}
// 检查智能推荐状态(页面初始化时调用一次)
async function checkAiRecommendStatus() {
try {
// 首次检查时使用 check_only 模式
const result: { [key: string]: any } = await api.post('search/recommend', {
check_only: true,
})
const { success, data } = result
const status = data?.status
// 只要有数据且状态不是disabled就标记已检查允许重试
if (data && status !== 'disabled') {
aiStatusChecked.value = true
}
if (success && data) {
const { results } = data
// 如果有完成的结果,加载它
if (status === 'completed' && results && results.length > 0) {
loadAiRecommendedResults(results)
}
// 如果正在运行,启动轮询
if (status === 'running') {
isRecommending.value = true
startAiRecommendPolling()
}
}
} catch (error) {
console.error('检查AI状态失败:', error)
}
}
// 计算当前显示的数据是否有数据
const hasData = computed(() => {
if (viewType.value === 'row') {
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
} else {
return filteredCardDataList.value.length > 0 || rawDataList.value.length > 0
}
})
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
// 使用 watchEffect 确保计算属性变化时立即响应
watchEffect(() => {
// 需要满足AI 功能启用、数据已加载、尚未检查
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
checkAiRecommendStatus()
}
})
// 加载数据
onMounted(() => {
onMounted(async () => {
fetchData()
})
// 卸载时停止加载进度
// 卸载时停止轮询
onUnmounted(() => {
stopLoadingProgress()
stopAiRecommendPolling()
})
</script>
@@ -215,9 +581,10 @@ onUnmounted(() => {
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
<div class="search-info-container">
<div class="search-title text-moviepilot">
{{ t('resource.searchResults') }}
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
</div>
<div class="search-tags d-flex flex-wrap mt-1">
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
{{ t('resource.keyword') }}: {{ keyword }}
</VChip>
@@ -232,10 +599,61 @@ onUnmounted(() => {
</VChip>
</div>
</div>
<VSpacer />
<!-- AI操作按钮组 -->
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
<div class="ai-toggle-buttons">
<VBtn
variant="text"
size="small"
rounded="0"
@click="toggleAiRecommend"
:disabled="isRecommending || !aiStatusChecked"
height="44"
class="ps-4 pe-3 ai-recommend-btn"
:class="{ 'ai-active': showingAiResults }"
>
<template #prepend>
<VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" />
</template>
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }">
{{ t('resource.aiRecommend') }}
</span>
</VBtn>
<VExpandXTransition>
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
<VBtn
variant="text"
size="small"
rounded="0"
:disabled="isRecommending || !aiStatusChecked"
@click="reRecommend"
height="44"
min-width="38"
class="px-0"
>
<VIcon
:icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
size="18"
class="ai-refresh-icon"
/>
<VTooltip activator="parent" location="top">
{{ t('resource.reRecommend') }}
</VTooltip>
</VBtn>
</div>
</VExpandXTransition>
</div>
</div>
<!-- 重新设计的视图切换按钮 -->
<div class="view-toggle-container">
<div class="view-toggle-buttons">
<div class="active-indicator" :class="viewType"></div>
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
</button>
@@ -246,39 +664,91 @@ onUnmounted(() => {
</div>
</VCard>
<!-- 视图切换加载状态 -->
<VFadeTransition>
<div v-if="isRefreshed && isViewChanging" class="view-changing-container rounded-lg">
<div class="view-changing-content">
<div class="pulse-loader">
<div class="pulse-circle"></div>
<div class="pulse-circle"></div>
<div class="pulse-circle"></div>
</div>
<div class="view-changing-text">{{ t('resource.switchingView') }}</div>
</div>
</div>
</VFadeTransition>
<!-- 搜索结果 -->
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
<!-- 卡片视图模式 -->
<VFadeTransition>
<div>
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
</div>
</VFadeTransition>
<div v-if="isRefreshed && hasData" class="search-results-container">
<!-- 筛选栏 -->
<TorrentFilterBar
:filter-form="torrentFilter.filterForm"
:filter-options="torrentFilter.filterOptions"
:sort-field="torrentFilter.sortField.value"
:sort-type="torrentFilter.sortType.value"
:total-filtered-count="torrentFilter.totalFilteredCount.value"
:filter-titles="torrentFilter.filterTitles"
:sort-titles="torrentFilter.sortTitles"
:enable-animation="enableFilterAnimation"
@update:sort-field="val => (torrentFilter.sortField.value = val)"
@update:sort-type="val => (torrentFilter.sortType.value = val)"
@update:filter-form="handleFilterFormUpdate"
@select-all="handleSelectAll"
@clear-filter="handleClearFilter"
@clear-all-filters="handleClearAllFilters"
@remove-filter="handleRemoveFilter"
/>
<!-- 列表视图模式 -->
<VFadeTransition>
<div>
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
<!-- 视图切换区域 -->
<VFadeTransition mode="out-in">
<!-- 卡片视图模式 -->
<div v-if="viewType === 'card'" key="card">
<!-- 资源列表 -->
<VInfiniteScroll
mode="intersect"
side="end"
:items="cardScroll.displayDataList.value"
class="overflow-visible"
@load="cardScroll.loadMore"
>
<template #loading />
<template #empty />
<div class="grid gap-4 grid-torrent-card items-start">
<TorrentCard
v-for="item in cardScroll.displayDataList.value"
:key="`${item.torrent_info.page_url}`"
:torrent="item"
:more="item.more"
/>
</div>
</VInfiniteScroll>
<!-- 无结果时显示 -->
<div v-if="cardScroll.displayDataList.value.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
</div>
<!-- 列表视图模式 -->
<div v-else-if="viewType === 'row'" key="row">
<VCard class="resource-list-container">
<!-- 无结果时显示 -->
<div v-if="rowScroll.displayDataList.value.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
<!-- 资源列表 -->
<VInfiniteScroll
v-else
mode="intersect"
side="end"
:items="rowScroll.displayDataList.value"
class="resource-list overflow-visible"
@load="rowScroll.loadMore"
>
<template #loading />
<template #empty />
<div
v-for="(item, index) in rowScroll.displayDataList.value"
:key="`${item.torrent_info?.enclosure || ''}-${index}`"
>
<TorrentItem :torrent="item" />
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
</div>
</VInfiniteScroll>
</VCard>
</div>
</VFadeTransition>
</div>
<!-- 无数据显示 -->
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
<div v-else-if="isRefreshed" class="d-flex flex-column align-center justify-center py-8">
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
{{ t('resource.backToHome') }}
@@ -343,8 +813,8 @@ onUnmounted(() => {
/* 精简标题栏样式 */
.search-header {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 12px;
padding-inline: 16px;
padding-block: 8px;
padding-inline: 12px;
}
.search-info-container {
@@ -374,6 +844,25 @@ onUnmounted(() => {
padding: 4px;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
position: relative;
isolation: isolate; /* Create new stacking context */
}
.active-indicator {
position: absolute;
top: 4px;
left: 4px;
width: 40px;
height: 36px;
background-color: rgb(var(--v-theme-surface));
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.active-indicator.row {
transform: translateX(40px);
}
.view-toggle-btn {
@@ -381,79 +870,87 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
block-size: 36px;
cursor: pointer;
inline-size: 40px;
transition: all 0.2s ease;
}
.view-toggle-btn.active {
box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
z-index: 2; /* Sit on top of indicator */
position: relative;
}
.view-toggle-btn:hover:not(.active) {
background-color: rgba(var(--v-theme-primary), 0.05);
border-radius: 6px;
}
/* 视图切换加载状态 */
.view-changing-container {
position: absolute;
z-index: 10;
/* AI按钮组样式 */
.ai-toggle-container {
position: relative;
}
.ai-toggle-buttons {
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
inset: 0;
padding: 0;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
overflow: hidden;
height: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
}
.view-changing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
.ai-recommend-btn {
transition: all 0.3s ease;
margin: 0;
height: 100% !important;
}
.pulse-loader {
display: flex;
gap: 8px;
/* 仅为激活的按钮添加背景 */
.ai-recommend-btn.ai-active {
background-color: rgba(var(--v-theme-primary), 0.15);
z-index: 1;
}
.pulse-circle {
border-radius: 50%;
animation: pulse 1.2s ease-in-out infinite;
background-color: rgb(var(--v-theme-primary));
block-size: 12px;
inline-size: 12px;
/* 图标基础样式 */
.ai-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateZ(0);
}
.pulse-circle:nth-child(2) {
animation-delay: 0.2s;
}
.pulse-circle:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.view-changing-text {
/* 激活状态图标:变色 + 辉光 */
.ai-icon-active {
color: rgb(var(--v-theme-primary));
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 1px;
filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));
}
/* 文字基础样式 */
.ai-text {
color: rgba(var(--v-theme-on-surface), 0.6);
font-weight: 600; /* 保持一致的字重防止位移 */
font-size: 0.85rem;
transition: color 0.3s ease;
transform: translateZ(0);
}
/* 激活状态文字 */
.ai-text-active {
color: rgb(var(--v-theme-primary));
}
/* 刷新图标样式 */
.ai-refresh-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
transition: color 0.3s ease;
}
.ai-divider {
width: 0; /* 宽度设为0不占用空间 */
height: 20px;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
flex-shrink: 0;
transition: opacity 0.3s ease;
z-index: 0;
}
.search-results-container {
@@ -461,27 +958,52 @@ onUnmounted(() => {
min-block-size: 50vh;
}
/* 卡片网格布局 */
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
/* 列表视图样式 */
.resource-list-container {
padding: 8px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 12px;
}
.resource-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 无结果提示 */
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-block-size: 300px;
}
@media (width <= 600px) {
.search-header {
padding-block: 8px;
padding-block: 6px;
padding-inline: 12px;
}
.search-title {
font-size: 1.2rem;
font-size: 1.1rem;
white-space: nowrap;
}
.search-info-container {
overflow: hidden;
flex: 1;
gap: 8px;
min-inline-size: 0;
}
.search-tags {
flex-wrap: nowrap;
margin-inline-end: 8px;
margin-inline-end: 4px;
overflow-x: auto;
scrollbar-width: none;
}
@@ -498,9 +1020,43 @@ onUnmounted(() => {
padding: 2px;
}
.active-indicator {
top: 2px;
left: 2px;
width: 36px;
height: 32px;
}
.active-indicator.row {
transform: translateX(36px);
}
.view-toggle-btn {
block-size: 32px;
inline-size: 36px;
}
.ai-toggle-buttons {
height: 36px;
}
.ai-text {
font-size: 0.8rem;
}
.ai-recommend-btn,
.ai-toggle-buttons .v-btn {
height: 36px !important;
min-width: unset !important;
}
.ai-recommend-btn {
padding-inline-start: 12px !important;
padding-inline-end: 8px !important;
}
.ai-toggle-buttons .v-btn:last-child {
min-width: 32px !important;
}
}
</style>

View File

@@ -23,27 +23,10 @@ cleanupOutdatedCaches()
// 预缓存并路由
precacheAndRoute(self.__WB_MANIFEST)
// 变量记录是否为更新安装(兼容旧版前端监听逻辑)
let isUpdate = false
// 监听安装事件
self.addEventListener('install', () => {
// 强制等待中的 Service Worker 立即激活
self.skipWaiting()
// 检查是否是更新(兼容旧版前端监听逻辑)
if (self.registration.active) {
isUpdate = true
// 通知客户端发现新版本
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SW_VERSION_DETECTED',
version: CACHE_VERSION,
})
})
})
}
})
// 监听激活事件
@@ -52,19 +35,8 @@ self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
await self.clients.claim()
// 清理旧版本的运行时缓存
await cleanupRuntimeCaches(true)
// 如果是更新,则通知客户端刷新页面(兼容旧版前端监听逻辑)
if (isUpdate) {
const clients = await self.clients.matchAll({ type: 'window' })
clients.forEach(client => {
client.postMessage({
type: 'SW_RELOAD_PAGE',
})
})
}
})(),
)
})
@@ -164,10 +136,13 @@ registerRoute(
({ url, request }) =>
url.pathname.includes('/api/v1/') &&
request.method === 'GET' &&
!url.pathname.includes('/api/v1/system/message') && // 排除 SSE 长连接
!url.pathname.includes('/api/v1/common/message') && // 排除通用消息
!url.pathname.includes('/api/v1/message/') && // 排除所有消息类接口
!url.pathname.includes('/api/v1/system/global'), // 排除global接口
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5,

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type { globalSettingsState } from '@/stores/types'
import { fetchGlobalSettings } from '@/utils/globalSetting'
import { useVersionChecker } from '@/composables/useVersionChecker'
import api from '@/api'
export const useGlobalSettingsStore = defineStore('globalSettings', {
state: (): globalSettingsState => ({
@@ -32,6 +33,19 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
}
},
// 登录后加载用户相关设置
async loadUserSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global/user')
if (result.success && result.data) {
// 合并用户设置到现有数据
this.data = { ...this.data, ...result.data }
}
} catch (error) {
console.error('Failed to load user settings', error)
}
},
setData(data: { [key: string]: any }) {
this.data = data
this.initialized = true

View File

@@ -150,7 +150,8 @@ async function loadSeasonEpisodes(season: number) {
// 加载季集信息
if (seasonEpisodesInfo.value[season]) return
try {
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`)
const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
seasonEpisodesInfo.value[season] = result || []
} catch (error) {
console.error(error)

View File

@@ -46,12 +46,37 @@ const redoTargetStorage = ref<string>()
// 已选中的数据
const selected = ref<TransferHistory[]>([])
const getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0);
function sortByTitle(a: TransferHistory, b: TransferHistory) {
if (a.type !== b.type) {
return (a.type ?? '').localeCompare(b.type ?? '');
}
if (a.title !== b.title) {
return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase());
}
if (a.type === '电视剧') {
if (a.seasons !== b.seasons) {
return getNum(a.seasons) - getNum(b.seasons);
}
if (a.episodes !== b.episodes) {
return getNum(a.episodes) - getNum(b.episodes);
}
}
return 0
}
function sortBySourceSize(a: TransferHistory, b: TransferHistory) {
return (a.src_fileitem?.size ?? 0) - (b.src_fileitem?.size ?? 0)
}
// 表头
const headers = [
{
title: t('transferHistory.titleColumn'),
key: 'title',
sortable: true,
sortRaw: sortByTitle,
},
{
title: t('transferHistory.pathColumn'),
@@ -67,6 +92,7 @@ const headers = [
title: t('transferHistory.sizeColumn'),
key: 'size',
sortable: true,
sortRaw: sortBySourceSize,
},
{
title: t('transferHistory.dateColumn'),
@@ -91,6 +117,7 @@ const groupHeaders = [
title: t('transferHistory.seasonEpisode'),
key: 'title',
sortable: true,
sortRaw: sortByTitle,
},
{
title: t('transferHistory.pathColumn'),
@@ -106,6 +133,7 @@ const groupHeaders = [
title: t('transferHistory.sizeColumn'),
key: 'size',
sortable: true,
sortRaw: sortBySourceSize,
},
{
title: t('transferHistory.dateColumn'),

View File

@@ -8,6 +8,7 @@ import { TransferDirectoryConf, StorageConf } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { storageAttributes } from '@/api/constants'
@@ -28,6 +29,9 @@ const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 分类编辑对话框
const categoryDialog = ref(false)
// 数据源
const sourceItems = [
{ 'title': 'TheMovieDb', 'value': 'themoviedb' },
@@ -292,7 +296,12 @@ onMounted(() => {
:directory="element"
:categories="mediaCategories"
:storages="storages"
@update:modelValue="(value: any) => {element.download_path = value?.download; element.library_path = value?.library}"
@update:modelValue="
(value: any) => {
element.download_path = value?.download
element.library_path = value?.library
}
"
@close="removeDirectory(element)"
/>
</template>
@@ -304,9 +313,13 @@ onMounted(() => {
<VBtn type="submit" @click="saveDirectories" prepend-icon="mdi-content-save">
{{ t('common.save') }}
</VBtn>
<VBtn color="success" variant="tonal" @click="addDirectory">
<VBtn color="success" variant="tonal" @click="addDirectory" class="me-2">
<VIcon icon="mdi-plus" />
</VBtn>
<VSpacer />
<VBtn color="info" variant="tonal" prepend-icon="mdi-shape-plus" @click="categoryDialog = true">
{{ t('setting.category.title') }}
</VBtn>
</div>
</VForm>
</VCardText>
@@ -370,4 +383,12 @@ onMounted(() => {
</VRow>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('setting.system.reloading')" />
<!-- 分类对话框 -->
<CategoryEditDialog
v-if="categoryDialog"
v-model="categoryDialog"
:categories="mediaCategories"
@close="categoryDialog = false"
@done="loadMediaCategories"
/>
</template>

View File

@@ -37,6 +37,10 @@ const SystemSettings = ref<any>({
LLM_MODEL: 'deepseek-chat',
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50,
LLM_MAX_CONTEXT_TOKENS: 64,
},
// 高级系统设置
Advanced: {
@@ -76,6 +80,7 @@ const SystemSettings = ref<any>({
// 实验室
PLUGIN_AUTO_RELOAD: false,
ENCODING_DETECTION_PERFORMANCE_MODE: true,
TRANSFER_THREADS: 1,
},
})
@@ -643,7 +648,7 @@ onDeactivated(() => {
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
@@ -651,6 +656,14 @@ onDeactivated(() => {
persistent-hint
/>
</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>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_PROVIDER"
@@ -709,11 +722,50 @@ onDeactivated(() => {
</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')"
<VTextField
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
:label="t('setting.system.llmMaxContextTokens')"
:hint="t('setting.system.llmMaxContextTokensHint')"
persistent-hint
type="number"
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
<VSwitch
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
:label="t('setting.system.aiRecommendEnabled')"
:hint="t('setting.system.aiRecommendEnabledHint')"
persistent-hint
/>
</VCol>
<VCol
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
cols="12"
md="6"
>
<VTextarea
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
:label="t('setting.system.aiRecommendUserPreference')"
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
persistent-hint
rows="1"
auto-grow
prepend-inner-icon="mdi-account-heart"
/>
</VCol>
<VCol
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
cols="12"
md="6"
>
<VTextField
v-model.number="SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS"
:label="t('setting.system.aiRecommendMaxItems')"
:hint="t('setting.system.aiRecommendMaxItemsHint')"
persistent-hint
type="number"
prepend-inner-icon="mdi-format-list-numbered"
/>
</VCol>
</VRow>
@@ -1377,7 +1429,10 @@ onDeactivated(() => {
min="1"
type="number"
:suffix="t('setting.system.mb')"
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'), (v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin')]"
:rules="[
(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'),
(v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin'),
]"
prepend-inner-icon="mdi-file-document"
/>
</VCol>
@@ -1389,7 +1444,10 @@ onDeactivated(() => {
persistent-hint
min="1"
type="number"
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'), (v: any) => v >= 1 || t('setting.system.logBackupCountMin')]"
:rules="[
(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'),
(v: any) => v >= 1 || t('setting.system.logBackupCountMin'),
]"
prepend-inner-icon="mdi-backup-restore"
/>
</VCol>
@@ -1424,6 +1482,17 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="SystemSettings.Advanced.TRANSFER_THREADS"
:label="t('setting.system.transferThreads')"
:hint="t('setting.system.transferThreadsHint')"
persistent-hint
type="number"
min="1"
prepend-inner-icon="mdi-swap-horizontal"
/>
</VCol>
</VRow>
</div>
</VWindowItem>

View File

@@ -71,7 +71,8 @@ async function eventsHander(subscribe: Subscribe) {
}
} else {
// 调用API查询集信息
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`)
const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`, params ? { params } : undefined)
interface EpisodeInfo {
title: string

View File

@@ -1,918 +0,0 @@
<script lang="ts" setup>
import { cloneDeepWith } from 'lodash-es'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
interface SearchTorrent extends Context {
more?: Array<Context>
}
// 定义输入参数
const props = defineProps({
// 数据列表
items: Array as PropType<SearchTorrent[]>,
})
// 过滤表单
const filterForm: Record<string, string[]> = reactive({
// 站点
site: [] as string[],
// 季
season: [] as string[],
// 制作组
releaseGroup: [] as string[],
// 视频编码
videoCode: [] as string[],
// 促销状态
freeState: [] as string[],
// 质量
edition: [] as string[],
// 分辨率
resolution: [] as string[],
})
// 排序选项
const sortField = ref('default')
// 降序
const sortType = ref<'asc' | 'desc'>('desc')
const sortTitles: Record<string, string> = {
default: t('torrent.sortDefault'),
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 过滤项映射
const filterTitles: Record<string, string> = {
site: t('torrent.filterSite'),
season: t('torrent.filterSeason'),
freeState: t('torrent.filterFreeState'),
videoCode: t('torrent.filterVideoCode'),
edition: t('torrent.filterEdition'),
resolution: t('torrent.filterResolution'),
releaseGroup: t('torrent.filterReleaseGroup'),
}
// 统一存储过滤选项
const filterOptions: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
videoCode: [] as string[],
releaseGroup: [] as string[],
})
// 完整的数据列表
let dataList: SearchTorrent[]
// 显示用的数据列表
const displayDataList = ref<Array<SearchTorrent>>([])
// 分组后的数据列表
const groupedDataList = ref<Map<string, Context[]>>()
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 初始化过滤选项
function initOptions(data: Context) {
const { torrent_info, meta_info } = data
const optionValue = (options: Array<string>, value: string | undefined) => {
if (value && !options.includes(value)) {
options.push(value)
// 如果是season选项立即进行排序
if (options === filterOptions.season) {
sortSeasonOptions()
}
}
}
optionValue(filterOptions.site, torrent_info?.site_name)
optionValue(filterOptions.season, meta_info?.season_episode)
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
optionValue(filterOptions.videoCode, meta_info?.video_encode)
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
optionValue(filterOptions.edition, meta_info?.edition)
optionValue(filterOptions.resolution, meta_info?.resource_pix)
}
// 直接对季集选项进行排序的函数
function sortSeasonOptions() {
if (filterOptions.season.length <= 1) {
return // 不需要排序
}
// 预解析所有选项
const parsedOptions = filterOptions.season.map((option, index) => {
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
if (!match) {
// 格式不符合规范的放到最后
return {
original: option,
seasonNum: 0,
episodeNum: 0,
maxEpisodeNum: 0,
isWholeSeason: false,
index,
}
}
const seasonNum = parseInt(match[1], 10)
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
const isWholeSeason = !match[3] // 没有E部分表示整季
return {
original: option,
seasonNum,
episodeNum,
maxEpisodeNum,
isWholeSeason,
index,
}
})
// 先对所有项进行分类
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
// 对整季按季号降序排序
wholeSeasons.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum // 季号降序
}
return a.index - b.index // 相同季号按原始索引
})
// 对单集先按季号降序排序,季号相同时按集号降序排序
episodes.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum // 季号降序
}
// 使用最大集号进行排序 (对于范围如 E01-E06)
const aMaxEp = a.maxEpisodeNum || a.episodeNum
const bMaxEp = b.maxEpisodeNum || b.episodeNum
if (aMaxEp !== bMaxEp) {
return bMaxEp - aMaxEp // 集号降序
}
// 如果最大集号相同,再比较起始集号
if (a.episodeNum !== b.episodeNum) {
return b.episodeNum - a.episodeNum
}
return a.index - b.index // 都相同时按原始索引
})
// 合并结果:整季在前,单集在后
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
// 直接更新 filterOptions.season
filterOptions.season = sortedOptions
}
// 计算分组后的列表
onMounted(() => {
// 数据分组
const groupMap = new Map<string, Context[]>()
// 遍历数据
props.items?.forEach(item => {
const { torrent_info, meta_info } = item
// init options
initOptions(item)
// group data
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
if (groupMap.has(key)) {
// 已入库相同标题和大小的分组,将当前上下文信息添加到分组中
const group = groupMap.get(key)
group?.push(item)
} else {
// 创建新的分组,并将当前上下文信息添加到分组中
groupMap.set(key, [item])
}
})
groupedDataList.value = groupMap
// 确保季集选项排序
if (filterOptions.season.length > 0) {
sortSeasonOptions()
}
})
// 修改watch监听同时监听排序字段的变化
watch([filterForm, groupedDataList, sortField, sortType], filterData)
function filterData() {
// 清空列表
dataList = []
displayDataList.value = []
// 匹配过滤函数filter中有任一值包含value则返回true
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 筛选数据
const filteredData: SearchTorrent[] = []
groupedDataList.value?.forEach(value => {
if (value.length > 0) {
const matchData = value.filter(data => {
const { meta_info, torrent_info } = data
// 季、制作组、视频编码
return (
// 站点过滤
match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤
match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤
match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤
match(filterForm.releaseGroup, meta_info.resource_team) &&
// 视频编码过滤
match(filterForm.videoCode, meta_info.video_encode) &&
// 分辨率过滤
match(filterForm.resolution, meta_info.resource_pix) &&
// 质量过滤
match(filterForm.edition, meta_info.edition)
)
})
if (matchData.length > 0) {
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1)
filteredData.push(firstData)
}
}
})
// 排序数据
if (sortField.value !== 'default') {
filteredData.sort((a, b) => {
if (sortType.value === 'desc') {
if (sortField.value === 'site') {
// 按站点名称排序
return (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
} else if (sortField.value === 'size') {
// 按文件大小排序(降序)
return (Number(b.torrent_info.size) || 0) - (Number(a.torrent_info.size) || 0)
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(降序,最新的在前)
return new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime()
}
} else {
if (sortField.value === 'site') {
// 按站点名称排序
return (b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || '')
} else if (sortField.value === 'size') {
// 按文件大小排序(降序)
return (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(升序,最旧的在前)
return new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
}
}
return 0
})
}
// 显示前20个
displayDataList.value = filteredData.slice(0, 20)
// 保存剩余数据
dataList = filteredData.slice(20)
}
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 开关筛选菜单
function toggleFilterMenu(key: string) {
if (currentFilter.value === key && filterMenuOpen.value) {
filterMenuOpen.value = false
} else {
currentFilter.value = key
filterMenuOpen.value = true
// 如果是季集选项,确保已排序
if (key === 'season' && filterOptions.season.length > 0) {
sortSeasonOptions()
}
}
}
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 清除所有过滤条件
function clearAllFilters() {
for (const key in filterForm) {
filterForm[key] = []
}
}
// 清除某个过滤项
function clearFilter(key: string) {
filterForm[key] = []
}
// 全选某个过滤项
function selectAll(key: string) {
// 不再需要特殊处理季集选项
filterForm[key] = [...filterOptions[key]]
}
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in filterForm) {
count += filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
if (filterForm[key].length > 0) {
filters[key] = [...filterForm[key]]
}
}
return filters
})
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
const index = filterForm[key].indexOf(value)
if (index !== -1) {
filterForm[key].splice(index, 1)
}
}
function loadMore({ done }: { done: any }) {
// 从 dataList 中获取最前面的 20 个元素
const itemsToMove = dataList.splice(0, 20)
displayDataList.value.push(...itemsToMove)
done('ok')
}
// 处理图标点击
const handleSortIconClick = () => {
// 切换排序方向
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
</script>
<template>
<div class="search-header d-none d-sm-flex mb-3">
<!-- 页面头部和筛选栏 -->
<VCard class="view-header rounded-xl">
<div class="d-flex align-center flex-wrap pa-3">
<VChip color="primary" variant="elevated" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
<div class="sort-container me-4">
<VSelect
v-model="sortField"
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
item-title="title"
item-value="value"
density="compact"
hide-details
class="sort-select"
variant="plain"
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选按钮组 -->
<div class="filter-bar">
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
>
{{ title }}
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ filterForm[key].length }}
</VChip>
<VMenu activator="parent" :close-on-content-click="false" scrim>
<VCard max-width="25rem">
<VCardText class="filter-menu-content">
<div class="flex justify-between">
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</div>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn ms-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
variant="tonal"
size="small"
color="error"
@click="clearAllFilters"
class="filter-btn"
prepend-icon="mdi-close-circle-outline"
rounded="pill"
>
{{ t('torrent.clearFilters') }}
</VBtn>
</div>
</div>
<!-- 已选择的过滤项显示 -->
<div v-if="getFilterCount > 0" class="selected-filters pa-3 pt-0">
<div class="d-flex flex-wrap align-center">
<template v-for="(values, key) in getSelectedFilters" :key="key">
<VChip
v-for="(value, index) in values"
:key="`${key}-${index}`"
color="primary"
size="small"
closable
variant="elevated"
class="me-1 mt-2 filter-tag"
@click:close="removeFilter(key, value)"
>
<VIcon size="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
</VChip>
</template>
</div>
</div>
</VCard>
</div>
<!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3">
<!-- 移动端头部 -->
<div class="view-header">
<div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100 mb-2">
<VChip
color="primary"
variant="elevated"
size="small"
class="search-count me-auto"
prepend-icon="mdi-magnify"
>
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
<VSelect
v-model="sortField"
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
item-title="title"
item-value="value"
density="compact"
hide-details
class="mobile-sort-select"
variant="plain"
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100 mt-2">
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
variant="text"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
>
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ title }}
</span>
<VBadge
v-if="filterForm[key].length > 0"
:content="filterForm[key].length"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
</div>
</div>
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog
v-model="allFilterMenuOpen"
max-width="50rem"
location="center"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" location="center" max-height="85vh" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
<span>{{ currentFilterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterForm[currentFilter].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(currentFilter)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 资源列表 -->
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-visible" @load="loadMore">
<template #loading />
<template #empty />
<div class="grid gap-4 grid-torrent-card items-start">
<TorrentCard
v-for="item in displayDataList"
:key="`${item.torrent_info.page_url}`"
:torrent="item"
:more="item.more"
/>
</div>
</VInfiniteScroll>
<!-- 无结果时显示 -->
<div v-if="displayDataList.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
</template>
<style scoped>
.search-header {
position: sticky;
z-index: 10;
backdrop-filter: blur(10px);
inset-block-start: 0;
}
.view-header {
overflow: hidden;
}
.sort-container {
border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding-inline-end: 12px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.filter-btn {
min-inline-size: 0;
transition: transform 0.2s;
}
.filter-btn:hover {
transform: translateY(-2px);
}
.selected-filters {
overflow: hidden;
border-radius: 0 0 12px 12px;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
.filter-menu-content {
max-block-size: 50vh;
overflow-y: auto;
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
transform: translateY(-2px);
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
.filter-tag {
font-weight: 500;
transition: all 0.2s;
}
.filter-tag:hover {
transform: translateY(-2px);
}
.search-count {
font-weight: 600;
}
.grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
@media (width <= 600px) {
.filter-btn {
font-size: 0.75rem;
}
.sort-container {
border-inline-end: none;
inline-size: 100%;
margin-block-end: 8px;
padding-inline-end: 0;
}
.filter-bar {
inline-size: 100%;
margin-block-start: 8px;
}
}
.mobile-sort-select {
max-inline-size: 130px;
min-inline-size: 80px;
}
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
}
.filter-btn-mobile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
padding-inline: 0;
}
.filter-icon {
font-size: 18px;
margin-block-end: 2px;
}
.filter-label {
font-size: 0.8rem;
text-align: center;
}
.search-header-mobile {
position: sticky;
z-index: 10;
backdrop-filter: blur(10px);
background-color: rgba(var(--v-theme-background), 0.95);
inset-block-start: 0;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -1,910 +0,0 @@
<script lang="ts" setup>
import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入参数
const props = defineProps({
items: Array as PropType<Context[]>,
})
// 过滤表单
const filterForm: Record<string, string[]> = reactive({
// 站点
site: [] as string[],
// 季
season: [] as string[],
// 制作组
releaseGroup: [] as string[],
// 视频编码
videoCode: [] as string[],
// 促销状态
freeState: [] as string[],
// 质量
edition: [] as string[],
// 分辨率
resolution: [] as string[],
})
// 过滤项映射(保持中文标题)
const filterTitles: Record<string, string> = {
site: t('torrent.filterSite'),
season: t('torrent.filterSeason'),
freeState: t('torrent.filterFreeState'),
videoCode: t('torrent.filterVideoCode'),
edition: t('torrent.filterEdition'),
resolution: t('torrent.filterResolution'),
releaseGroup: t('torrent.filterReleaseGroup'),
}
// 排序中文名
const sortTitles: Record<string, string> = {
default: t('torrent.sortDefault'),
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 统一存储过滤选项
const filterOptions: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
videoCode: [] as string[],
releaseGroup: [] as string[],
})
// 排序字段
const sortField = ref('default')
// 降序
const sortType = ref<'asc' | 'desc'>('desc')
// 数据列表
const dataList = ref<Array<Context>>([])
// 显示用的数据列表
const displayDataList = ref<Array<Context>>([])
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in filterForm) {
count += filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
if (filterForm[key].length > 0) {
filters[key] = [...filterForm[key]]
}
}
return filters
})
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
const index = filterForm[key].indexOf(value)
if (index !== -1) {
filterForm[key].splice(index, 1)
}
}
// 清除所有过滤条件
function clearAllFilters() {
for (const key in filterForm) {
filterForm[key] = []
}
}
// 初始化过滤选项
function initOptions(data: Context) {
const { torrent_info, meta_info } = data
const optionValue = (options: Array<string>, value: string | undefined) => {
if (value && !options.includes(value)) {
options.push(value)
// 如果是season选项立即触发重新计算
if (options === filterOptions.season) {
// 季集选项排序
sortSeasonOptions()
}
}
}
optionValue(filterOptions.site, torrent_info?.site_name)
optionValue(filterOptions.season, meta_info?.season_episode)
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
optionValue(filterOptions.videoCode, meta_info?.video_encode)
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
optionValue(filterOptions.edition, meta_info?.edition)
optionValue(filterOptions.resolution, meta_info?.resource_pix)
}
// 直接在组件中添加季集排序函数,而不是用计算属性
function sortSeasonOptions() {
if (filterOptions.season.length <= 1) {
return // 不需要排序
}
// 预解析所有选项
const parsedOptions = filterOptions.season.map((option, index) => {
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
if (!match) {
// 格式不符合规范的放到最后
return {
original: option,
seasonNum: 0,
episodeNum: 0,
maxEpisodeNum: 0,
isWholeSeason: false,
index,
}
}
const seasonNum = parseInt(match[1], 10)
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
const isWholeSeason = !match[3] // 没有E部分表示整季
return {
original: option,
seasonNum,
episodeNum,
maxEpisodeNum,
isWholeSeason,
index,
}
})
// 先对所有项进行分类
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
// 对整季按季号降序排序
wholeSeasons.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum // 季号降序
}
return a.index - b.index // 相同季号按原始索引
})
// 对单集先按季号降序排序,季号相同时按集号降序排序
episodes.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum // 季号降序
}
// 使用最大集号进行排序 (对于范围如 E01-E06)
const aMaxEp = a.maxEpisodeNum || a.episodeNum
const bMaxEp = b.maxEpisodeNum || b.episodeNum
if (aMaxEp !== bMaxEp) {
return bMaxEp - aMaxEp // 集号降序
}
// 如果最大集号相同,再比较起始集号
if (a.episodeNum !== b.episodeNum) {
return b.episodeNum - a.episodeNum
}
return a.index - b.index // 都相同时按原始索引
})
// 合并结果:整季在前,单集在后
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
// 直接更新 filterOptions.season
filterOptions.season = sortedOptions
}
// 修改watch监听同时监听排序字段的变化
watch([filterForm, sortField, sortType], filterData)
// 计算过滤后的列表
function filterData() {
// 清空列表
dataList.value = []
displayDataList.value = []
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 先收集所有过滤选项,再过滤数据
if (props.items?.length) {
// 首先收集所有过滤选项
props.items.forEach(data => {
initOptions(data)
})
// 筛选数据
let filteredData: Context[] = []
// 然后根据过滤条件筛选数据
props.items.forEach(data => {
const { meta_info, torrent_info } = data
if (
// 站点过滤
match(filterForm.site, torrent_info.site_name) &&
// 促销状态过滤
match(filterForm.freeState, torrent_info.volume_factor) &&
// 季过滤
match(filterForm.season, meta_info.season_episode) &&
// 制作组过滤
match(filterForm.releaseGroup, meta_info.resource_team) &&
// 视频编码过滤
match(filterForm.videoCode, meta_info.video_encode) &&
// 分辨率过滤
match(filterForm.resolution, meta_info.resource_pix) &&
// 质量过滤
match(filterForm.edition, meta_info.edition)
) {
filteredData.push(data)
}
})
// 排序
if (sortType.value === 'desc') {
if (sortField.value === 'default') {
filteredData = filteredData.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
} else if (sortField.value === 'site') {
filteredData = filteredData.sort((a, b) =>
(a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''),
)
} else if (sortField.value === 'size') {
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(降序,最新的在前)
filteredData = filteredData.sort(
(a, b) => new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime(),
)
}
} else {
if (sortField.value === 'default') {
filteredData = filteredData.sort((a, b) => a.torrent_info.pri_order - b.torrent_info.pri_order)
} else if (sortField.value === 'site') {
filteredData = filteredData.sort((a, b) =>
(b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || ''),
)
} else if (sortField.value === 'size') {
filteredData = filteredData.sort((a, b) => a.torrent_info.size - b.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => a.torrent_info.seeders - b.torrent_info.seeders)
} else if (sortField.value === 'publishTime') {
// 按发布时间排序(升序,最旧的在前)
filteredData = filteredData.sort(
(a, b) => new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime(),
)
}
}
// 显示前20个
displayDataList.value = filteredData.slice(0, 20)
// 保存剩余数据
dataList.value = filteredData.slice(20)
}
// 确保在数据筛选完成后重新排序季集选项
if (filterOptions.season.length > 0) {
// 直接排序,不再使用延时
sortSeasonOptions()
}
}
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 全选某个过滤项
function selectAll(key: string) {
if (key === 'season') {
filterForm[key] = [...filterOptions[key]]
} else {
filterForm[key] = [...filterOptions[key]]
}
}
// 清除某个过滤项
function clearFilter(key: string) {
filterForm[key] = []
}
// 添加toggleFilterMenu函数
function toggleFilterMenu(key: string) {
if (currentFilter.value === key && filterMenuOpen.value) {
filterMenuOpen.value = false
} else {
currentFilter.value = key
filterMenuOpen.value = true
// 如果是季集选项,确保已排序
if (key === 'season' && filterOptions.season.length > 0) {
sortSeasonOptions()
}
}
}
function loadMore({ done }: { done: any }) {
// 从 dataList 中获取最前面的 20 个元素
const itemsToMove = dataList.value.splice(0, 20)
displayDataList.value.push(...itemsToMove)
done('ok')
}
// 处理图标点击
const handleSortIconClick = () => {
// 切换排序方向
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
onMounted(() => {
filterData()
})
</script>
<template>
<div class="torrent-view">
<!-- 搜索头部容器 - 新增用于固定在顶部 -->
<div class="search-header d-none d-sm-block">
<!-- PC端页面头部和筛选栏 -->
<VCard class="view-header mb-3">
<div class="d-flex align-center flex-wrap pa-3">
<VChip color="primary" variant="flat" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
{{ dataList.length }} {{ t('torrent.resources') }}
</VChip>
<div class="filter-bar">
<!-- 排序选择 -->
<VSelect
v-model="sortField"
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
item-title="title"
item-value="value"
density="compact"
hide-details
class="sort-select"
variant="plain"
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
<div class="filter-divider"></div>
<!-- 筛选按钮 -->
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
>
{{ title }}
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ filterForm[key].length }}
</VChip>
<VMenu activator="parent" :close-on-content-click="false" scrim>
<VCard max-width="20rem">
<VCardText class="filter-menu-content">
<div class="flex justify-between">
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</div>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn me-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
class="filter-btn"
prepend-icon="mdi-close-circle-outline"
>
{{ t('torrent.clearFilters') }}
</VBtn>
</div>
</div>
<!-- 已选择的过滤项显示 -->
<div v-if="getFilterCount > 0" class="selected-filters">
<div class="d-flex flex-wrap align-center">
<template v-for="(values, key) in getSelectedFilters" :key="key">
<VChip
v-for="(value, index) in values"
:key="`${key}-${index}`"
color="primary"
size="small"
closable
variant="elevated"
class="me-1 mb-1 mt-1 filter-tag"
@click:close="removeFilter(key, value)"
>
<VIcon size="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
</VChip>
</template>
</div>
</div>
</VCard>
</div>
<!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3">
<!-- 移动端头部 -->
<div class="view-header">
<div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100">
<VChip
color="primary"
variant="elevated"
size="small"
class="search-count me-auto"
prepend-icon="mdi-magnify"
>
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
<VSelect
v-model="sortField"
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
item-title="title"
item-value="value"
density="compact"
hide-details
class="mobile-sort-select"
variant="plain"
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100 mt-2">
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
variant="text"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
>
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ title }}
</span>
<VBadge
v-if="filterForm[key].length > 0"
:content="filterForm[key].length"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
</div>
</div>
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog
v-model="allFilterMenuOpen"
max-width="50rem"
location="center"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
<span>{{ currentFilterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterForm[currentFilter].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(currentFilter)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 资源列表容器 -->
<VCard class="resource-list-container">
<!-- 无结果时显示 -->
<div v-if="displayDataList.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
<!-- 资源列表 -->
<VInfiniteScroll
v-else
mode="intersect"
side="end"
:items="displayDataList"
class="resource-list overflow-visible"
@load="loadMore"
>
<template #loading />
<template #empty />
<div v-for="(item, index) in displayDataList" :key="`${item.torrent_info?.enclosure || ''}-${index}`">
<TorrentItem :torrent="item" />
<VDivider v-if="index < displayDataList.length - 1" class="my-2" />
</div>
</VInfiniteScroll>
</VCard>
</div>
</template>
<style scoped>
.torrent-view {
position: relative;
block-size: 100%;
}
.search-header {
position: sticky;
z-index: 10;
backdrop-filter: blur(10px);
inset-block-start: 0;
}
.search-header-mobile {
position: sticky;
z-index: 10;
backdrop-filter: blur(10px);
inset-block-start: 0;
}
.view-header {
overflow: hidden;
}
.search-count {
font-weight: 500;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.filter-divider {
background-color: rgba(var(--v-theme-on-surface), 0.12);
block-size: 24px;
inline-size: 1px;
margin-block: 0;
margin-inline: 8px;
}
.filter-btn {
min-inline-size: 0;
transition: transform 0.2s;
}
.filter-btn:hover {
transform: translateY(-2px);
}
.filter-menu-content {
max-block-size: 50vh;
overflow-y: auto;
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
transform: translateY(-2px);
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
.filter-tag {
font-weight: 500;
transition: all 0.2s;
}
.filter-tag:hover {
transform: translateY(-2px);
}
.selected-filters {
overflow: hidden;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
padding-block: 8px;
padding-inline: 12px;
}
.resource-list-container {
padding: 8px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 12px;
}
.resource-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-block-size: 300px;
}
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
}
.filter-btn-mobile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
padding-inline: 0;
}
.filter-icon {
font-size: 18px;
margin-block-end: 2px;
}
.filter-label {
font-size: 0.8rem;
text-align: center;
}
.mobile-sort-select {
max-inline-size: 130px;
min-inline-size: 80px;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -1,14 +1,14 @@
<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'
import api from '@/api'
import type { User } from '@/api/types'
import type { User, PassKey } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
import { useDisplay } from 'vuetify'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
// 国际化
const { t, locale } = useI18n()
@@ -35,15 +35,6 @@ const isSaving = ref(false)
// 开启双重验证窗口
const otpDialog = ref(false)
// otp uri
const otpUri = ref('')
// otp secret
const secret = ref('')
// 确认双重验证密码
const otpPassword = ref('')
// 当前头像缓存
const currentAvatar = ref(avatar1)
@@ -65,34 +56,12 @@ const accountInfo = ref<User>({
nickname: '',
})
// 二维码信息
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)
@@ -103,7 +72,7 @@ const verifyPasswordDialog = ref(false)
const verifyPassword = ref('')
// 验证后的回调
const verifyCallback = ref<(() => void) | null>(null)
const verifyCallback = ref<((password: string) => void) | null>(null)
// 验证对话框标题
const verifyTitle = ref('')
@@ -163,6 +132,7 @@ async function fetchUserInfo() {
if (result) {
accountInfo.value = result
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
@@ -192,12 +162,10 @@ async function saveAccountInfo() {
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (accountInfo.value.nickname) {
if (!accountInfo.value.settings) {
accountInfo.value.settings = {}
}
accountInfo.value.settings.nickname = accountInfo.value.nickname
if (!accountInfo.value.settings) {
accountInfo.value.settings = {}
}
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
const oldUserName = accountInfo.value.name
const oldAvatar = accountInfo.value.avatar
@@ -246,33 +214,15 @@ async function saveAccountInfo() {
isSaving.value = false
}
// 为当前用户获取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('mfa/otp/generate')
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
otpDialog.value = true
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
}
// 验证密码载荷接口
interface VerifyPasswordPayload {
title: string
text: string
callback: (password: string) => void
}
// 密码验证并执行回调
function withPasswordVerification(title: string, text: string, callback: () => void) {
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
verifyTitle.value = title
verifyText.value = text
verifyCallback.value = callback
@@ -280,6 +230,11 @@ function withPasswordVerification(title: string, text: string, callback: () => v
verifyPasswordDialog.value = true
}
// 弹窗请求密码验证
function onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {
withPasswordVerification(title, text, callback)
}
// 确认密码验证
async function confirmVerifyPassword() {
if (!verifyPassword.value) {
@@ -287,59 +242,11 @@ async function confirmVerifyPassword() {
return
}
if (verifyCallback.value) {
verifyCallback.value()
verifyCallback.value(verifyPassword.value)
}
verifyPasswordDialog.value = false
}
// 关闭当前用户的双重验证
async function disableOtp() {
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
async function judgeOtpPassword() {
if (!otpPassword.value) {
$toast.error(t('profile.otpCodeRequired'))
return
}
try {
const result: { [key: string]: any } = await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})
if (result.success) {
$toast.success(t('profile.otpEnableSuccess'))
otpDialog.value = false
accountInfo.value.is_otp = true
} else {
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
}
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
@@ -352,116 +259,6 @@ async function fetchPassKeyList() {
}
}
// 打开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()
@@ -515,11 +312,7 @@ watch(
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn
:color="hasMfaEnabled ? 'warning' : 'success'"
variant="tonal"
v-bind="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') }}
@@ -528,7 +321,14 @@ watch(
</VBtn>
</template>
<VList>
<VListItem @click="getOtpUri(); mfaMenu = false">
<VListItem
@click="
() => {
otpDialog = true
mfaMenu = false
}
"
>
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
@@ -537,9 +337,16 @@ watch(
{{ t('profile.enabled') }}
</VListItemSubtitle>
</VListItem>
<VListItem @click="openPassKeyDialog(); mfaMenu = false">
<VListItem
@click="
() => {
passkeyDialog = true
mfaMenu = false
}
"
>
<template #prepend>
<VIcon icon="mdi-key-variant" />
<VIcon icon="material-symbols:passkey" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
@@ -647,6 +454,15 @@ watch(
prepend-inner-icon="mdi-slack"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.discord_userid"
density="comfortable"
clearable
:label="t('profile.discordUser')"
prepend-inner-icon="mdi-discord"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.vocechat_userid"
@@ -691,179 +507,20 @@ watch(
</VRow>
<!-- 双重验证弹窗 -->
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
<VCard>
<VCardText>
<!-- 如果已启用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 color="error" @click="disableOtp(); otpDialog = false">
<template #prepend>
<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>
<OTPAuthDialog
v-model="otpDialog"
v-model:is-otp="accountInfo.is_otp"
:passkey-list="passkeyList"
@verify-password="onVerifyPassword"
/>
<!-- 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>
<PasskeyDialog
v-model="passkeyDialog"
:is-otp="accountInfo.is_otp"
v-model:passkey-list="passkeyList"
@verify-password="onVerifyPassword"
/>
<!-- 密码验证对话框 -->
<VDialog v-model="verifyPasswordDialog" max-width="30rem">

201
yarn.lock
View File

@@ -1145,6 +1145,27 @@
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz"
integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==
"@iconify-json/line-md@^1.2.13":
version "1.2.13"
resolved "https://registry.yarnpkg.com/@iconify-json/line-md/-/line-md-1.2.13.tgz#19714b8471ebac5871e20036512eaffa869a04b7"
integrity sha512-XFXThXsEQ2Wzzn+ze2T1d+JHkkFvI1AxiVKnOox4qFbdR9EVikckZlUK+/DUsV4zSy6pMQAgXpIk+1xG8qFYPQ==
dependencies:
"@iconify/types" "*"
"@iconify-json/lucide@^1.2.85":
version "1.2.85"
resolved "https://registry.yarnpkg.com/@iconify-json/lucide/-/lucide-1.2.85.tgz#0074b64f50798da4b89f9f74e4db5a4e56c640b1"
integrity sha512-VXUWT6KRDiVK4Ty/7Ypu+U0KnSbHzDAOOiSgLLPhU8u3ES5IusP1X7ahZb1iwiVKGWRG6gkKywaRUIZLgYWXyA==
dependencies:
"@iconify/types" "*"
"@iconify-json/material-symbols@^1.2.51":
version "1.2.51"
resolved "https://registry.yarnpkg.com/@iconify-json/material-symbols/-/material-symbols-1.2.51.tgz#270862a21bb65a8632de4943146096b5a58863ae"
integrity sha512-GkxlK8ocHi3NVVozaW62jm3qR9fNY3xX2penFtIRvoe1OtNhJ2KD4KRzv8x34pugMOAZYK8sALMcU30gDgCi1A==
dependencies:
"@iconify/types" "*"
"@iconify-json/mdi@^1.1.52":
version "1.2.3"
resolved "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz"
@@ -1976,6 +1997,11 @@
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/linkify-it@^5":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
@@ -1988,6 +2014,26 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz"
integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==
"@types/markdown-it-link-attributes@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/markdown-it-link-attributes/-/markdown-it-link-attributes-3.0.5.tgz#521179990cd2ced55761d9b8c93e502b679df329"
integrity sha512-VZ2BGN3ywUg7mBD8W6PwR8ChpOxaQSBDbLqPgvNI+uIra3zY2af1eG/3XzWTKjEraTWskMKnZqZd6m1fDF67Bg==
dependencies:
"@types/markdown-it" "*"
"@types/markdown-it@*", "@types/markdown-it@^14.1.2":
version "14.1.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
dependencies:
"@types/linkify-it" "^5"
"@types/mdurl" "^2"
"@types/mdurl@^2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/mousetrap@^1.6.15":
version "1.6.15"
resolved "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.15.tgz"
@@ -2020,6 +2066,13 @@
resolved "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz"
integrity sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==
"@types/qrcode@^1.5.6":
version "1.5.6"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42"
integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==
dependencies:
"@types/node" "*"
"@types/resolve@1.20.2":
version "1.20.2"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
@@ -3016,6 +3069,11 @@ camelcase-css@^2.0.1:
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001761"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz"
@@ -3104,6 +3162,15 @@ clean-regexp@^1.0.0:
dependencies:
escape-string-regexp "^1.0.5"
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
@@ -3460,6 +3527,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3
dependencies:
ms "^2.1.3"
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
@@ -3543,6 +3615,11 @@ didyoumean@^1.2.2:
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
dijkstrajs@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
@@ -4452,6 +4529,11 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
@@ -5313,6 +5395,13 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-it@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
dependencies:
uc.micro "^2.0.0"
local-pkg@^0.5.1:
version "0.5.1"
resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz"
@@ -5414,6 +5503,23 @@ magic-string@^0.30.11, magic-string@^0.30.17:
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
markdown-it-link-attributes@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz#25751f2cf74fd91f0a35ba7b3247fa45f2056d88"
integrity sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==
markdown-it@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
dependencies:
argparse "^2.0.1"
entities "^4.4.0"
linkify-it "^5.0.0"
mdurl "^2.0.0"
punycode.js "^2.3.1"
uc.micro "^2.1.0"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
@@ -5444,6 +5550,11 @@ mdn-data@^2.15.0:
resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.21.0.tgz"
integrity sha512-+ZKPQezM5vYJIkCxaC+4DTnRrVZR1CgsKLu5zsQERQx6Tea8Y+wMx5A24rq8A8NepCeatIQufVAekKNgiBMsGQ==
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
@@ -6045,6 +6156,11 @@ pluralize@^8.0.0:
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
possible-typed-array-names@^1.0.0:
version "1.1.0"
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
@@ -6197,15 +6313,24 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
punycode.js@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qrcode.vue@^3.6.0:
version "3.6.0"
resolved "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz"
integrity sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==
qrcode@^1.5.4:
version "1.5.4"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
dependencies:
dijkstrajs "^1.0.1"
pngjs "^5.0.0"
yargs "^15.3.1"
qs@6.13.0:
version "6.13.0"
@@ -6411,11 +6536,21 @@ regjsparser@^0.12.0:
dependencies:
jsesc "~3.0.2"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
requires-port@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
@@ -6617,6 +6752,11 @@ serve-static@1.16.2:
parseurl "~1.3.3"
send "0.19.0"
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz"
@@ -6855,8 +6995,7 @@ std-env@^3.9.0:
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
name string-width-cjs
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6942,7 +7081,6 @@ stringify-object@^3.3.0:
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7408,6 +7546,11 @@ typescript@^5, typescript@^5.0.4:
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
uc.micro@^2.0.0, uc.micro@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
ufo@^1.5.4, ufo@^1.6.1:
version "1.6.1"
resolved "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz"
@@ -7917,6 +8060,11 @@ which-collection@^1.0.2:
is-weakmap "^2.0.2"
is-weakset "^2.0.3"
which-module@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
which-typed-array@^1.1.16, which-typed-array@^1.1.18:
version "1.1.19"
resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz"
@@ -8116,6 +8264,15 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
@@ -8143,6 +8300,11 @@ xml-name-validator@^4.0.0:
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"
@@ -8166,6 +8328,31 @@ yaml@^2.0.0, yaml@^2.3.4, yaml@^2.7.0:
resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz"
integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
dependencies:
cliui "^6.0.0"
decamelize "^1.2.0"
find-up "^4.1.0"
get-caller-file "^2.0.1"
require-directory "^2.1.1"
require-main-filename "^2.0.0"
set-blocking "^2.0.0"
string-width "^4.2.0"
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^18.1.2"
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"