Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3b99ae51 | ||
|
|
e5339d524b | ||
|
|
e218508c2d | ||
|
|
e7bfc4ec1c | ||
|
|
5c63fe2313 | ||
|
|
8d86bd6e92 | ||
|
|
8a4ca2482c | ||
|
|
3daa031a04 | ||
|
|
15ef332ea2 | ||
|
|
57448ca609 | ||
|
|
0f7c95cfaf | ||
|
|
3886909e9e | ||
|
|
0f8a817413 | ||
|
|
eb47545b76 | ||
|
|
6fc123e0f6 | ||
|
|
5bdef2ba8f | ||
|
|
42ca5b8f94 | ||
|
|
ba48a25982 | ||
|
|
76c524eb9b | ||
|
|
48f8e8fc71 | ||
|
|
2a0f15c1bd | ||
|
|
e94ade364e | ||
|
|
27f6c33782 | ||
|
|
b3e596c761 | ||
|
|
0ee9e466c3 | ||
|
|
5325d813b4 | ||
|
|
dc3f0abf9b | ||
|
|
e63fc9a8bd | ||
|
|
eb5366f129 | ||
|
|
77c4a0f84c | ||
|
|
1bf6c1f1d8 | ||
|
|
31caf8f5f8 | ||
|
|
787076355b | ||
|
|
4da764ddef | ||
|
|
590755169f | ||
|
|
f7ef9dd116 | ||
|
|
4bc16eb220 | ||
|
|
032bda61f6 | ||
|
|
91ce2c8aee | ||
|
|
9b230d14b4 | ||
|
|
6201e01914 | ||
|
|
441f2ecc06 | ||
|
|
1ed69ccc64 | ||
|
|
80519e6839 | ||
|
|
b4be18e71f | ||
|
|
4a196731f1 | ||
|
|
b32a4e7c6a | ||
|
|
dc8becb41a | ||
|
|
d2896d5936 | ||
|
|
0315481eb6 | ||
|
|
ca955b48e2 | ||
|
|
19e93edca0 | ||
|
|
d039efbefe | ||
|
|
103472404f | ||
|
|
4405a69d59 | ||
|
|
c5f3a4db44 | ||
|
|
b66bc353b3 | ||
|
|
17cdfe6885 | ||
|
|
5cf0dc116a | ||
|
|
c1bb54a31e | ||
|
|
0fb5bbebc6 | ||
|
|
9f375e8413 | ||
|
|
1d7cfbfed8 | ||
|
|
0cc254c07d | ||
|
|
65d93b70e4 | ||
|
|
8dc36a2007 | ||
|
|
42ba550bb4 | ||
|
|
1aa8c9122d | ||
|
|
225d1d2ec0 | ||
|
|
00c0c86345 | ||
|
|
eee83542df |
8
.github/workflows/test.yml
vendored
@@ -4,16 +4,20 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
unit-tests:
|
||||
typecheck-and-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
|
||||
@@ -80,7 +80,7 @@ yarn build
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。
|
||||
1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||
|
||||
@@ -135,12 +135,16 @@ export default defineConfig({
|
||||
// 自定义事件,用于通知主应用刷新数据
|
||||
const emit = defineEmits(['action', 'switch', 'close'])
|
||||
|
||||
// 接收API对象
|
||||
// 接收主应用能力
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -175,7 +179,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收初始配置和API对象
|
||||
// 接收初始配置和主应用能力
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
@@ -185,6 +189,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
@@ -230,7 +238,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收配置和刷新控制
|
||||
// 接收配置、刷新控制和主应用能力
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
@@ -240,6 +248,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -272,16 +284,18 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
nativeSubscribe: { type: Function, default: null },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
@@ -296,7 +310,68 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 调用主应用 Toast
|
||||
### 5.5 主应用宿主能力
|
||||
|
||||
登录后的联邦组件宿主会向插件开放以下能力:
|
||||
|
||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||
|
||||
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
### 5.6 玻璃光学表面
|
||||
|
||||
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
||||
|
||||
```html
|
||||
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
|
||||
<!-- 插件自己的视觉内容 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
使用时需同时声明 `data-glass-optical-surface` 和 `data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page`、`Config` 或 `AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
|
||||
|
||||
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
|
||||
|
||||
### 5.7 调用主应用原生订阅
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
|
||||
|
||||
媒体信息必须包含:
|
||||
|
||||
- `type`:`电影` / `电视剧`,也兼容 `movie` / `tv`;
|
||||
- `title`;
|
||||
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid`、`douban_id` / `doubanid`、`bangumi_id` / `bangumiid`、`anilist_id` / `anilistid`,或者 `media_id` 与 `mediaid_prefix` / `source` / `media_source` 的组合。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
type NativeSubscribeResult =
|
||||
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
|
||||
|
||||
const props = defineProps<{
|
||||
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
|
||||
}>()
|
||||
|
||||
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
|
||||
|
||||
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
|
||||
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
|
||||
const result = await nativeSubscribe?.(mediaInfo)
|
||||
if (!result?.success) {
|
||||
// 插件可在这里执行自己的 fallback;宿主已同时显示明确错误提示。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
|
||||
|
||||
### 5.8 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
@@ -304,7 +379,14 @@ const emit = defineEmits(['action'])
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
const toast = inject<any>('moviepilot:toast', null)
|
||||
interface HostToast {
|
||||
error(message: string): unknown
|
||||
info(message: string): unknown
|
||||
success(message: string): unknown
|
||||
warning(message: string): unknown
|
||||
}
|
||||
|
||||
const toast = inject<HostToast | null>('moviepilot:toast', null)
|
||||
|
||||
// 保存完成后调用主应用的统一通知。
|
||||
function saveComplete() {
|
||||
|
||||
@@ -92,4 +92,4 @@ yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v2` 的 Pull Request 和推送到 `v2` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
|
||||
@@ -54,9 +54,6 @@
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-catch": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -810,9 +807,6 @@
|
||||
}
|
||||
},
|
||||
"src/pages/login.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 10
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
1638
index.html
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.14.6",
|
||||
"version": "2.15.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"dev:pwa": "vite --host --port 5174",
|
||||
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||
"build": "vite build",
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -130,6 +132,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sharp": "^0.33.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
|
||||
BIN
public/splash/apple-splash-1125-2436.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1136-640.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/splash/apple-splash-1170-2532.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1206-2622.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1242-2208.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1242-2688.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1260-2736.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1284-2778.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1320-2868.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/splash/apple-splash-1334-750.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/splash/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1536-2048.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-1620-2160.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1668-2224.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1668-2388.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1792-828.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/splash/apple-splash-2048-1536.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-2048-2732.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/splash/apple-splash-2160-1620.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2208-1242.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2224-1668.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2388-1668.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2436-1125.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2532-1170.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2622-1206.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2688-1242.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2732-2048.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/splash/apple-splash-2736-1260.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2778-1284.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2868-1320.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/splash/apple-splash-640-1136.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/splash/apple-splash-750-1334.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/splash/apple-splash-828-1792.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
68
scripts/generate-pwa-splash.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||
const background = '#0E1116'
|
||||
|
||||
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||
// to physical pixels for the selected Apple launch image.
|
||||
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||
const image = sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
}).composite([
|
||||
{
|
||||
input: logo,
|
||||
left: Math.round((width - logoSize) / 2),
|
||||
top: Math.round((height - logoSize) / 2),
|
||||
},
|
||||
])
|
||||
|
||||
if (format === 'png') {
|
||||
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||
const landscapeWidth = portraitHeight
|
||||
const landscapeHeight = portraitWidth
|
||||
|
||||
await createSplash(
|
||||
portraitWidth,
|
||||
portraitHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
await createSplash(
|
||||
landscapeWidth,
|
||||
landscapeHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the previous fallback filename for older deployments and bookmarked
|
||||
// entries that may still reference it. Its palette matches the new assets.
|
||||
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||
|
||||
console.log(
|
||||
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||
)
|
||||
294
scripts/pwa-development.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
|
||||
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
|
||||
|
||||
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
|
||||
const devEntryScriptUrl = '/src/main.ts'
|
||||
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
|
||||
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
|
||||
const moviePilotIdentityTimeoutMs = 1500
|
||||
const moviePilotIdentityAttempts = 2
|
||||
|
||||
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
|
||||
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
|
||||
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
|
||||
|
||||
/** 普通开发服务器才执行历史 Service Worker 清理,production preview 保持构建产物行为。 */
|
||||
export const shouldEnableDevServiceWorkerCleanup = (
|
||||
command: 'build' | 'serve',
|
||||
mode: string,
|
||||
isPreview: boolean | undefined,
|
||||
lifecycleEvent?: string,
|
||||
) =>
|
||||
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
|
||||
|
||||
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
|
||||
export function resolveDevAppScope(pageUrl: string): URL {
|
||||
return new URL('./', new URL(pageUrl))
|
||||
}
|
||||
|
||||
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
|
||||
export function isManagedServiceWorkerRegistration(
|
||||
scriptUrl: string,
|
||||
registrationScope: string,
|
||||
pageUrl: string,
|
||||
): boolean {
|
||||
const appScope = resolveDevAppScope(pageUrl)
|
||||
const scope = new URL(registrationScope)
|
||||
const script = new URL(scriptUrl)
|
||||
|
||||
return (
|
||||
scope.href === appScope.href &&
|
||||
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
|
||||
)
|
||||
}
|
||||
|
||||
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
|
||||
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
return typeof (value as { count?: unknown }).count === 'number'
|
||||
}
|
||||
|
||||
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
|
||||
export async function retryMoviePilotIdentityVerification(
|
||||
verifyOnce: () => Promise<boolean>,
|
||||
attempts: number,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
if (await verifyOnce()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 删除当前 origin 的全部 Cache Storage;调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
|
||||
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
|
||||
const cacheNames = await cacheStorage.keys()
|
||||
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
|
||||
}
|
||||
|
||||
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||
if (!requested) return new URL('/', origin)
|
||||
const target = new URL(requested, origin)
|
||||
return target.origin === origin ? target : new URL('/', origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
|
||||
*/
|
||||
export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
|
||||
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
|
||||
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
|
||||
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||
|
||||
const redirectScript = `
|
||||
(() => {
|
||||
const entryScriptUrl = ${entryScriptUrl}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
let appStarted = false
|
||||
const startApp = () => {
|
||||
if (appStarted) return
|
||||
appStarted = true
|
||||
const entry = document.createElement('script')
|
||||
entry.type = 'module'
|
||||
entry.src = entryScriptUrl
|
||||
document.head.appendChild(entry)
|
||||
}
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
startApp()
|
||||
return
|
||||
}
|
||||
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const cleanupPath = ${cleanupPath}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const hasVerifiedRegistration = async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const redirectToCleanup = () => {
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'pending')
|
||||
const target = new URL(cleanupPath.slice(1), appScope)
|
||||
target.searchParams.set('return', location.href)
|
||||
location.replace(target.href)
|
||||
}
|
||||
|
||||
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||
if (cleanupState === 'complete') {
|
||||
void (async () => {
|
||||
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
location.reload()
|
||||
})().catch(error => {
|
||||
console.error('[PWA] Failed to finish stale development cache cleanup', error)
|
||||
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
void hasVerifiedRegistration().then(hasRegistration => {
|
||||
if (hasRegistration) {
|
||||
redirectToCleanup()
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
startApp()
|
||||
}).catch(error => {
|
||||
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
|
||||
startApp()
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const cleanupDocument = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const resolveReturnUrl = () => {
|
||||
const requested = new URLSearchParams(location.search).get('return')
|
||||
if (!requested) return appScope
|
||||
const target = new URL(requested, appScope)
|
||||
return target.href.startsWith(appScope.href) ? target : appScope
|
||||
}
|
||||
const returnUrl = resolveReturnUrl()
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const cleanup = async () => {
|
||||
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
|
||||
throw new Error('Missing Service Worker cleanup context for current application scope')
|
||||
}
|
||||
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
|
||||
const managedRegistrations = []
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
|
||||
}
|
||||
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||
|
||||
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
|
||||
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||
location.replace(returnUrl.href)
|
||||
}
|
||||
|
||||
void cleanup().catch(error => {
|
||||
console.error('[PWA] Failed to clean stale development Service Worker state', error)
|
||||
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return {
|
||||
name: 'moviepilot:dev-service-worker-cleanup',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const pathname = new URL(request.url || '/', 'http://localhost').pathname
|
||||
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.end(cleanupDocument)
|
||||
})
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html) {
|
||||
if (!html.includes(devEntryScriptTag)) {
|
||||
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
html: html.replace(devEntryScriptTag, ''),
|
||||
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
22
scripts/pwa-splash-specs.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||
]
|
||||
@@ -96,7 +96,8 @@
|
||||
|
||||
// 👉 Nav Link
|
||||
.nav-link {
|
||||
overflow: hidden;
|
||||
// 交互项已预留下边距承接激活阴影,父级不能再次裁切这段视觉溢出。
|
||||
overflow: visible;
|
||||
|
||||
> :first-child {
|
||||
@extend %vertical-nav-item;
|
||||
|
||||
@@ -34,10 +34,15 @@ $ps-track-size: 0.5rem;
|
||||
inline-size: $ps-hover-size;
|
||||
}
|
||||
|
||||
// fix bug
|
||||
@media(hover: none) {
|
||||
.ps > .ps__rail-x,
|
||||
.ps > .ps__rail-y {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
|
||||
.ps:hover > .ps__rail-x,
|
||||
.ps:hover > .ps__rail-y,
|
||||
.ps--focus > .ps__rail-x,
|
||||
.ps--focus > .ps__rail-y {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.ps.ps--scrolling-x > .ps__rail-x,
|
||||
.ps.ps--scrolling-y > .ps__rail-y {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
53
src/@core/utils/__tests__/image.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {},
|
||||
}))
|
||||
|
||||
import { preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
describe('preloadCorsImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a CORS-clean cached response without reloading it', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://image.example/wallpaper.jpg'),
|
||||
expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
new URL('/wallpaper.jpg', window.location.href),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the source cannot be read with CORS', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -70,22 +70,57 @@ export async function getDominantColor(
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(available)
|
||||
}
|
||||
|
||||
img.onload = () => finish(true)
|
||||
img.onerror = () => finish(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
finish(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
finish(img.naturalWidth > 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
|
||||
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||
if (name === 'auto') {
|
||||
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
707
src/App.vue
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
@@ -6,12 +7,12 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { preloadCorsImage, preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
applyStoredTransparencySettings,
|
||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||
@@ -22,21 +23,86 @@ import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import {
|
||||
useGlobalOfflineStatus,
|
||||
type ConnectionFailureReason,
|
||||
} from '@/composables/useOfflineStatus'
|
||||
BACKGROUND_ROTATION_GRACE_MS,
|
||||
createBackgroundCandidateOrderResolver,
|
||||
findFirstAvailableBackground,
|
||||
preloadBackgroundRotationImages,
|
||||
shouldAllowBackgroundRotation,
|
||||
} from '@/utils/backgroundRotation'
|
||||
import {
|
||||
activateLoginBackgroundLayer,
|
||||
createLoginBackgroundLayers,
|
||||
getLoginGlassOpticalSettings,
|
||||
getLoginVisualProfile,
|
||||
prepareLoginBackgroundLayer,
|
||||
settleLoginBackgroundLayers,
|
||||
type LoginBackgroundLayer,
|
||||
} from '@/utils/loginPresentation'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
loadGlassWallpaperToneProfile,
|
||||
type GlassWallpaperToneProfile,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
||||
const LAUNCH_MIN_VISIBLE_MS = 320
|
||||
const LAUNCH_MAX_WAIT_MS = 1200
|
||||
const LAUNCH_EXIT_DURATION_MS = 180
|
||||
|
||||
function getLaunchNow() {
|
||||
return globalThis.performance?.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
const launchStartedAt = Number.parseFloat(document.documentElement.dataset.launchStartedAt || '') || getLaunchNow()
|
||||
|
||||
function getRemainingLaunchBudget() {
|
||||
return Math.max(0, LAUNCH_MAX_WAIT_MS - (getLaunchNow() - launchStartedAt))
|
||||
}
|
||||
|
||||
async function waitForLaunchTask(task: Promise<unknown>, timeoutMs: number, label: string) {
|
||||
if (timeoutMs <= 0) return
|
||||
|
||||
await Promise.race([
|
||||
task.catch(error => {
|
||||
console.warn(`[Launch] ${label} failed`, error)
|
||||
}),
|
||||
new Promise<void>(resolve => window.setTimeout(resolve, timeoutMs)),
|
||||
])
|
||||
}
|
||||
|
||||
async function waitForMinimumLaunchVisibility() {
|
||||
const remaining = LAUNCH_MIN_VISIBLE_MS - (getLaunchNow() - launchStartedAt)
|
||||
if (remaining > 0) {
|
||||
await new Promise<void>(resolve => window.setTimeout(resolve, remaining))
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedAutoResolvedTheme() {
|
||||
const cachedTheme = localStorage.getItem('materio-initial-resolved-theme')
|
||||
|
||||
return cachedTheme === 'dark' || cachedTheme === 'light' ? cachedTheme : null
|
||||
}
|
||||
|
||||
function resolveInitialThemeName(themePreference: string) {
|
||||
if (themePreference === 'auto') {
|
||||
return getCachedAutoResolvedTheme() || resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
return resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
globalTheme.name.value = resolveThemeName(themeValue)
|
||||
let resumeThemeSyncTimer: number | null = null
|
||||
globalTheme.name.value = resolveInitialThemeName(themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
@@ -59,25 +125,110 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 生成背景图片key
|
||||
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
|
||||
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
const backgroundCrossfadeStartedAt = ref(0)
|
||||
const pendingOpticalBackgroundImage = ref('')
|
||||
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
|
||||
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const backgroundRotationGraceActive = ref(false)
|
||||
let backgroundRotationGraceTimer: number | null = null
|
||||
// 壁纸时钟允许短时后台续跑;指针、滚动和流场仍服从更严格的应用活动状态。
|
||||
const allowsBackgroundRotation = computed(() =>
|
||||
shouldAllowBackgroundRotation(
|
||||
appActivityState.value,
|
||||
backgroundRotationGraceActive.value,
|
||||
preferredMotion.value === 'reduce',
|
||||
),
|
||||
)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
||||
const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||
const isInitialRouteReady = ref(false)
|
||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const loginVisualProfile = computed(() => getLoginVisualProfile(globalTheme.name.value))
|
||||
const loginGlassSettings = computed(() =>
|
||||
getLoginGlassOpticalSettings({
|
||||
appearance: effectiveGlassSettings.value.glassAppearance,
|
||||
deformationStrength: effectiveGlassSettings.value.glassDeformationStrength,
|
||||
flowStrength: effectiveGlassSettings.value.glassFlowStrength,
|
||||
preset: effectiveGlassSettings.value.glassPreset,
|
||||
reflectionStrength: effectiveGlassSettings.value.glassReflectionStrength,
|
||||
transmissionStrength: effectiveGlassSettings.value.glassTransmissionStrength,
|
||||
translationStrength: effectiveGlassSettings.value.glassTranslationStrength,
|
||||
transparencyStrength: effectiveGlassSettings.value.glassTransparencyStrength,
|
||||
}),
|
||||
)
|
||||
const opticalDeformationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.deformationStrength
|
||||
: effectiveGlassSettings.value.glassDeformationStrength,
|
||||
)
|
||||
const opticalFlowStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? loginGlassSettings.value.flowStrength : effectiveGlassSettings.value.glassFlowStrength,
|
||||
)
|
||||
const opticalQuality = computed(() =>
|
||||
isLoginWallpaperRoute.value ? loginGlassSettings.value.quality : effectiveGlassSettings.value.glassQuality,
|
||||
)
|
||||
const opticalReflectionStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.reflectionStrength
|
||||
: effectiveGlassSettings.value.glassReflectionStrength,
|
||||
)
|
||||
const opticalTransparencyStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.transparencyStrength
|
||||
: effectiveGlassSettings.value.glassTransparencyStrength,
|
||||
)
|
||||
const opticalTranslationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.translationStrength
|
||||
: effectiveGlassSettings.value.glassTranslationStrength,
|
||||
)
|
||||
const opticalTransmissionStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.transmissionStrength
|
||||
: effectiveGlassSettings.value.glassTransmissionStrength,
|
||||
)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => isTransparentTheme.value && Boolean(isLogin.value))
|
||||
const shouldUseGlassBackgroundTreatment = computed(
|
||||
() => isGlassTheme.value && (Boolean(isLogin.value) || isLoginWallpaperRoute.value),
|
||||
)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||
)
|
||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
|
||||
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
|
||||
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
|
||||
const previousOpticalBackgroundImage = computed(() => {
|
||||
const previousIndex = previousImageIndex.value
|
||||
if (previousIndex === null) return ''
|
||||
|
||||
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||
})
|
||||
const shouldRenderGlassOpticalLayer = computed(
|
||||
() =>
|
||||
isGlassTheme.value &&
|
||||
opticalQuality.value !== 'css' &&
|
||||
isInitialRouteReady.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||
const transparentBackgroundBlur = ref(16)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||
@@ -88,12 +239,15 @@ const shouldRenderGlobalBlurLayer = computed(
|
||||
transparentBackgroundBlur.value > 0 &&
|
||||
transparencyGlassQuality.value === 'realtime',
|
||||
)
|
||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
let pendingOpticalWallpaperTimer: number | null = null
|
||||
let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
let windowBlurRenderThrottleTimer: number | null = null
|
||||
let backgroundLoadVersion = 0
|
||||
let backgroundRecoveryAttemptedVersion = -1
|
||||
let backgroundRotationVersion = 0
|
||||
|
||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||
function applyTransparentBackgroundSettings() {
|
||||
@@ -111,53 +265,36 @@ function handleTransparencySettingsChanged(event: Event) {
|
||||
transparencyGlassQuality.value = glassQuality
|
||||
}
|
||||
|
||||
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */
|
||||
async function ensureBackgroundToneProfile(imageUrl: string) {
|
||||
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
|
||||
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
|
||||
backgroundToneProfiles.value = {
|
||||
...backgroundToneProfiles.value,
|
||||
[imageUrl]: profile,
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
|
||||
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
||||
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
const appearance = effectiveGlassSettings.value.glassAppearance
|
||||
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
||||
|
||||
return {
|
||||
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
|
||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
||||
}
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
function clearWindowBlurRenderThrottleTimer() {
|
||||
if (windowBlurRenderThrottleTimer) {
|
||||
window.clearTimeout(windowBlurRenderThrottleTimer)
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function restoreForegroundRendering() {
|
||||
const wasRenderThrottled = isRenderThrottled.value
|
||||
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
isRenderThrottled.value = false
|
||||
|
||||
if (wasRenderThrottled && backgroundImages.value.length > 1) {
|
||||
startBackgroundRotation()
|
||||
rotateBackgroundImage()
|
||||
}
|
||||
}
|
||||
|
||||
function throttleBackgroundRendering() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
resetBackgroundCrossfade()
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
|
||||
function handleWindowBlurRenderThrottle() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
if (document.visibilityState === 'hidden') {
|
||||
throttleBackgroundRendering()
|
||||
return
|
||||
}
|
||||
|
||||
windowBlurRenderThrottleTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && !document.hasFocus()) {
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS)
|
||||
}
|
||||
|
||||
function handleWindowFocusRenderThrottle() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
}
|
||||
}
|
||||
void router.isReady().then(() => {
|
||||
isInitialRouteReady.value = true
|
||||
})
|
||||
|
||||
let heartbeatInterval: number | null = null
|
||||
let connectionRetryTimer: number | null = null
|
||||
@@ -209,13 +346,10 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||
const probePromise = (async () => {
|
||||
try {
|
||||
await api.get(
|
||||
'system/ping',
|
||||
{
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig,
|
||||
)
|
||||
await api.get('system/ping', {
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig)
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -258,9 +392,12 @@ function startHeartbeat() {
|
||||
|
||||
void probeServerConnection()
|
||||
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
}, 5 * 60 * 1000)
|
||||
heartbeatInterval = window.setInterval(
|
||||
async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
/** 停止心跳和等待中的自动重连任务。 */
|
||||
@@ -306,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
function syncThemePreferenceFromStorage(preferCachedAuto = false) {
|
||||
if (resumeThemeSyncTimer !== null) {
|
||||
window.clearTimeout(resumeThemeSyncTimer)
|
||||
resumeThemeSyncTimer = null
|
||||
}
|
||||
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
const resolvedTheme = resolveThemeName(themeValue)
|
||||
const resolvedTheme =
|
||||
themeValue === 'auto' && preferCachedAuto
|
||||
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
|
||||
: resolveThemeName(themeValue)
|
||||
if (globalTheme.name.value !== resolvedTheme) {
|
||||
globalTheme.name.value = resolvedTheme
|
||||
}
|
||||
@@ -320,13 +465,20 @@ function syncThemePreferenceFromStorage() {
|
||||
|
||||
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
||||
void themeManager
|
||||
.setTheme(themeValue)
|
||||
.setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
|
||||
.then(() => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('同步主题管理器失败:', error)
|
||||
})
|
||||
|
||||
if (preferCachedAuto && themeValue === 'auto') {
|
||||
resumeThemeSyncTimer = window.setTimeout(() => {
|
||||
resumeThemeSyncTimer = null
|
||||
syncThemePreferenceFromStorage()
|
||||
}, 180)
|
||||
}
|
||||
}
|
||||
|
||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||
@@ -339,21 +491,17 @@ function handleSystemThemeChange() {
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
} else {
|
||||
throttleBackgroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
|
||||
function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
}
|
||||
|
||||
// 清理背景图交叉淡入淡出定时器。
|
||||
@@ -364,10 +512,44 @@ function clearBackgroundCrossfadeTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */
|
||||
function settlePendingOpticalWallpaper(ready: boolean) {
|
||||
if (pendingOpticalWallpaperTimer !== null) {
|
||||
window.clearTimeout(pendingOpticalWallpaperTimer)
|
||||
pendingOpticalWallpaperTimer = null
|
||||
}
|
||||
pendingOpticalBackgroundImage.value = ''
|
||||
pendingOpticalWallpaperResolve?.(ready)
|
||||
pendingOpticalWallpaperResolve = null
|
||||
}
|
||||
|
||||
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
|
||||
function prepareOpticalWallpaper(url: string) {
|
||||
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
settlePendingOpticalWallpaper(false)
|
||||
pendingOpticalBackgroundImage.value = url
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
pendingOpticalWallpaperResolve = resolve
|
||||
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
|
||||
})
|
||||
}
|
||||
|
||||
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
|
||||
function handleOpticalWallpaperPrepared(url: string) {
|
||||
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
|
||||
}
|
||||
|
||||
// 重置背景图交叉淡入淡出状态。
|
||||
function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundCrossfadeStartedAt.value = 0
|
||||
backgroundLayers.value = createLoginBackgroundLayers(activeBackgroundImage.value)
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
@@ -375,67 +557,180 @@ function activateBackgroundImage(nextIndex: number) {
|
||||
if (nextIndex === activeImageIndex.value) return
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
isBackgroundCrossfading.value = true
|
||||
backgroundCrossfadeStartedAt.value = performance.now()
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
|
||||
async function fetchBackgroundImages() {
|
||||
backgroundRequestController?.abort()
|
||||
const controller = new AbortController()
|
||||
backgroundRequestController = controller
|
||||
try {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: backgroundRequestController.signal,
|
||||
return await api.get<string[], string[]>(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
resetBackgroundCrossfade()
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
if (backgroundRequestController === controller) backgroundRequestController = null
|
||||
}
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (isRenderThrottled.value) return
|
||||
/** 仅提前加载当前图的下一项,不建立全目录预载队列。 */
|
||||
function preloadNextBackgroundImage() {
|
||||
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
void preloadBackgroundCandidate(backgroundImages.value[nextIndex])
|
||||
}
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
// 预加载下一张图片
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activateBackgroundImage(nextIndex)
|
||||
}
|
||||
})
|
||||
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
||||
async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
const toneProfile = isGlassTheme.value
|
||||
? ensureBackgroundToneProfile(imageUrl)
|
||||
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
if (!shouldRenderGlassOpticalLayer.value) {
|
||||
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
const opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
||||
if (!opticalReady) {
|
||||
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return displayReady
|
||||
}
|
||||
|
||||
const [displayReady] = await Promise.all([
|
||||
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
|
||||
toneProfile,
|
||||
])
|
||||
|
||||
return displayReady
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
async function rotateBackgroundImage() {
|
||||
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return
|
||||
|
||||
const requestVersion = ++backgroundRotationVersion
|
||||
const activeIndex = activeImageIndex.value
|
||||
for (let offset = 1; offset < backgroundImages.value.length; offset += 1) {
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
|
||||
const nextImage = backgroundImages.value[nextIndex]
|
||||
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
|
||||
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue
|
||||
const imagesReady = await preloadBackgroundRotationImages({
|
||||
displayUrl: nextImage,
|
||||
opticalUrl: opticalImage,
|
||||
preload: preloadImage,
|
||||
})
|
||||
if (!imagesReady) continue
|
||||
await ensureBackgroundToneProfile(nextImage)
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
activateBackgroundImage(nextIndex)
|
||||
preloadNextBackgroundImage()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
|
||||
stopBackgroundRotation()
|
||||
const recoveryVersion = ++backgroundLoadVersion
|
||||
backgroundRecoveryAttemptedVersion = recoveryVersion
|
||||
void loadBackgroundImages(recoveryVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
|
||||
function stopBackgroundRotation() {
|
||||
backgroundRotationVersion += 1
|
||||
removeBackgroundTimer('background-rotation')
|
||||
settlePendingOpticalWallpaper(false)
|
||||
}
|
||||
|
||||
function clearBackgroundRotationGrace() {
|
||||
if (backgroundRotationGraceTimer !== null) {
|
||||
window.clearTimeout(backgroundRotationGraceTimer)
|
||||
backgroundRotationGraceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startBackgroundRotationGrace() {
|
||||
if (backgroundRotationGraceActive.value) return
|
||||
|
||||
backgroundRotationGraceActive.value = true
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceTimer = window.setTimeout(() => {
|
||||
backgroundRotationGraceTimer = null
|
||||
backgroundRotationGraceActive.value = false
|
||||
}, BACKGROUND_ROTATION_GRACE_MS)
|
||||
}
|
||||
|
||||
// 开始背景图片轮换
|
||||
function startBackgroundRotation() {
|
||||
// 清除现有定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
stopBackgroundRotation()
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 使用优化的定时器管理器,后台时自动暂停
|
||||
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
||||
preloadNextBackgroundImage()
|
||||
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
|
||||
addBackgroundTimer(
|
||||
'background-rotation',
|
||||
rotateBackgroundImage,
|
||||
() => void rotateBackgroundImage(),
|
||||
10000, // 每10秒切换一次
|
||||
{
|
||||
runInBackground: false, // 后台时不运行
|
||||
runInBackground: true,
|
||||
skipInitialRun: true, // 不需要立即执行
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止登录页或透明主题背景图加载、重试和轮播。
|
||||
watch(
|
||||
appActivityState,
|
||||
(state, previousState) => {
|
||||
if (state === 'active') {
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'idle') {
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (previousState === 'active') startBackgroundRotationGrace()
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(allowsBackgroundRotation, allowsRotation => {
|
||||
resetBackgroundCrossfade()
|
||||
|
||||
if (allowsRotation) {
|
||||
startBackgroundRotation()
|
||||
} else {
|
||||
stopBackgroundRotation()
|
||||
}
|
||||
})
|
||||
|
||||
// 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。
|
||||
function stopBackgroundLoading() {
|
||||
backgroundLoadVersion += 1
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
|
||||
@@ -445,7 +740,7 @@ function stopBackgroundLoading() {
|
||||
}
|
||||
|
||||
resetBackgroundCrossfade()
|
||||
removeBackgroundTimer('background-rotation')
|
||||
stopBackgroundRotation()
|
||||
}
|
||||
|
||||
// 初始化登录后的全局设置和用户设置状态。
|
||||
@@ -490,7 +785,7 @@ async function animateAndRemoveLoader() {
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
resolve()
|
||||
}, 120)
|
||||
}, LAUNCH_EXIT_DURATION_MS)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
@@ -503,19 +798,27 @@ async function removeLoadingWithStateCheck() {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
|
||||
const pwaController = (window as any).pwaStateController
|
||||
if (pwaController) {
|
||||
await pwaController.waitForStateRestore()
|
||||
if (pwaController?.waitForStateRestore) {
|
||||
await waitForLaunchTask(
|
||||
Promise.resolve().then(() => pwaController.waitForStateRestore()),
|
||||
getRemainingLaunchBudget(),
|
||||
'PWA state restore',
|
||||
)
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||
await initializePWA()
|
||||
await initializeAuthenticatedState()
|
||||
await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
// 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
|
||||
void initializeAuthenticatedState().catch(error => {
|
||||
console.warn('[Launch] Authenticated state initialization failed', error)
|
||||
})
|
||||
|
||||
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
|
||||
await waitForMinimumLaunchVisibility()
|
||||
|
||||
// 移除加载界面
|
||||
await animateAndRemoveLoader()
|
||||
@@ -532,19 +835,43 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
|
||||
// 加载背景图片
|
||||
async function loadBackgroundImages(retryCount = 0) {
|
||||
async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
||||
const maxRetries = 3
|
||||
try {
|
||||
await fetchBackgroundImages()
|
||||
const images = resolveBackgroundCandidateOrder(await fetchBackgroundImages())
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
|
||||
const firstAvailableIndex = await findFirstAvailableBackground({
|
||||
urls: images,
|
||||
canContinue: () => loadVersion === backgroundLoadVersion,
|
||||
preload: preloadBackgroundCandidate,
|
||||
})
|
||||
if (firstAvailableIndex === null) throw new Error('没有可用的登录壁纸')
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
|
||||
const currentImage = activeBackgroundImage.value
|
||||
const currentIndex = images.indexOf(currentImage)
|
||||
if (currentImage && currentIndex < 0) {
|
||||
backgroundImages.value = [currentImage, ...images]
|
||||
activeImageIndex.value = 0
|
||||
} else {
|
||||
backgroundImages.value = images
|
||||
activeImageIndex.value = currentIndex >= 0 ? currentIndex : firstAvailableIndex
|
||||
}
|
||||
backgroundRecoveryAttemptedVersion = -1
|
||||
resetBackgroundCrossfade()
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
backgroundRetryTimer = window.setTimeout(() => {
|
||||
backgroundRetryTimer = null
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
if (loadVersion === backgroundLoadVersion) {
|
||||
void loadBackgroundImages(loadVersion, retryCount + 1)
|
||||
}
|
||||
}, retryDelay)
|
||||
}
|
||||
}
|
||||
@@ -566,7 +893,7 @@ onMounted(async () => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
@@ -586,24 +913,30 @@ onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.addEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.addEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
// 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
loadBackgroundImages()
|
||||
} else if (!isTransparentTheme.value) {
|
||||
void loadBackgroundImages(backgroundLoadVersion)
|
||||
} else if (!isBackdropTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(isGlassTheme, enabled => {
|
||||
if (!enabled) return
|
||||
|
||||
void Promise.all(
|
||||
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
|
||||
)
|
||||
})
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
@@ -632,11 +965,11 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
stopBackgroundLoading()
|
||||
clearBackgroundRotationGrace()
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
@@ -644,36 +977,67 @@ onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.removeEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
||||
<!-- 透明主题背景 -->
|
||||
<div
|
||||
class="app-wrapper"
|
||||
:class="{
|
||||
'app-wrapper--background-transition': isBackgroundCrossfading,
|
||||
'app-wrapper--decorative-motion-paused': !allowsDecorativeMotion,
|
||||
'app-wrapper--login-glass-high': isLoginWallpaperRoute && loginVisualProfile === 'glass',
|
||||
'app-wrapper--login-wallpaper': isLoginWallpaperRoute,
|
||||
'app-wrapper--render-throttled': isRenderThrottled,
|
||||
}"
|
||||
:data-app-activity-state="appActivityState"
|
||||
>
|
||||
<!-- 登录页、透明主题和玻璃主题共用动态壁纸场景。 -->
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{
|
||||
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
|
||||
'is-glass-theme': shouldUseGlassBackgroundTreatment,
|
||||
'is-transparent-glass-lightweight':
|
||||
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
v-for="layer in renderedBackgroundLayers"
|
||||
:key="layer.key"
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
:class="layer.role"
|
||||
:style="getBackgroundLayerStyle(layer)"
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<GlassOpticalLayer
|
||||
v-if="shouldRenderGlassOpticalLayer"
|
||||
:appearance="effectiveGlassSettings.glassAppearance"
|
||||
:deformation-strength="opticalDeformationStrength"
|
||||
:flow-strength="opticalFlowStrength"
|
||||
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
||||
:reflection-strength="opticalReflectionStrength"
|
||||
:transparency-strength="opticalTransparencyStrength"
|
||||
:transmission-strength="opticalTransmissionStrength"
|
||||
:translation-strength="opticalTranslationStrength"
|
||||
:route-key="route.fullPath"
|
||||
:tint-color="globalTheme.current.value.colors.primary"
|
||||
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
|
||||
:transition-started-at="backgroundCrossfadeStartedAt"
|
||||
:wallpaper-url="activeOpticalBackgroundImage"
|
||||
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
||||
:pending-wallpaper-url="pendingOpticalBackgroundImage"
|
||||
@wallpaper-prepared="handleOpticalWallpaperPrepared"
|
||||
/>
|
||||
<!-- 页面内容 -->
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<VApp
|
||||
:class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }"
|
||||
:data-login-visual-profile="isLoginWallpaperRoute ? loginVisualProfile : undefined"
|
||||
>
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -701,6 +1065,14 @@ onUnmounted(() => {
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
// 登录内容与壁纸进入同一文档弹性合成上下文;sticky 仍保持普通滚动时的 viewport 锁定。
|
||||
.app-wrapper--login-wallpaper .background-container {
|
||||
position: sticky;
|
||||
block-size: 100dvh;
|
||||
margin-block-end: -100dvh;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
background-position: center;
|
||||
@@ -737,6 +1109,52 @@ onUnmounted(() => {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
.background-container.is-glass-theme .background-image.active,
|
||||
.background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.86)) saturate(0.95) contrast(1.02);
|
||||
}
|
||||
|
||||
.background-container.is-glass-theme .background-image.active {
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.background-container.is-glass-theme .background-image.active::after,
|
||||
.background-container.is-glass-theme .background-image.previous::after {
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 12%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 30%) 100%);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.85)) saturate(0.97) contrast(1.02);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
|
||||
opacity: 0.93;
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 14%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active::after,
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||
background: linear-gradient(rgba(6, 10, 19, 24%) 0%, rgba(6, 10, 19, 48%) 100%), rgba(11, 19, 34, 8%);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous {
|
||||
filter: blur(var(--transparent-background-blur, 16px));
|
||||
@@ -745,9 +1163,7 @@ onUnmounted(() => {
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
||||
background:
|
||||
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
|
||||
rgba(128, 128, 128, 30%);
|
||||
background: linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%), rgba(128, 128, 128, 30%);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
@@ -766,14 +1182,15 @@ onUnmounted(() => {
|
||||
.global-blur-layer {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-bg-decor *,
|
||||
.app-wrapper--decorative-motion-paused {
|
||||
.login-ambient-light *,
|
||||
.login-logo,
|
||||
.login-logo-wrapper,
|
||||
.login-logo-wrapper::before,
|
||||
.login-title,
|
||||
.login-subtitle,
|
||||
.agent-assistant-fab * {
|
||||
.login-subtitle {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {})
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
media_source: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 媒体类型
|
||||
type_name?: string
|
||||
}
|
||||
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
@@ -15,7 +27,13 @@ export interface Subscribe {
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
@@ -116,6 +134,12 @@ export interface SubscribeShare {
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -218,6 +242,14 @@ export interface TransferHistory {
|
||||
tvdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季Sxx
|
||||
seasons?: string
|
||||
// 集Exx
|
||||
@@ -238,7 +270,7 @@ export interface TransferHistory {
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// 类型 电影、电视剧、合集
|
||||
type?: string
|
||||
@@ -259,7 +291,11 @@ export interface MediaInfo {
|
||||
// 豆瓣ID
|
||||
douban_id?: string
|
||||
// Bangumi ID
|
||||
bangumi_id?: string
|
||||
bangumi_id?: string | number
|
||||
// AniList ID
|
||||
anilist_id?: number
|
||||
// AniDB ID
|
||||
anidb_id?: number
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
@@ -416,7 +452,7 @@ export interface TmdbEpisode {
|
||||
|
||||
// TMDB人物信息
|
||||
export interface Person {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// ID
|
||||
id?: number
|
||||
@@ -964,7 +1000,7 @@ export interface User {
|
||||
is_superuser: boolean
|
||||
// 头像
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
// 是否开启二次验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
@@ -1353,6 +1389,8 @@ export interface MediaServerConf {
|
||||
enabled: boolean
|
||||
// 同步媒体体库列表
|
||||
sync_libraries?: string[]
|
||||
// 自动同步间隔(小时),为空时使用旧全局配置
|
||||
sync_interval?: number | null
|
||||
}
|
||||
|
||||
// 文件整理目录配置
|
||||
@@ -1513,6 +1551,14 @@ export interface TransferForm {
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string | null
|
||||
// 季号
|
||||
season?: number
|
||||
// 类型
|
||||
@@ -1541,6 +1587,8 @@ export interface TransferForm {
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
// 清理已有成功记录后重新整理
|
||||
reorganize: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
@@ -1567,6 +1615,14 @@ export interface ManualTransferTargetPathData {
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理命中的成功历史摘要
|
||||
export interface ManualTransferHistoryInfo {
|
||||
// 是否应执行重新整理
|
||||
reorganize: boolean
|
||||
// 命中的成功历史数量
|
||||
history_count: number
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
|
||||
@@ -35,10 +35,13 @@ interface AgentAssistantEntryBubbleInput {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
|
||||
motionActive?: boolean
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
active: true,
|
||||
motionActive: true,
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
@@ -214,7 +217,7 @@ const {
|
||||
playAction: playAgentPetAction,
|
||||
scheduleRandomAction: scheduleFabRandomAction,
|
||||
} = useAgentPetMachine({
|
||||
active: () => props.active,
|
||||
active: () => props.active && props.motionActive,
|
||||
docked: fabDocked,
|
||||
dragging: fabDragging,
|
||||
pressed: fabPressed,
|
||||
@@ -237,8 +240,7 @@ function getViewportSize() {
|
||||
|
||||
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
|
||||
return {
|
||||
height:
|
||||
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
|
||||
}
|
||||
}
|
||||
@@ -826,17 +828,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
|
||||
|
||||
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
|
||||
function queueFabPointerUpdate(clientX: number, clientY: number) {
|
||||
if (!props.active) return
|
||||
if (!props.active || !props.motionActive) return
|
||||
|
||||
fabPendingPointerPoint = { clientX, clientY }
|
||||
if (fabPointerFrame) return
|
||||
|
||||
fabPointerFrame = window.requestAnimationFrame(() => {
|
||||
fabPointerFrame = 0
|
||||
if (!fabPendingPointerPoint) return
|
||||
|
||||
updateFabPointerFromPoint(fabPendingPointerPoint)
|
||||
const point = fabPendingPointerPoint
|
||||
fabPendingPointerPoint = null
|
||||
if (!point || !props.active || !props.motionActive) return
|
||||
|
||||
updateFabPointerFromPoint(point)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,22 +853,6 @@ function updateFabPointer(event: PointerEvent) {
|
||||
queueFabPointerUpdate(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 清理入口自动贴边计时器。
|
||||
function clearFabIdleTimer() {
|
||||
if (fabIdleTimer === null) return
|
||||
@@ -895,11 +882,20 @@ function suppressNextFabClick() {
|
||||
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。
|
||||
function scheduleFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return
|
||||
if (
|
||||
!props.active ||
|
||||
!props.motionActive ||
|
||||
fabDocked.value ||
|
||||
hasKeepOpenFabBubbles.value ||
|
||||
fabRandomAction.value ||
|
||||
!shouldFabAutoDock()
|
||||
)
|
||||
return
|
||||
|
||||
fabIdleTimer = window.setTimeout(() => {
|
||||
fabIdleTimer = null
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return
|
||||
if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
|
||||
return
|
||||
|
||||
if (fabRandomAction.value) {
|
||||
scheduleFabAutoDock()
|
||||
@@ -915,14 +911,36 @@ function pauseFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移。
|
||||
function cancelFabPointerUpdate() {
|
||||
if (fabPointerFrame) {
|
||||
window.cancelAnimationFrame(fabPointerFrame)
|
||||
fabPointerFrame = 0
|
||||
}
|
||||
|
||||
fabPendingPointerPoint = null
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
cancelFabPointerUpdate()
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
cancelFabPointerUpdate()
|
||||
window.removeEventListener('pointermove', handleGlobalFabPointer)
|
||||
window.removeEventListener('pointerdown', handleGlobalFabPointer)
|
||||
}
|
||||
@@ -1432,6 +1450,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.motionActive,
|
||||
motionActive => {
|
||||
if (motionActive) {
|
||||
if (props.active && shouldFabAutoDock()) scheduleFabAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
clearFabIdleTimer()
|
||||
resetFabPointer()
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearFabIdleTimer)
|
||||
onScopeDispose(clearFabSuppressNextClickTimer)
|
||||
onScopeDispose(resetFabBubbles)
|
||||
@@ -1752,12 +1783,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-notification::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--v-theme-primary), 0.1),
|
||||
transparent 48%
|
||||
),
|
||||
rgba(var(--v-theme-surface), 0.94);
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-success::before {
|
||||
@@ -1778,11 +1805,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-toast::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
|
||||
transparent 54%
|
||||
),
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
|
||||
rgba(var(--v-theme-surface), 0.95);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,12 @@ const userStore = useUserStore()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
/** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */
|
||||
motionActive?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
motionActive: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -211,13 +214,11 @@ let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
let userAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let pendingStreamRecovery:
|
||||
| {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
}
|
||||
| null = null
|
||||
let pendingStreamRecovery: {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
} | null = null
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
@@ -250,12 +251,11 @@ const filteredSlashCommands = computed(() => {
|
||||
const query = slashCommandQuery.value
|
||||
if (!query) return slashCommands.value
|
||||
|
||||
return slashCommands.value
|
||||
.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
return slashCommands.value.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
// 判断是否展示命令建议浮层。
|
||||
const showSlashCommandMenu = computed(
|
||||
@@ -1956,6 +1956,7 @@ onScopeDispose(() => {
|
||||
<aside
|
||||
v-show="isOpen"
|
||||
class="agent-assistant-panel"
|
||||
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
|
||||
:style="drawerStyle"
|
||||
role="dialog"
|
||||
:aria-label="t('agentAssistant.title')"
|
||||
@@ -2515,13 +2516,16 @@ onScopeDispose(() => {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.24rem;
|
||||
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
|
||||
inline-size: 0.22rem;
|
||||
inset-block-start: 0.16rem;
|
||||
}
|
||||
|
||||
.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
|
||||
animation: agent-fab-blink 4.8s ease-in-out 1;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye--left {
|
||||
inset-inline-start: 0.22rem;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
@@ -23,6 +25,17 @@ function handleAssistantPreview(value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
<AgentAssistantEntry
|
||||
ref="entryRef"
|
||||
:active="!panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
:thinking="thinking"
|
||||
@open="openPanel"
|
||||
/>
|
||||
<AgentAssistantPanel
|
||||
v-model="panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
@assistant-preview="handleAssistantPreview"
|
||||
@thinking-change="thinking = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
71
src/components/agent/__tests__/AgentAssistantEntry.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
let animationFrameCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextAnimationFrameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
animationFrameCallbacks = new Map()
|
||||
nextAnimationFrameId = 1
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++
|
||||
animationFrameCallbacks.set(id, callback)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
animationFrameCallbacks.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const pointerEvent = new Event('pointermove')
|
||||
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
|
||||
const frameCountBeforePointer = animationFrameCallbacks.size
|
||||
window.dispatchEvent(pointerEvent)
|
||||
const pointerFrameId = nextAnimationFrameId - 1
|
||||
|
||||
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
|
||||
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await wrapper.setProps({ motionActive: false })
|
||||
await nextTick()
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
|
||||
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PET_RANDOM_ACTION_MIN_DELAY } from '../agentPetActions'
|
||||
import { useAgentPetMachine } from '../useAgentPetMachine'
|
||||
|
||||
describe('useAgentPetMachine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs finite random actions and clears the queue when decorative motion stops', async () => {
|
||||
const active = ref(true)
|
||||
const docked = ref(false)
|
||||
const dragging = ref(false)
|
||||
const pressed = ref(false)
|
||||
const thinking = ref(false)
|
||||
const scope = effectScope()
|
||||
const machine = scope.run(() =>
|
||||
useAgentPetMachine({
|
||||
active,
|
||||
docked,
|
||||
dragging,
|
||||
pressed,
|
||||
scheduleAutoDock: vi.fn(),
|
||||
shouldAutoDock: () => false,
|
||||
thinking,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(machine).toBeDefined()
|
||||
|
||||
machine?.scheduleRandomAction()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).toBe('wave')
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(machine?.currentAction.value).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).not.toBeNull()
|
||||
|
||||
scope.stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,6 @@
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
@@ -58,7 +57,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
@@ -97,7 +95,6 @@
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
@@ -136,7 +133,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
@@ -204,14 +200,12 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
@@ -225,13 +219,11 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@@ -257,8 +249,7 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
@@ -315,83 +306,6 @@
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
193
src/components/auth/LoginMfaStep.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MfaMethod } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
/** 当前验证步骤的错误信息。 */
|
||||
errorMessage: string
|
||||
/** 密码验证通过后服务端声明的可用方式。 */
|
||||
methods: MfaMethod[]
|
||||
/** OTP 提交状态。 */
|
||||
otpLoading: boolean
|
||||
/** 当前输入的 OTP。 */
|
||||
otpPassword: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'back'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hasOtp = computed(() => props.methods.includes('otp'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mfa-step" :class="{ 'mfa-step--unavailable': !hasOtp }" aria-labelledby="mfa-step-title">
|
||||
<header class="mfa-step__header">
|
||||
<VBtn
|
||||
data-testid="mfa-back"
|
||||
icon="mdi-arrow-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
:aria-label="t('login.mfa.back')"
|
||||
:disabled="props.otpLoading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div>
|
||||
<h2 id="mfa-step-title" class="mfa-step__title">{{ t('login.secondaryVerification') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form v-if="hasOtp" data-testid="mfa-otp-form" class="mfa-step__method" @submit.prevent="emit('otp')">
|
||||
<p class="mfa-step__description">{{ t('login.mfa.otpPrompt') }}</p>
|
||||
<div class="mfa-step__field">
|
||||
<VIcon icon="mdi-shield-key" class="mfa-step__field-icon" aria-hidden="true" />
|
||||
<input
|
||||
:value="props.otpPassword"
|
||||
class="mfa-step__input"
|
||||
type="text"
|
||||
name="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
:placeholder="t('login.otpCode')"
|
||||
:aria-label="t('login.otpCode')"
|
||||
autofocus
|
||||
:disabled="props.otpLoading"
|
||||
@input="emit('update:otpPassword', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="mfa-step__submit"
|
||||
prepend-icon="mdi-login"
|
||||
:loading="props.otpLoading"
|
||||
:disabled="!props.otpPassword"
|
||||
>
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</form>
|
||||
|
||||
<VAlert v-if="props.errorMessage" class="mfa-step__alert" type="error" variant="tonal" role="alert">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mfa-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mfa-step__header {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
margin-block-end: 20px;
|
||||
}
|
||||
|
||||
.mfa-step__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfa-step__field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.38);
|
||||
min-block-size: 52px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-surface), 0.13);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background 220ms ease;
|
||||
}
|
||||
|
||||
.mfa-step__field:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.mfa-step__field-icon {
|
||||
position: absolute;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
inset-inline-start: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mfa-step__input {
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
block-size: 50px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font: inherit;
|
||||
inline-size: 100%;
|
||||
outline: none;
|
||||
padding-block: 0;
|
||||
padding-inline: 48px 16px;
|
||||
}
|
||||
|
||||
.mfa-step__input::placeholder {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mfa-step__input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--v-disabled-opacity);
|
||||
}
|
||||
|
||||
.mfa-step__submit {
|
||||
flex: 0 0 48px !important;
|
||||
block-size: 48px !important;
|
||||
max-block-size: 48px !important;
|
||||
min-block-size: 48px !important;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mfa-step__description {
|
||||
margin: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__alert {
|
||||
margin-block-start: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__header {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__alert {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
47
src/components/auth/__tests__/LoginMfaStep.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const buttonStub = {
|
||||
emits: ['click'],
|
||||
props: ['loading'],
|
||||
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
}
|
||||
|
||||
function mountStep(methods: Array<'otp'>) {
|
||||
return shallowMount(LoginMfaStep, {
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: slotStub,
|
||||
VBtn: buttonStub,
|
||||
VIcon: true,
|
||||
VTextField: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
errorMessage: '',
|
||||
methods,
|
||||
otpLoading: false,
|
||||
otpPassword: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoginMfaStep', () => {
|
||||
it('shows only the OTP form for an OTP-only account', () => {
|
||||
const wrapper = mountStep(['otp'])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no authentication action when the server declares no supported method', () => {
|
||||
const wrapper = mountStep([])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -376,7 +376,7 @@ watch(recognitionSource, () => {
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
@@ -432,7 +432,7 @@ watch(recognitionSource, () => {
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
@@ -590,7 +590,6 @@ watch(recognitionSource, () => {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
@@ -166,12 +166,17 @@ function getExistsStatusKey() {
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
const mediaId = getMediaId()
|
||||
if (subscribe.media_source && subscribe.media_id) {
|
||||
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||
return mediaId === `${prefix}:${subscribe.media_id}`
|
||||
}
|
||||
if (subscribe.mediaid) return mediaId === subscribe.mediaid
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
if (props.media?.anilist_id && subscribe.anilistid) return props.media.anilist_id === subscribe.anilistid
|
||||
return false
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
@@ -459,6 +464,7 @@ onBeforeUnmount(() => {
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'media-card--image-loaded': isImageLoaded,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
@@ -535,7 +541,8 @@ onBeforeUnmount(() => {
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,11 @@ const props = defineProps({
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
// 旧版全局同步间隔,用作服务器未单独设置时的默认值
|
||||
defaultSyncInterval: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -56,6 +61,7 @@ function openMediaServerInfoDialog() {
|
||||
{
|
||||
mediaserver: props.mediaserver,
|
||||
mediaservers: props.mediaservers,
|
||||
defaultSyncInterval: props.defaultSyncInterval,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
|
||||
@@ -38,6 +38,9 @@ function getPersonImage() {
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.medium
|
||||
} else if (personProps.person?.source === 'anilist') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.large || personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ async function goPlay() {
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
crossorigin="anonymous"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@@ -216,8 +217,7 @@ async function goPlay() {
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
background: linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__percent,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -35,8 +35,8 @@ const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -76,8 +76,8 @@ function closeInstallProgress() {
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
@@ -236,15 +236,13 @@ onUnmounted(() => {
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -40,8 +40,8 @@ const { t } = useI18n()
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -98,8 +98,8 @@ watch(
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
@@ -573,17 +573,15 @@ watch(
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
|
||||
@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
@@ -63,43 +63,44 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
crossorigin="anonymous"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -264,9 +264,14 @@ async function editSubscribeDialog() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_source && props.media?.media_id) {
|
||||
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||
return `${prefix}:${props.media.media_id}`
|
||||
}
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||
else return props.media?.mediaid
|
||||
}
|
||||
|
||||
@@ -480,13 +485,7 @@ function handleCardClick() {
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<VImg :src="backdropUrl || posterUrl" :aspect-ratio="2" cover position="top" @load="imageLoadHandler">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
@@ -535,12 +534,7 @@ function handleCardClick() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn
|
||||
v-if="!props.sortable"
|
||||
class="subscribe-card-mobile-menu"
|
||||
size="small"
|
||||
@click.stop
|
||||
>
|
||||
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -576,112 +570,114 @@ function handleCardClick() {
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex min-w-0 max-w-full align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="props.media?.username && props.sortable"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0 me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.username"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span
|
||||
v-if="props.media?.username"
|
||||
class="min-w-0 truncate text-subtitle-2 text-white"
|
||||
:title="props.media?.username"
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div
|
||||
class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ..."
|
||||
>
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex min-w-0 max-w-full align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="props.media?.username && props.sortable"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0 me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.username"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span
|
||||
v-if="props.media?.username"
|
||||
class="min-w-0 truncate text-subtitle-2 text-white"
|
||||
:title="props.media?.username"
|
||||
>
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
||||
形成两段语义;其余订阅维持原有单段进度条
|
||||
-->
|
||||
<VProgressLinear
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -47,9 +47,14 @@ const posterUrl = computed(() => {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_source && props.media?.media_id) {
|
||||
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||
return `${prefix}:${props.media.media_id}`
|
||||
}
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
@@ -102,61 +107,65 @@ function doDelete() {
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_title }}
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div
|
||||
class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calendar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calendar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -461,6 +461,55 @@ describe('MediaCard', () => {
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'structured TMDB identity',
|
||||
createMediaInfo({
|
||||
media_id: 'series-9554',
|
||||
mediaid_prefix: undefined,
|
||||
season: 2,
|
||||
source: 'themoviedb',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
}),
|
||||
'tmdb:series-9554',
|
||||
[
|
||||
{ id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' },
|
||||
{ id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' },
|
||||
],
|
||||
[4],
|
||||
],
|
||||
[
|
||||
'legacy AniList identity',
|
||||
createMediaInfo({ anilist_id: 154588, season: 2, source: 'anilist', tmdb_id: undefined, type: '电视剧' }),
|
||||
'anilist:154588',
|
||||
[
|
||||
{ anilistid: 154588, id: 95, season: 1, type: '电视剧' },
|
||||
{ anilistid: 154589, id: 96, season: 3, type: '电视剧' },
|
||||
],
|
||||
[1],
|
||||
],
|
||||
])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => {
|
||||
server.use(
|
||||
querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler(subscribes),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: {} }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: expected })
|
||||
})
|
||||
|
||||
it('updates image badges on load and falls back after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
@@ -499,6 +548,34 @@ describe('MediaCard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the AniList source badge after the poster loads', async () => {
|
||||
const media = createMediaInfo({
|
||||
anilist_id: 154588,
|
||||
poster_path: '/original/anilist.jpg',
|
||||
source: 'anilist',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['load'],
|
||||
props: { src: String },
|
||||
setup(_props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
|
||||
},
|
||||
})
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||
permissions: {
|
||||
|
||||
39
src/components/cards/__tests__/MediaServerImageCards.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utils/appDeepLink', () => ({
|
||||
openMediaServerItem: vi.fn(),
|
||||
}))
|
||||
|
||||
const media: MediaServerPlayItem = {
|
||||
id: 'media-1',
|
||||
image: 'https://media.example.com/poster.jpg',
|
||||
title: 'Test media',
|
||||
}
|
||||
|
||||
const VImgStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
crossorigin: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { crossorigin: props.crossorigin, src: props.src }),
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['PosterCard', PosterCard],
|
||||
['PlayingBackdropCard', PlayingBackdropCard],
|
||||
])('%s image request mode', (_name, component) => {
|
||||
it('loads the media server image anonymously', async () => {
|
||||
const { container } = await renderWithProviders(component, {
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
props: { media },
|
||||
})
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
|
||||
})
|
||||
})
|
||||
@@ -164,7 +164,16 @@ describe('SubscribeCard display and progress', () => {
|
||||
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
||||
])(
|
||||
'normalizes %s for wash progress and badges',
|
||||
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
|
||||
async (
|
||||
_case,
|
||||
bestVersion,
|
||||
bestVersionFull,
|
||||
completedEpisode,
|
||||
type,
|
||||
expectedProgress,
|
||||
expectedWash,
|
||||
expectedFull,
|
||||
) => {
|
||||
const { container } = await renderCard({
|
||||
best_version: bestVersion,
|
||||
best_version_full: bestVersionFull,
|
||||
@@ -291,10 +300,20 @@ describe('SubscribeCard interaction boundaries', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
|
||||
['TMDB before all fallbacks', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||
['Douban before Bangumi', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||
['Bangumi before custom', { bangumiid: 33, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||
[
|
||||
'AniList before legacy custom',
|
||||
{ anilistid: 55, bangumiid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||
'anilist:55',
|
||||
],
|
||||
['selected primary identity', { media_id: '66', media_source: 'anilist', tmdbid: 11 }, 'anilist:66'],
|
||||
[
|
||||
'custom media ID last',
|
||||
{ bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||
'custom:44',
|
||||
],
|
||||
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
||||
const { container, media } = await renderCard(identifiers)
|
||||
|
||||
@@ -364,30 +383,34 @@ describe('SubscribeCard item operations', () => {
|
||||
['confirmation cancellation', false, 200, { success: true }, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'keeps status unchanged after %s',
|
||||
async (_case, confirmed, status, response, expectedError) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||
] as const)('keeps status unchanged after %s', async (_case, confirmed, status, response, expectedError) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
||||
['confirmation cancellation', false, 200, { success: true }, null, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
|
||||
[
|
||||
'business failure',
|
||||
true,
|
||||
200,
|
||||
{ message: 'rejected', success: false },
|
||||
'error',
|
||||
'卡片测试媒体 重置失败:rejected',
|
||||
],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'handles reset %s without speculative state',
|
||||
@@ -423,16 +446,19 @@ describe('SubscribeCard item operations', () => {
|
||||
it.each([
|
||||
['success', 200, { success: true }, true, null],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
||||
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, media } = await renderCard()
|
||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||
] as const)(
|
||||
'handles delete %s without a synthetic business-failure branch',
|
||||
async (_case, status, response, removed, error) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, media } = await renderCard()
|
||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '取消订阅')
|
||||
await chooseMenuItem(container, '取消订阅')
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('SubscribeShareCard', () => {
|
||||
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
|
||||
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
|
||||
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
|
||||
['AniList without other IDs', { anilistid: 4404, bangumiid: undefined, tmdbid: undefined }, 'anilist:4404'],
|
||||
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
|
||||
const { container, media } = await renderCard(ids)
|
||||
const poster = await loadPoster(container)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type { DownloaderConf, MediaDataSource, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -18,7 +18,11 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSource = ref<MediaDataSource>(
|
||||
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
|
||||
? globalSettings.RECOGNIZE_SOURCE
|
||||
: 'themoviedb',
|
||||
)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -51,11 +55,19 @@ const loading = ref(false)
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
// 当前数据源对应的原生ID标签。
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
@@ -140,11 +152,9 @@ async function addDownload() {
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
if (mediaId.value) {
|
||||
payload.media_source = mediaSource.value
|
||||
payload.media_id = mediaId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
@@ -269,23 +279,9 @@ onMounted(() => {
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
@@ -307,13 +303,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type { MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
@@ -17,7 +17,11 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSource = ref<MediaDataSource>(
|
||||
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
|
||||
? globalSettings.RECOGNIZE_SOURCE
|
||||
: 'themoviedb',
|
||||
)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -43,11 +47,19 @@ const loading = ref(false)
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
// 当前数据源对应的原生ID标签。
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
@@ -98,11 +110,9 @@ async function addSubtitleDownload() {
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
if (mediaId.value) {
|
||||
payload.media_source = mediaSource.value
|
||||
payload.media_id = mediaId.value
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||
@@ -221,23 +231,9 @@ onMounted(() => {
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
@@ -258,13 +254,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MediaDataSource } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -20,12 +21,28 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
||||
(event: 'confirm', payload: { mediaSource?: MediaDataSource; mediaId?: string }): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const tmdbId = ref<number | undefined>()
|
||||
const doubanId = ref<string | undefined>()
|
||||
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
|
||||
const mediaId = ref<string>()
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||
])
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
themoviedb: t('setting.cache.reidentifyDialog.tmdbId'),
|
||||
douban: t('setting.cache.reidentifyDialog.doubanId'),
|
||||
bangumi: t('setting.cache.reidentifyDialog.bangumiId'),
|
||||
anilist: t('setting.cache.reidentifyDialog.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value] || t('setting.cache.reidentifyDialog.mediaId')
|
||||
})
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -38,8 +55,8 @@ const visible = computed({
|
||||
// 提交重新识别参数给缓存页执行接口调用。
|
||||
function submitReidentify() {
|
||||
emit('confirm', {
|
||||
doubanId: doubanId.value,
|
||||
tmdbId: tmdbId.value,
|
||||
mediaSource: mediaSource.value,
|
||||
mediaId: mediaId.value?.trim() || undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -59,20 +76,20 @@ function submitReidentify() {
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="props.recognizeSource === 'themoviedb'"
|
||||
v-model="tmdbId"
|
||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
<VSelect
|
||||
v-model="mediaSource"
|
||||
:items="mediaSourceItems"
|
||||
:label="t('setting.cache.reidentifyDialog.mediaSource')"
|
||||
:hint="t('setting.cache.reidentifyDialog.mediaSourceHint')"
|
||||
prepend-inner-icon="mdi-database-search"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
||||
v-model="mediaId"
|
||||
:label="mediaIdLabel"
|
||||
:hint="t('setting.cache.reidentifyDialog.mediaIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
|
||||
@@ -74,6 +74,7 @@ function closeDialog() {
|
||||
:label="t('file.newName')"
|
||||
:loading="loading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
mobile-control-width="80%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="item && item.type == 'dir'" cols="12">
|
||||
|
||||
@@ -97,9 +97,14 @@ const posterUrl = computed(() => {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_source && props.media?.media_id) {
|
||||
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||
return `${prefix}:${props.media.media_id}`
|
||||
}
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
|
||||
566
src/components/dialog/GlassSettingsDialog.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
cancelGlassPreview,
|
||||
commitGlassPreview,
|
||||
previewGlassSettings,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerGlassAppearance,
|
||||
type ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
GLASS_OPTICAL_STRENGTH_MIN,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassOpticalPresetParameters,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalPreset,
|
||||
} from '@/utils/glassOptics'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
||||
const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||
const isSaving = ref(false)
|
||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
||||
const showsDynamicTuning = computed(() => usesRealtimeOptics.value)
|
||||
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||
)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
if (!value) cancelGlassPreview()
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 父级控制弹窗生命周期时,同步结束旧预览并重建持久化草稿。 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value, previous) => {
|
||||
if (value) {
|
||||
draftAppearance.value = settings.value.glassAppearance
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftQuality.value = settings.value.glassQuality
|
||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
||||
draftTranslationStrength.value = settings.value.glassTranslationStrength
|
||||
draftTransparencyStrength.value = settings.value.glassTransparencyStrength
|
||||
} else if (previous) {
|
||||
cancelGlassPreview()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const appearanceOptions: Array<{
|
||||
label: string
|
||||
value: ThemeCustomizerGlassAppearance
|
||||
}> = [
|
||||
{ label: 'theme.glassAppearanceClear', value: 'clear' },
|
||||
{ label: 'theme.glassAppearanceTinted', value: 'tinted' },
|
||||
{ label: 'theme.glassAppearanceFrosted', value: 'frosted' },
|
||||
]
|
||||
|
||||
const qualityOptions: Array<{
|
||||
hint: string
|
||||
label: string
|
||||
value: ThemeCustomizerGlassQuality
|
||||
}> = [
|
||||
{ hint: 'theme.glassQualityCssHint', label: 'theme.glassQualityCss', value: 'css' },
|
||||
{ hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||
{ hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' },
|
||||
]
|
||||
const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '')
|
||||
const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
||||
{ label: 'theme.glassPresetNatural', value: 'natural' },
|
||||
{ label: 'theme.glassPresetGlide', value: 'glide' },
|
||||
{ label: 'theme.glassPresetLiquid', value: 'liquid' },
|
||||
]
|
||||
const visiblePresetOptions = computed(() =>
|
||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
||||
)
|
||||
|
||||
/** 仅允许已实现的材质进入待保存设置。 */
|
||||
function updateAppearance(value: unknown) {
|
||||
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
||||
|
||||
draftAppearance.value = value
|
||||
previewGlassSettings({ glassAppearance: value })
|
||||
}
|
||||
|
||||
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
||||
function updateQuality(value: unknown) {
|
||||
const option = qualityOptions.find(item => item.value === value)
|
||||
if (!option) return
|
||||
|
||||
draftQuality.value = option.value
|
||||
previewGlassSettings({ glassQuality: option.value })
|
||||
}
|
||||
|
||||
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
|
||||
function applyPreset(value: unknown) {
|
||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||
if (!availablePresets.value.includes(value)) return
|
||||
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
||||
draftPreset.value = value
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
draftTransmissionStrength.value = parameters.transmission
|
||||
draftTranslationStrength.value = parameters.translation
|
||||
draftTransparencyStrength.value = parameters.transparency
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||
function updateTranslationStrength(value: unknown) {
|
||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
||||
}
|
||||
|
||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||
function updateDeformationStrength(value: unknown) {
|
||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
||||
}
|
||||
|
||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||
function updateFlowStrength(value: unknown) {
|
||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
||||
}
|
||||
|
||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||
function updateReflectionStrength(value: unknown) {
|
||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
||||
}
|
||||
|
||||
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
||||
function updateTransmissionStrength(value: unknown) {
|
||||
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
|
||||
}
|
||||
|
||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||
function updateTransparencyStrength(value: unknown) {
|
||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
||||
}
|
||||
|
||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
||||
function resetSettings() {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||
async function saveSettings() {
|
||||
if (isSaving.value) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
commitGlassPreview()
|
||||
visible.value = false
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗的任意未保存销毁路径都应恢复持久化快照。
|
||||
onScopeDispose(cancelGlassPreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-blur-radial" class="me-2" />
|
||||
{{ t('theme.glassSettings') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="glass-settings-dialog__body">
|
||||
<section>
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassAppearance') }}</h3>
|
||||
<VBtnToggle
|
||||
:model-value="draftAppearance"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__appearance"
|
||||
@update:model-value="updateAppearance"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in appearanceOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__appearance-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassQuality') }}</h3>
|
||||
<VBtnToggle
|
||||
:model-value="draftQuality"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__quality"
|
||||
@update:model-value="updateQuality"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in qualityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__quality-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t(qualityHint) }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="usesRealtimeOptics">
|
||||
<div class="glass-settings-dialog__preset-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassPreset') }}</h3>
|
||||
</div>
|
||||
<VBtnToggle
|
||||
:model-value="activePreset"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__preset"
|
||||
@update:model-value="applyPreset"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in visiblePresetOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__preset-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</section>
|
||||
|
||||
<section class="glass-settings-dialog__tuning">
|
||||
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassMaterialTuning') }}</h3>
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransparencyStrength') }}</h3>
|
||||
<output>{{ draftTransparencyStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTransparencyStrength"
|
||||
:aria-label="t('theme.glassTransparencyStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTransparencyStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransmissionStrength') }}</h3>
|
||||
<output>{{ draftTransmissionStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTransmissionStrength"
|
||||
:aria-label="t('theme.glassTransmissionStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTransmissionStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassReflectionStrength') }}</h3>
|
||||
<output>{{ draftReflectionStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftReflectionStrength"
|
||||
:aria-label="t('theme.glassReflectionStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateReflectionStrength"
|
||||
/>
|
||||
|
||||
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
|
||||
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3>
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTranslationStrength') }}</h3>
|
||||
<output>{{ draftTranslationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTranslationStrength"
|
||||
:aria-label="t('theme.glassTranslationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTranslationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDeformationStrength') }}</h3>
|
||||
<output>{{ draftDeformationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftDeformationStrength"
|
||||
:aria-label="t('theme.glassDeformationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateDeformationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassFlowStrength') }}</h3>
|
||||
<output>{{ draftFlowStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftFlowStrength"
|
||||
:aria-label="t('theme.glassFlowStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateFlowStrength"
|
||||
/>
|
||||
</div>
|
||||
<p class="glass-settings-dialog__hint">
|
||||
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }}
|
||||
</p>
|
||||
</section>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||
{{ t('common.reset') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.glass-settings-dialog__body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 24px;
|
||||
grid-auto-rows: max-content;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin: 0 0 10px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__hint {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__group-label {
|
||||
margin: 0 0 14px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
output {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header--spaced,
|
||||
.glass-settings-dialog__slider-header + .glass-settings-dialog__slider-header {
|
||||
margin-block-start: 18px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__live-controls {
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
margin-block-start: 24px;
|
||||
padding-block-start: 20px;
|
||||
|
||||
:deep(.v-slider) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance,
|
||||
.glass-settings-dialog__quality,
|
||||
.glass-settings-dialog__preset {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||
gap: 4px;
|
||||
inline-size: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__quality {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-block-start: 10px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option {
|
||||
block-size: 32px !important;
|
||||
inline-size: 100%;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option,
|
||||
.glass-settings-dialog__quality-option,
|
||||
.glass-settings-dialog__preset-option {
|
||||
border: 0 !important;
|
||||
border-radius: 7px !important;
|
||||
box-shadow: none !important;
|
||||
min-inline-size: 0;
|
||||
inline-size: 100%;
|
||||
letter-spacing: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__quality-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
errorMessage?: string
|
||||
modelValue?: boolean
|
||||
otpPassword?: string
|
||||
passkeyLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
errorMessage: '',
|
||||
modelValue: true,
|
||||
otpPassword: '',
|
||||
passkeyLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'passkey'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const otpValue = computed({
|
||||
get: () => props.otpPassword,
|
||||
set: value => emit('update:otpPassword', value),
|
||||
})
|
||||
|
||||
// 提交 OTP 登录请求。
|
||||
function submitOtp() {
|
||||
emit('otp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
||||
<VCard>
|
||||
<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>
|
||||
|
||||
<VCard variant="tonal" class="mb-3">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitOtp">
|
||||
<VTextField
|
||||
v-model="otpValue"
|
||||
:label="t('login.otpCode')"
|
||||
:placeholder="t('login.otpPlaceholder')"
|
||||
type="text"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
class="mb-2"
|
||||
/>
|
||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="props.passkeyLoading"
|
||||
@click="emit('passkey')"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -26,6 +26,10 @@ const props = defineProps({
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
defaultSyncInterval: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -203,6 +207,20 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
@@ -243,7 +261,7 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
@@ -269,11 +287,26 @@ onMounted(() => {
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
:hint="t('mediaserver.passwordHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
@@ -335,6 +368,20 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
@@ -375,7 +422,7 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
@@ -390,6 +437,8 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
@@ -399,10 +448,37 @@ onMounted(() => {
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
:hint="t('mediaserver.passwordHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.access_code"
|
||||
:label="t('mediaserver.accessCode')"
|
||||
:hint="t('mediaserver.accessCodeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
@@ -443,7 +519,7 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
@@ -458,6 +534,8 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
@@ -467,24 +545,24 @@ onMounted(() => {
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
:hint="t('mediaserver.passwordHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -508,6 +586,22 @@ onMounted(() => {
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -553,6 +647,20 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
clearable
|
||||
:label="t('mediaserver.syncInterval')"
|
||||
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
|
||||
persistent-hint
|
||||
suffix="h"
|
||||
prepend-inner-icon="mdi-sync"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
|
||||
@@ -4,25 +4,20 @@ 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'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
@@ -36,11 +31,9 @@ const otpUri = ref('')
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
// 当前二次验证设置流程中输入的 6 位验证码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// OTP 初始化加载状态
|
||||
const otpLoading = ref(false)
|
||||
|
||||
@@ -132,14 +125,8 @@ async function judgeOtpPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
// 关闭当前用户的二次验证
|
||||
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'),
|
||||
@@ -241,7 +228,14 @@ watch(
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<VAlert
|
||||
v-if="secret"
|
||||
:title="secret"
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
class="my-4"
|
||||
:text="t('profile.secretKeyTip')"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
|
||||
@@ -6,11 +6,9 @@ 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 相关接口定义
|
||||
@@ -27,7 +25,6 @@ const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPass
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
@@ -44,11 +41,7 @@ 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)
|
||||
const passkeyTransactionToken = ref('')
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -90,16 +83,16 @@ async function registerPassKey() {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
})) as ApiResponse<{ options: string; transaction_token: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const { options, transaction_token: transactionToken } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
passkeyTransactionToken.value = transactionToken
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
@@ -138,7 +131,7 @@ async function registerPassKey() {
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
transaction_token: passkeyTransactionToken.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
@@ -202,7 +195,7 @@ watch(
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyTransactionToken.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
@@ -236,7 +229,7 @@ watch(
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
|
||||
<VCard 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>
|
||||
@@ -256,15 +249,6 @@ watch(
|
||||
</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
|
||||
|
||||
@@ -8,6 +8,7 @@ import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -43,6 +44,10 @@ const $toast = useToast()
|
||||
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 配置联邦组件沿用与其它插件宿主一致的原生订阅能力。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -168,7 +173,12 @@ onBeforeMount(async () => {
|
||||
<template>
|
||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
@@ -203,12 +213,13 @@ onBeforeMount(async () => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
@switch="emit('switch')"
|
||||
|
||||
@@ -6,6 +6,7 @@ import api from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -31,6 +32,10 @@ const { appMode } = usePWA()
|
||||
const $toast = useToast()
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 向联邦插件同时提供 prop 与 inject 形式的主程序原生订阅入口。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
// 组件是否已加载成功
|
||||
@@ -131,7 +136,12 @@ onMounted(() => {
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
@@ -153,11 +163,12 @@ onMounted(() => {
|
||||
/>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
:show_switch="show_switch"
|
||||
@action="handleAction"
|
||||
@switch="emit('switch')"
|
||||
|
||||
@@ -7,9 +7,11 @@ import { transferTypeOptions } from '@/api/constants'
|
||||
import {
|
||||
ApiResponse,
|
||||
FileItem,
|
||||
ManualTransferHistoryInfo,
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
MediaDataSource,
|
||||
MediaInfo,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
@@ -37,13 +39,22 @@ const props = defineProps({
|
||||
target_path: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||
])
|
||||
|
||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||
function getDefaultMediaSource(): MediaDataSource {
|
||||
const configuredSource = globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||
return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
@@ -89,6 +100,10 @@ const previewLoaded = ref(false)
|
||||
// 预览数据
|
||||
const previewData = ref<ManualTransferPreviewData>()
|
||||
|
||||
// 手动整理历史查询状态
|
||||
const manualHistoryLoading = ref(false)
|
||||
const manualHistoryCount = ref(0)
|
||||
|
||||
interface EpisodeFormatRecommendData {
|
||||
rule_name?: string
|
||||
rule_index?: number
|
||||
@@ -304,6 +319,8 @@ const transferForm = reactive<TransferForm>({
|
||||
logid: 0,
|
||||
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||
target_path: initialTargetPath,
|
||||
media_source: getDefaultMediaSource(),
|
||||
media_id: null,
|
||||
transfer_type: null,
|
||||
min_filesize: 0,
|
||||
scrape: initialTargetPath ? false : null,
|
||||
@@ -311,6 +328,24 @@ const transferForm = reactive<TransferForm>({
|
||||
library_type_folder: null,
|
||||
library_category_folder: null,
|
||||
episode_group: null,
|
||||
reorganize: Boolean(props.logids?.length),
|
||||
})
|
||||
|
||||
// 历史记录入口和文件浏览器命中的成功历史都属于重新整理。
|
||||
const isReorganize = computed(() => Boolean(props.logids?.length || transferForm.reorganize))
|
||||
|
||||
// 当前手动识别与刮削数据源。
|
||||
const mediaSource = computed(() => transferForm.media_source ?? 'themoviedb')
|
||||
|
||||
// 当前数据源对应的原生ID标签。
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
|
||||
@@ -403,28 +438,39 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听 TMDB 编号变化,自动加载可用剧集组并清空旧选择。
|
||||
// 监听媒体编号变化,仅在TMDB电视剧场景加载剧集组。
|
||||
watch(
|
||||
() => transferForm.tmdbid,
|
||||
tmdbid => {
|
||||
() => transferForm.media_id,
|
||||
mediaId => {
|
||||
transferForm.episode_group = null
|
||||
episodeGroups.value = []
|
||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||
if (transferForm.type_name !== '电视剧' || mediaSource.value !== 'themoviedb') return
|
||||
episodeGroupQueryTimer = setTimeout(() => getEpisodeGroups(tmdbid), 400)
|
||||
episodeGroupQueryTimer = setTimeout(() => getEpisodeGroups(mediaId ?? undefined), 400)
|
||||
},
|
||||
)
|
||||
|
||||
// 切换媒体类型或识别源时,非 TMDB 电视剧不保留剧集组选择。
|
||||
watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, source]) => {
|
||||
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
|
||||
getEpisodeGroups(transferForm.tmdbid)
|
||||
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.media_id) {
|
||||
getEpisodeGroups(transferForm.media_id)
|
||||
return
|
||||
}
|
||||
transferForm.episode_group = null
|
||||
episodeGroups.value = []
|
||||
})
|
||||
|
||||
// 切换数据源时清空上一来源的原生ID,避免把同一数字误传给新来源。
|
||||
watch(
|
||||
() => transferForm.media_source,
|
||||
(source, previousSource) => {
|
||||
if (previousSource && source !== previousSource) {
|
||||
transferForm.media_id = null
|
||||
mediaSelectorDialog.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => transferForm.episode_group,
|
||||
episodeGroup => {
|
||||
@@ -859,6 +905,8 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
||||
target_storage: normalizeOptionalText(transferForm.target_storage),
|
||||
target_path: normalizeTargetPath(transferForm.target_path),
|
||||
transfer_type: normalizeOptionalText(transferForm.transfer_type),
|
||||
media_source: mediaSource.value,
|
||||
media_id: normalizeOptionalText(transferForm.media_id),
|
||||
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
||||
}
|
||||
|
||||
@@ -881,6 +929,29 @@ async function requestManualTransfer<T = any>(
|
||||
return await api.post<ApiResponse<T>, ApiResponse<T>>(`transfer/manual?background=${background}`, payload)
|
||||
}
|
||||
|
||||
// 查询当前文件或目录是否存在成功整理历史,决定是否展示重新整理语义。
|
||||
async function loadManualTransferHistory() {
|
||||
if (props.logids?.length || !normalizedItems.value.length) return
|
||||
|
||||
manualHistoryLoading.value = true
|
||||
try {
|
||||
const payload =
|
||||
normalizedItems.value.length === 1 ? { fileitem: normalizedItems.value[0] } : { fileitems: normalizedItems.value }
|
||||
const result = await api.post<ApiResponse<ManualTransferHistoryInfo>, ApiResponse<ManualTransferHistoryInfo>>(
|
||||
'transfer/manual/history',
|
||||
payload,
|
||||
)
|
||||
if (!result.success) return
|
||||
|
||||
manualHistoryCount.value = result.data?.history_count ?? 0
|
||||
transferForm.reorganize = Boolean(result.data?.reorganize)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
manualHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||
async function loadEpisodeFormatRuleConfiguration() {
|
||||
try {
|
||||
@@ -1314,7 +1385,7 @@ async function transfer(background: boolean = false) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await Promise.all([loadDirectories(), loadManualTransferHistory()])
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1347,6 +1418,16 @@ onUnmounted(() => {
|
||||
<div class="reorganize-form-pane">
|
||||
<div class="reorganize-form-pane__content pa-6">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VAlert
|
||||
v-if="manualHistoryCount > 0"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
icon="mdi-history"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ t('dialog.reorganize.historyFound', { count: manualHistoryCount }) }}
|
||||
</VAlert>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
@@ -1385,7 +1466,7 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="transferForm.type_name"
|
||||
:label="t('dialog.reorganize.mediaType')"
|
||||
@@ -1399,25 +1480,22 @@ onUnmounted(() => {
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
:disabled="transferForm.type_name === ''"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="transferForm.media_source"
|
||||
:items="mediaSourceItems"
|
||||
:label="t('dialog.reorganize.mediaSource')"
|
||||
:hint="t('dialog.reorganize.mediaSourceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
prepend-inner-icon="mdi-database-search"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="transferForm.doubanid"
|
||||
v-model="transferForm.media_id"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:disabled="transferForm.type_name === ''"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
@@ -1437,7 +1515,7 @@ onUnmounted(() => {
|
||||
item-value="value"
|
||||
:item-props="episodeGroupItemProps"
|
||||
:loading="episodeGroupLoading"
|
||||
:disabled="!transferForm.tmdbid"
|
||||
:disabled="!transferForm.media_id"
|
||||
clearable
|
||||
:label="t('dialog.reorganize.episodeGroup')"
|
||||
:placeholder="t('dialog.reorganize.episodeGroupPlaceholder')"
|
||||
@@ -1595,10 +1673,11 @@ onUnmounted(() => {
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
:prepend-icon="isReorganize ? 'mdi-refresh' : 'mdi-arrow-right-bold'"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
:loading="manualHistoryLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
{{ isReorganize ? t('dialog.reorganize.reorganizeAgain') : t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
@@ -1744,18 +1823,10 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
<!-- 手动整理进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- TMDB ID搜索框 -->
|
||||
<!-- 媒体数据源ID搜索框 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector
|
||||
v-else
|
||||
v-model="transferForm.doubanid"
|
||||
v-model="transferForm.media_id"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
|
||||
188
src/components/dialog/ScrapeDialog.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts" setup>
|
||||
import { numberValidator } from '@/@validators'
|
||||
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'scrape', options: ManualScrapeOptions): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||
])
|
||||
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const mediaType = ref('')
|
||||
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
|
||||
const mediaId = ref<string | null>(null)
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const dialogSubtitle = computed(() => {
|
||||
if (props.items.length > 1) {
|
||||
return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
|
||||
}
|
||||
return t('dialog.reorganize.singleItemTitle', { path: props.items[0]?.path ?? '' })
|
||||
})
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const normalizedMediaId = mediaId.value?.trim()
|
||||
return !normalizedMediaId || /^\d+$/.test(normalizedMediaId)
|
||||
})
|
||||
|
||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
|
||||
function getDefaultMediaSource(): MediaDataSource {
|
||||
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||
return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||
}
|
||||
|
||||
// 将搜索结果媒体类型映射为手动刮削接口接受的类型名。
|
||||
function resolveMediaType(type?: string) {
|
||||
const normalizedType = type?.trim().toLowerCase()
|
||||
if (['电影', 'movie'].includes(normalizedType ?? '')) return '电影'
|
||||
if (['电视剧', 'tv', 'series'].includes(normalizedType ?? '')) return '电视剧'
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 选择搜索结果后同步媒体类型,减少手动填写出错。
|
||||
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
|
||||
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
|
||||
}
|
||||
|
||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// 提交本次手动刮削的请求级识别条件。
|
||||
function submitScrape() {
|
||||
const normalizedMediaId = mediaId.value?.trim()
|
||||
emit('scrape', {
|
||||
media_source: mediaSource.value,
|
||||
media_id: normalizedMediaId || undefined,
|
||||
type_name: mediaType.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 切换数据源时清空上一来源的原生 ID,避免错用同一编号。
|
||||
watch(mediaSource, () => {
|
||||
mediaId.value = null
|
||||
mediaSelectorDialog.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="45rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-auto-fix" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.manualScrape') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-6">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="mediaType"
|
||||
:label="t('dialog.reorganize.mediaType')"
|
||||
:items="[
|
||||
{ title: t('dialog.reorganize.auto'), value: '' },
|
||||
{ title: t('dialog.reorganize.movie'), value: '电影' },
|
||||
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
|
||||
]"
|
||||
:hint="t('dialog.reorganize.mediaTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="mediaSource"
|
||||
:items="mediaSourceItems"
|
||||
:label="t('dialog.reorganize.mediaSource')"
|
||||
:hint="t('dialog.reorganize.mediaSourceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-database-search"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:disabled="mediaType === ''"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
class="px-5"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitScrape"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-model="mediaId"
|
||||
:type="mediaSource"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
/>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -33,10 +33,6 @@ const router = useRouter()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
@@ -62,11 +58,6 @@ const hasAdminPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'admin')
|
||||
})
|
||||
|
||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||
const showCollectionSearch = computed(() => {
|
||||
return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false
|
||||
})
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
@@ -105,6 +96,83 @@ const searchOverlayProps = computed(() =>
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
||||
type MediaSearchType = 'media' | 'collection' | 'person'
|
||||
|
||||
interface MediaSearchSourceOption {
|
||||
label: string
|
||||
name: string
|
||||
value: MediaSearchSource
|
||||
}
|
||||
|
||||
interface MediaSearchAction {
|
||||
type: MediaSearchType
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。
|
||||
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
|
||||
media: 'themoviedb',
|
||||
collection: 'themoviedb',
|
||||
person: 'themoviedb',
|
||||
})
|
||||
|
||||
// 按后端实际能力限定每类搜索可选的数据源。
|
||||
const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSourceOption[]>>(() => {
|
||||
const themoviedb = {
|
||||
label: 'TMDB',
|
||||
name: t('discoverTabs.themoviedb'),
|
||||
value: 'themoviedb' as const,
|
||||
}
|
||||
const douban = {
|
||||
label: t('discoverTabs.douban'),
|
||||
name: t('discoverTabs.douban'),
|
||||
value: 'douban' as const,
|
||||
}
|
||||
const bangumi = {
|
||||
label: 'Bangumi',
|
||||
name: t('discoverTabs.bangumi'),
|
||||
value: 'bangumi' as const,
|
||||
}
|
||||
const anilist = {
|
||||
label: 'AniList',
|
||||
name: t('discoverTabs.anilist'),
|
||||
value: 'anilist' as const,
|
||||
}
|
||||
|
||||
return {
|
||||
media: [themoviedb, douban, bangumi, anilist],
|
||||
collection: [themoviedb],
|
||||
person: [themoviedb, douban],
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索项及其来源组共用同一份声明,避免显示能力与请求类型不一致。
|
||||
const mediaSearchActions = computed(() => {
|
||||
return [
|
||||
{
|
||||
type: 'media',
|
||||
icon: 'mdi-movie-search',
|
||||
title: `${t('recommend.categoryMovie')}、${t('recommend.categoryTV')}`,
|
||||
description: t('resource.title'),
|
||||
},
|
||||
{
|
||||
type: 'collection',
|
||||
icon: 'mdi-movie-filter',
|
||||
title: t('dialog.searchBar.collections'),
|
||||
description: t('dialog.searchBar.collectionSearch'),
|
||||
},
|
||||
{
|
||||
type: 'person',
|
||||
icon: 'mdi-account-search',
|
||||
title: t('browse.actor'),
|
||||
description: t('dialog.searchBar.actorSearch'),
|
||||
},
|
||||
] satisfies MediaSearchAction[]
|
||||
})
|
||||
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -141,7 +209,7 @@ function loadRecentSearches() {
|
||||
|
||||
/** 获取可参与全局搜索的导航菜单和设置入口。 */
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
const menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
getNavMenus(t).forEach(
|
||||
item =>
|
||||
@@ -318,8 +386,7 @@ function searchSubtitle() {
|
||||
}
|
||||
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
function searchMedia(searchType: MediaSearchType) {
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
@@ -327,6 +394,7 @@ function searchMedia(searchType: string) {
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType,
|
||||
source: selectedMediaSearchSources[searchType],
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
@@ -452,8 +520,10 @@ onMounted(() => {
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
id="global-media-search"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:aria-label="t('dialog.searchBar.searchPlaceholder')"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
@@ -471,8 +541,10 @@ onMounted(() => {
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
id="global-media-search"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:aria-label="t('dialog.searchBar.searchPlaceholder')"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
@@ -493,53 +565,52 @@ onMounted(() => {
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
v-for="action in mediaSearchActions"
|
||||
:key="action.type"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
class="search-result-item search-source-result-item mx-2 my-1"
|
||||
@click="searchMedia(action.type)"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
<VIcon :icon="action.icon" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ action.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
{{ action.description }}
|
||||
</VListItemSubtitle>
|
||||
<div class="search-item-source-row">
|
||||
<VBtnToggle
|
||||
v-model="selectedMediaSearchSources[action.type]"
|
||||
class="search-item-source-toggle"
|
||||
density="compact"
|
||||
mandatory
|
||||
role="group"
|
||||
selected-class="media-source-button--active"
|
||||
variant="text"
|
||||
:aria-label="t('dialog.searchBar.mediaSourceFor', { type: action.title })"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<VBtn
|
||||
v-for="source in mediaSearchSourceOptions[action.type]"
|
||||
:key="source.value"
|
||||
class="media-source-button"
|
||||
size="x-small"
|
||||
:value="source.value"
|
||||
:aria-label="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
:title="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
>
|
||||
{{ source.label }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
@@ -758,7 +829,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-else class="empty-hint">
|
||||
<div v-else class="empty-hint pt-3">
|
||||
<span class="text-body-1 text-medium-emphasis">{{ t('dialog.searchBar.emptySearchHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -896,8 +967,8 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown .search-content {
|
||||
@@ -908,6 +979,68 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.search-item-source-row {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
block-size: 0;
|
||||
inline-size: 100%;
|
||||
margin-block-start: 0;
|
||||
min-inline-size: 0;
|
||||
transition:
|
||||
block-size 0.15s ease,
|
||||
margin-block-start 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-row,
|
||||
.search-source-result-item:focus-within .search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
overflow: auto hidden;
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
block-size: 28px;
|
||||
max-inline-size: 100%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.98);
|
||||
transform-origin: center left;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-toggle,
|
||||
.search-source-result-item:focus-within .search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.media-source-button {
|
||||
block-size: 100% !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72) !important;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0;
|
||||
min-inline-size: 0 !important;
|
||||
padding-inline: 7px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-source-button:hover {
|
||||
background: var(--app-grouped-list-hover-background) !important;
|
||||
}
|
||||
|
||||
.media-source-button--active {
|
||||
background: var(--app-grouped-list-active-background) !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
margin-block-end: 2px;
|
||||
transition: background-color 0.15s ease;
|
||||
@@ -917,6 +1050,19 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -131,6 +131,8 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
// 兼容未记录主来源的旧 TMDB 订阅;明确为其他来源时不使用辅助 TMDB ID 查询剧集组。
|
||||
if (subscribeForm.value.media_source && subscribeForm.value.media_source !== 'themoviedb') return
|
||||
if (!subscribeForm.value.tmdbid) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import api from '@/api'
|
||||
import type { SubscribeDownloadFileInfo, SubscribeEpisodeInfo, SubscribeLibraryFileInfo, SubscrbieInfo } from '@/api/types'
|
||||
import type {
|
||||
SubscribeDownloadFileInfo,
|
||||
SubscribeEpisodeInfo,
|
||||
SubscribeLibraryFileInfo,
|
||||
SubscrbieInfo,
|
||||
} from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -108,7 +113,10 @@ function formatEpisodeLabel(episodeNumber: number) {
|
||||
/**
|
||||
* 根据单集文件命中情况判断当前集状态。
|
||||
*/
|
||||
function resolveEpisodeStatus(download: SubscribeDownloadFileInfo[], library: SubscribeLibraryFileInfo[]): EpisodeStatus {
|
||||
function resolveEpisodeStatus(
|
||||
download: SubscribeDownloadFileInfo[],
|
||||
library: SubscribeLibraryFileInfo[],
|
||||
): EpisodeStatus {
|
||||
if (library.length) return 'library'
|
||||
if (download.length) return 'download'
|
||||
return 'missing'
|
||||
@@ -140,7 +148,12 @@ function resolveLibraryPathText(file: SubscribeLibraryFileInfo) {
|
||||
/**
|
||||
* 文件列表项稳定 key(媒体服务器条目可能无路径)。
|
||||
*/
|
||||
function resolveFileKey(tab: SubscribeFileTab, file: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo, index: number, episodeNumber?: number) {
|
||||
function resolveFileKey(
|
||||
tab: SubscribeFileTab,
|
||||
file: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo,
|
||||
index: number,
|
||||
episodeNumber?: number,
|
||||
) {
|
||||
const prefix = episodeNumber == null ? tab : `${episodeNumber}-${tab}`
|
||||
if (tab === 'library') {
|
||||
const libraryFile = file as SubscribeLibraryFileInfo
|
||||
@@ -236,9 +249,7 @@ function resolveResolutionLabel(file?: SubscribeDownloadFileInfo | SubscribeLibr
|
||||
return undefined
|
||||
}
|
||||
|
||||
const text = 'torrent_title' in file
|
||||
? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${path}`
|
||||
: path
|
||||
const text = 'torrent_title' in file ? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${path}` : path
|
||||
const matched = text.match(/(?:2160|1080|720|480)p|4k|8k/i)
|
||||
return matched?.[0]?.toUpperCase()
|
||||
}
|
||||
@@ -393,11 +404,12 @@ onBeforeMount(() => {
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
scrollable
|
||||
max-width="74rem"
|
||||
:height="display.mdAndUp.value ? '90vh' : undefined"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
content-class="subscribe-files-overlay"
|
||||
>
|
||||
<VCard class="subscribe-files-dialog">
|
||||
<VCard class="subscribe-files-dialog no-blur">
|
||||
<VBtn
|
||||
class="subscribe-files-dialog__close"
|
||||
icon="mdi-close"
|
||||
@@ -409,7 +421,7 @@ onBeforeMount(() => {
|
||||
|
||||
<LoadingBanner v-if="loading" />
|
||||
|
||||
<VCardText v-else class="subscribe-files-dialog__body">
|
||||
<div v-else class="subscribe-files-dialog__body">
|
||||
<div v-if="loadError" class="subscribe-files-empty subscribe-files-empty--standalone">
|
||||
<VIcon icon="mdi-folder-alert-outline" size="40" />
|
||||
<div>{{ t('error.serverError') }}</div>
|
||||
@@ -494,7 +506,9 @@ onBeforeMount(() => {
|
||||
v-for="episode in episodeGroups"
|
||||
:key="episode.episodeNumber"
|
||||
class="subscribe-files-episode-item"
|
||||
:class="{ 'subscribe-files-episode-item--active': selectedEpisode?.episodeNumber === episode.episodeNumber }"
|
||||
:class="{
|
||||
'subscribe-files-episode-item--active': selectedEpisode?.episodeNumber === episode.episodeNumber,
|
||||
}"
|
||||
type="button"
|
||||
@click="selectEpisode(episode.episodeNumber)"
|
||||
>
|
||||
@@ -583,27 +597,36 @@ onBeforeMount(() => {
|
||||
{{ (file as SubscribeDownloadFileInfo).site_name }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)"
|
||||
v-if="
|
||||
activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)
|
||||
"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
size="x-small"
|
||||
>
|
||||
{{ resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo) }}
|
||||
</VChip>
|
||||
<VChip
|
||||
:color="activeTab === 'download' ? 'info' : 'success'"
|
||||
variant="flat"
|
||||
size="x-small"
|
||||
>
|
||||
{{ activeTab === 'download' ? t('dialog.subscribeFiles.statusDownloaded') : t('dialog.subscribeFiles.statusInLibrary') }}
|
||||
<VChip :color="activeTab === 'download' ? 'info' : 'success'" variant="flat" size="x-small">
|
||||
{{
|
||||
activeTab === 'download'
|
||||
? t('dialog.subscribeFiles.statusDownloaded')
|
||||
: t('dialog.subscribeFiles.statusInLibrary')
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
<h3 class="subscribe-files-file-card__title">
|
||||
{{ activeTab === 'download' ? ((file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent')) : activeSectionTitle }}
|
||||
{{
|
||||
activeTab === 'download'
|
||||
? (file as SubscribeDownloadFileInfo).torrent_title ||
|
||||
t('dialog.subscribeFiles.unknownTorrent')
|
||||
: activeSectionTitle
|
||||
}}
|
||||
</h3>
|
||||
<div v-if="activeTab === 'download'" class="subscribe-files-file-card__meta">
|
||||
<span v-if="(file as SubscribeDownloadFileInfo).downloader">
|
||||
{{ t('dialog.subscribeFiles.downloader') }}:{{ (file as SubscribeDownloadFileInfo).downloader }}
|
||||
{{ t('dialog.subscribeFiles.downloader') }}:{{
|
||||
(file as SubscribeDownloadFileInfo).downloader
|
||||
}}
|
||||
</span>
|
||||
<span v-if="(file as SubscribeDownloadFileInfo).hash">
|
||||
Hash:{{ (file as SubscribeDownloadFileInfo).hash }}
|
||||
@@ -611,7 +634,14 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
<div class="subscribe-files-path-block">
|
||||
<div class="subscribe-files-path-block__label">
|
||||
<VIcon :icon="activeTab === 'library' && isDetailUrl(file.file_path) ? 'mdi-open-in-new' : 'mdi-folder-outline'" size="16" />
|
||||
<VIcon
|
||||
:icon="
|
||||
activeTab === 'library' && isDetailUrl(file.file_path)
|
||||
? 'mdi-open-in-new'
|
||||
: 'mdi-folder-outline'
|
||||
"
|
||||
size="16"
|
||||
/>
|
||||
{{ t('dialog.subscribeFiles.filePath') }}
|
||||
</div>
|
||||
<a
|
||||
@@ -627,7 +657,7 @@ onBeforeMount(() => {
|
||||
{{
|
||||
activeTab === 'library'
|
||||
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
|
||||
: (file.file_path || t('dialog.subscribeFiles.noPath'))
|
||||
: file.file_path || t('dialog.subscribeFiles.noPath')
|
||||
}}
|
||||
</code>
|
||||
<VBtn
|
||||
@@ -697,7 +727,9 @@ onBeforeMount(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
<div v-if="activeTab === 'download'" class="subscribe-files-mobile-file__title">
|
||||
{{ (file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent') }}
|
||||
{{
|
||||
(file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent')
|
||||
}}
|
||||
</div>
|
||||
<div class="subscribe-files-path-block subscribe-files-path-block--mobile">
|
||||
<a
|
||||
@@ -713,7 +745,7 @@ onBeforeMount(() => {
|
||||
{{
|
||||
activeTab === 'library'
|
||||
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
|
||||
: (file.file_path || t('dialog.subscribeFiles.noPath'))
|
||||
: file.file_path || t('dialog.subscribeFiles.noPath')
|
||||
}}
|
||||
</code>
|
||||
<VBtn
|
||||
@@ -741,102 +773,26 @@ onBeforeMount(() => {
|
||||
<VIcon icon="mdi-folder-alert-outline" size="40" />
|
||||
<div>{{ t('dialog.subscribeFiles.noData') }}</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.subscribe-files-overlay {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
inline-size: 100% !important;
|
||||
width: 100% !important;
|
||||
max-inline-size: 74rem !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.v-dialog:not(.v-dialog--fullscreen) > .subscribe-files-overlay {
|
||||
block-size: 80vh !important;
|
||||
height: 80vh !important;
|
||||
max-block-size: 80vh !important;
|
||||
max-height: 80vh !important;
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .subscribe-files-overlay {
|
||||
block-size: 100% !important;
|
||||
height: 100% !important;
|
||||
max-block-size: 100% !important;
|
||||
max-height: 100% !important;
|
||||
min-block-size: 100% !important;
|
||||
min-height: 100% !important;
|
||||
max-inline-size: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.v-dialog > .subscribe-files-overlay {
|
||||
block-size: 100% !important;
|
||||
height: 100% !important;
|
||||
max-block-size: 100% !important;
|
||||
max-height: 100% !important;
|
||||
min-block-size: 100% !important;
|
||||
min-height: 100% !important;
|
||||
max-inline-size: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.subscribe-files-overlay > .v-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
width: 100%;
|
||||
block-size: 100%;
|
||||
height: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .subscribe-files-overlay > .v-card {
|
||||
min-block-size: 100% !important;
|
||||
min-height: 100% !important;
|
||||
block-size: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.subscribe-files-overlay > .v-card {
|
||||
min-block-size: 100% !important;
|
||||
min-height: 100% !important;
|
||||
block-size: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-files-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), var(--sfd-border-opacity));
|
||||
backdrop-filter: blur(var(--sfd-blur)) saturate(1.18);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(var(--v-theme-primary), var(--sfd-accent-opacity)), transparent 42%),
|
||||
rgba(var(--v-theme-surface), var(--sfd-dialog-opacity)) !important;
|
||||
rgba(var(--v-theme-surface), var(--sfd-dialog-opacity));
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
|
||||
--sfd-accent-opacity: 0.1;
|
||||
--sfd-blur: 18px;
|
||||
@@ -857,8 +813,8 @@ onBeforeMount(() => {
|
||||
z-index: 8;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgba(var(--v-theme-surface), 0.22);
|
||||
inset-block-start: 1rem;
|
||||
inset-inline-end: 1rem;
|
||||
inset-block-start: 0.75rem;
|
||||
inset-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.subscribe-files-dialog__body {
|
||||
@@ -867,7 +823,6 @@ onBeforeMount(() => {
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.subscribe-files-shell {
|
||||
@@ -904,10 +859,11 @@ onBeforeMount(() => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
gap: 2rem;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
padding: 3.5rem 2.25rem 2rem;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 7.25rem minmax(0, 1fr);
|
||||
padding-block: 1.25rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.subscribe-files-poster-card {
|
||||
@@ -927,19 +883,21 @@ onBeforeMount(() => {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 72%));
|
||||
color: white;
|
||||
font-size: 1.35rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1.25;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
|
||||
padding-block: 1rem 0.65rem;
|
||||
padding-inline: 0.65rem;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 45%);
|
||||
}
|
||||
|
||||
.subscribe-files-hero__meta {
|
||||
min-inline-size: 0;
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
.subscribe-files-hero__eyebrow {
|
||||
@@ -954,11 +912,11 @@ onBeforeMount(() => {
|
||||
|
||||
.subscribe-files-hero__title {
|
||||
overflow: hidden;
|
||||
margin-block: 0.75rem 0.5rem;
|
||||
font-size: clamp(2.1rem, 4vw, 3.25rem);
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.15;
|
||||
margin-block: 0.45rem 0.4rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -971,32 +929,32 @@ onBeforeMount(() => {
|
||||
.subscribe-files-hero__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.76);
|
||||
font-size: 0.95rem;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
line-height: 1.75;
|
||||
margin-block: 1rem 0;
|
||||
max-inline-size: 42rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.76);
|
||||
font-size: 0.86rem;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.5;
|
||||
margin-block: 0.55rem 0;
|
||||
max-inline-size: 40rem;
|
||||
}
|
||||
|
||||
.subscribe-files-stats {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-block-start: 1rem;
|
||||
max-inline-size: 42rem;
|
||||
margin-block-start: 0.65rem;
|
||||
max-inline-size: 36rem;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: 0.9rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: var(--app-surface-radius);
|
||||
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
|
||||
gap: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
@@ -1012,18 +970,18 @@ onBeforeMount(() => {
|
||||
display: grid;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 2.4rem;
|
||||
inline-size: 2.4rem;
|
||||
block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__label {
|
||||
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__value {
|
||||
font-size: 1.45rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
@@ -1032,21 +990,22 @@ onBeforeMount(() => {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
flex: 1 1 0;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 19rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
min-block-size: 12rem;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
padding-block: 0 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-files-episode-rail,
|
||||
.subscribe-files-main {
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: var(--app-surface-radius);
|
||||
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-files-episode-rail {
|
||||
@@ -1059,7 +1018,7 @@ onBeforeMount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -1079,15 +1038,15 @@ onBeforeMount(() => {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 0.6rem;
|
||||
gap: 0.35rem;
|
||||
min-block-size: 0;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.subscribe-files-episode-item {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--app-surface-radius) - 0.25rem);
|
||||
background: transparent;
|
||||
@@ -1096,7 +1055,10 @@ onBeforeMount(() => {
|
||||
cursor: pointer;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) auto auto;
|
||||
text-align: start;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.subscribe-files-episode-item:hover,
|
||||
@@ -1138,19 +1100,20 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
.subscribe-files-tabs {
|
||||
padding: 1rem 1rem 0;
|
||||
padding-block: 0.75rem 0;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.subscribe-files-tab-group {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
inline-size: min(28rem, 100%);
|
||||
padding: 0.25rem;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 0.25rem;
|
||||
inline-size: min(28rem, 100%);
|
||||
}
|
||||
|
||||
.subscribe-files-tab-group :deep(.v-btn) {
|
||||
@@ -1167,31 +1130,31 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
.subscribe-files-tab-group__button {
|
||||
border-radius: calc(var(--app-control-radius) - 0.15rem) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72) !important;
|
||||
border-radius: calc(var(--app-control-radius) - 0.15rem);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subscribe-files-tab-group__button--active {
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.92), rgba(var(--v-theme-primary), 0.68)) !important;
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.92), rgba(var(--v-theme-primary), 0.68));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
}
|
||||
|
||||
.subscribe-files-detail {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
min-block-size: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-files-detail__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-block-end: 1rem;
|
||||
gap: 1rem;
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
.subscribe-files-detail__title {
|
||||
@@ -1257,10 +1220,10 @@ onBeforeMount(() => {
|
||||
|
||||
.subscribe-files-file-card__title {
|
||||
overflow: hidden;
|
||||
margin-block: 0.6rem 0.35rem;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
margin-block: 0.6rem 0.35rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1298,7 +1261,7 @@ onBeforeMount(() => {
|
||||
.subscribe-files-path-link {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -1317,17 +1280,17 @@ onBeforeMount(() => {
|
||||
.subscribe-files-empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
min-block-size: 12rem;
|
||||
border: 1px dashed rgba(var(--v-theme-on-surface), 0.16);
|
||||
border-radius: var(--app-surface-radius);
|
||||
color: rgba(var(--v-theme-on-surface), var(--sfd-muted-opacity));
|
||||
gap: 0.5rem;
|
||||
justify-items: center;
|
||||
min-block-size: 12rem;
|
||||
}
|
||||
|
||||
.subscribe-files-empty--standalone {
|
||||
min-block-size: 24rem;
|
||||
margin: 1.5rem;
|
||||
min-block-size: 24rem;
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-list {
|
||||
@@ -1337,7 +1300,8 @@ onBeforeMount(() => {
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
min-block-size: 0;
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
padding-block: 0.85rem 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-card {
|
||||
@@ -1387,9 +1351,9 @@ onBeforeMount(() => {
|
||||
|
||||
.subscribe-files-mobile-card__files {
|
||||
display: grid;
|
||||
padding: 0.85rem;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-file {
|
||||
@@ -1410,12 +1374,15 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-card__empty {
|
||||
padding: 0 0.85rem 0.85rem;
|
||||
padding-block: 0 0.85rem;
|
||||
padding-inline: 0.85rem;
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from,
|
||||
@@ -1437,7 +1404,6 @@ onBeforeMount(() => {
|
||||
@media (width <= 960px) {
|
||||
.subscribe-files-dialog {
|
||||
border: 0;
|
||||
border-radius: 0 !important;
|
||||
block-size: 100%;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
@@ -1460,10 +1426,11 @@ onBeforeMount(() => {
|
||||
|
||||
.subscribe-files-hero__content {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 8rem minmax(0, 1fr);
|
||||
padding: 1rem;
|
||||
align-items: center;
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: 1rem;
|
||||
gap: 0.875rem;
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.subscribe-files-poster-card {
|
||||
@@ -1474,8 +1441,17 @@ onBeforeMount(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subscribe-files-hero__meta {
|
||||
padding-inline-end: 2.25rem;
|
||||
}
|
||||
|
||||
.subscribe-files-hero__eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subscribe-files-hero__title {
|
||||
font-size: 1.55rem;
|
||||
font-size: 1.35rem;
|
||||
margin-block: 0 0.35rem;
|
||||
}
|
||||
|
||||
.subscribe-files-hero__description {
|
||||
@@ -1484,17 +1460,21 @@ onBeforeMount(() => {
|
||||
|
||||
.subscribe-files-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-block-start: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card {
|
||||
padding: 0.75rem;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
padding: 0.5rem 0.6rem;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__content {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__label,
|
||||
@@ -1503,20 +1483,19 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__icon {
|
||||
block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subscribe-files-stat-card__value {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-files-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
padding: 0;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-files-main {
|
||||
@@ -1524,15 +1503,16 @@ onBeforeMount(() => {
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-files-tabs {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.9rem 1rem 0.25rem;
|
||||
padding-block: 0.75rem 0.25rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.subscribe-files-tab-group {
|
||||
@@ -1547,8 +1527,8 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-card__episode {
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.subscribe-files-mobile-card__title {
|
||||
@@ -1565,7 +1545,7 @@ onBeforeMount(() => {
|
||||
|
||||
@media (width <= 560px) {
|
||||
.subscribe-files-hero__content {
|
||||
grid-template-columns: 6.5rem minmax(0, 1fr);
|
||||
grid-template-columns: 5.25rem minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getMediaSubscribeIdentity,
|
||||
type SeasonSubscribeModes,
|
||||
type SubscribeMode,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
type SubscribeModeOption = {
|
||||
@@ -22,7 +29,7 @@ type EpisodeGroupOption = {
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { locale, t } = useI18n()
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
// 定义事件
|
||||
@@ -65,7 +72,9 @@ const isRefreshed = ref(false)
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
|
||||
const episodeGroup = ref(
|
||||
getMediaSubscribeIdentity(props.media)?.source === 'themoviedb' ? (props.initialEpisodeGroup ?? '') : '',
|
||||
)
|
||||
|
||||
// 剧集组横向轨道
|
||||
const episodeGroupRail = ref<HTMLElement | null>(null)
|
||||
@@ -164,14 +173,12 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return
|
||||
if (!props.media?.tmdb_id) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
@@ -206,7 +213,7 @@ async function getMediaSeasons() {
|
||||
|
||||
// 查询剧集组的剧集
|
||||
async function getGroupSeasons() {
|
||||
if (!episodeGroup.value) return
|
||||
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb' || !episodeGroup.value) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`)
|
||||
@@ -259,24 +266,60 @@ function getExistText(season: number) {
|
||||
else return t('dialog.subscribeSeason.status.exists')
|
||||
}
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return props.media?.poster_path
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
// 获取季海报地址,数据源未提供季海报时使用主海报。
|
||||
function getSeasonPoster(posterPath?: string) {
|
||||
const resolvedPosterPath = posterPath?.trim() || props.media?.poster_path?.trim()
|
||||
if (!resolvedPosterPath) return noImage
|
||||
|
||||
const posterUrl = resolvedPosterPath.startsWith('/')
|
||||
? `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${resolvedPosterPath}`
|
||||
: resolvedPosterPath.replace('/t/p/original/', '/t/p/w500/')
|
||||
|
||||
return getDisplayImageUrl(posterUrl, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
// 按当前界面语言格式化数据源返回的首播日期。
|
||||
function formatAirDate(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
const dateParts = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(airDate)
|
||||
if (!dateParts) return airDate
|
||||
|
||||
const date = new Date(Number(dateParts[1]), Number(dateParts[2]) - 1, Number(dateParts[3]))
|
||||
if (Number.isNaN(date.getTime())) return airDate
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
// 获取本地化季号标题,确保数据源未返回名称时仍有稳定文本。
|
||||
function getSeasonTitle(item: MediaSeason) {
|
||||
return t('dialog.subscribeSeason.seasonNumber', { number: item.season_number ?? 0 })
|
||||
}
|
||||
|
||||
// 获取数据源提供的非重复季名称,保留特别篇等来源语义。
|
||||
function getSeasonName(item: MediaSeason) {
|
||||
const name = item.name?.trim()
|
||||
if (!name || item.season_number === undefined) return ''
|
||||
|
||||
const number = item.season_number
|
||||
const normalizedName = name.toLocaleLowerCase().replaceAll(/\s/g, '')
|
||||
const genericNames = new Set([
|
||||
`第${number}季`,
|
||||
`season${number}`,
|
||||
`season${String(number).padStart(2, '0')}`,
|
||||
`s${number}`,
|
||||
`s${String(number).padStart(2, '0')}`,
|
||||
])
|
||||
|
||||
return genericNames.has(normalizedName) ? '' : name
|
||||
}
|
||||
|
||||
// 获取同时包含季号和来源季名称的海报替代文本。
|
||||
function getSeasonPosterAlt(item: MediaSeason) {
|
||||
return [getSeasonTitle(item), getSeasonName(item)].filter(Boolean).join(' - ')
|
||||
}
|
||||
|
||||
// 切换当前剧集组并清空上一组的派生数据。
|
||||
@@ -355,7 +398,8 @@ function getDefaultSeasonMode(season: number) {
|
||||
|
||||
// 确保指定季已初始化订阅模式。
|
||||
function ensureSeasonMode(season: number) {
|
||||
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||
if (!seasonModes.value[season])
|
||||
setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||
}
|
||||
|
||||
// 在入库状态刷新后同步尚未手动修改的默认模式。
|
||||
@@ -512,19 +556,20 @@ onBeforeUnmount(() => {
|
||||
<VList lines="three" class="subscribe-season-list">
|
||||
<VListItem
|
||||
v-for="(item, i) in seasonInfos"
|
||||
:key="i"
|
||||
:active="isSeasonSelected(item.season_number || 0)"
|
||||
:key="item.season_number ?? i"
|
||||
:active="isSeasonSelected(item.season_number ?? 0)"
|
||||
rounded="lg"
|
||||
class="subscribe-season-list-item"
|
||||
@click="toggleSeasonSelected(item.season_number || 0)"
|
||||
@click="toggleSeasonSelected(item.season_number ?? 0)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
:src="getSeasonPoster(item.poster_path)"
|
||||
:alt="getSeasonPosterAlt(item)"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
class="subscribe-season-poster object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
@@ -535,49 +580,59 @@ onBeforeUnmount(() => {
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ t('dialog.subscribeSeason.seasonNumber', { number: item.season_number }) }}
|
||||
<span>{{ getSeasonTitle(item) }}</span>
|
||||
<span v-if="getSeasonName(item)" class="subscribe-season-name"> · {{ getSeasonName(item) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" /> {{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
|
||||
</VChip>
|
||||
{{ getYear(item.air_date || '') }} •
|
||||
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date || '') }) }}
|
||||
<VListItemSubtitle
|
||||
v-if="item.vote_average || item.air_date || typeof item.episode_count === 'number'"
|
||||
class="mt-1 me-2"
|
||||
>
|
||||
<div class="subscribe-season-meta">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" />
|
||||
{{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
|
||||
</VChip>
|
||||
<span v-if="item.air_date" class="subscribe-season-meta-item">
|
||||
<VIcon icon="mdi-calendar-blank-outline" size="x-small" />
|
||||
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date) }) }}
|
||||
</span>
|
||||
<span v-if="typeof item.episode_count === 'number'" class="subscribe-season-meta-item">
|
||||
<VIcon icon="mdi-play-box-multiple-outline" size="x-small" />
|
||||
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-if="seasonsNotExisted"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
:color="getExistColor(item.season_number || 0)"
|
||||
:color="getExistColor(item.season_number ?? 0)"
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
{{ getExistText(item.season_number ?? 0) }}
|
||||
</VChip>
|
||||
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
|
||||
<VChip v-if="isSeasonSubscribed(item.season_number ?? 0)" class="mt-2 ms-2" size="small" color="error">
|
||||
{{ t('media.status.subscribed') }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VListItemAction start class="subscribe-season-actions">
|
||||
<VSwitch
|
||||
:model-value="isSeasonSelected(item.season_number || 0)"
|
||||
:model-value="isSeasonSelected(item.season_number ?? 0)"
|
||||
hide-details
|
||||
@click.stop
|
||||
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
|
||||
@update:model-value="setSeasonSelected(item.season_number ?? 0, $event)"
|
||||
/>
|
||||
<VBtnToggle
|
||||
v-if="isSeasonSelected(item.season_number || 0)"
|
||||
:model-value="seasonModes[item.season_number || 0] || 'normal'"
|
||||
v-if="isSeasonSelected(item.season_number ?? 0)"
|
||||
:model-value="seasonModes[item.season_number ?? 0] || 'normal'"
|
||||
density="compact"
|
||||
divided
|
||||
mandatory
|
||||
variant="outlined"
|
||||
class="subscribe-season-mode-toggle"
|
||||
@click.stop
|
||||
@update:model-value="updateSeasonMode(item.season_number || 0, $event)"
|
||||
@update:model-value="updateSeasonMode(item.season_number ?? 0, $event)"
|
||||
>
|
||||
<VBtn
|
||||
v-for="mode in subscribeModeOptions"
|
||||
@@ -778,6 +833,29 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.subscribe-season-poster {
|
||||
flex: 0 0 3.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subscribe-season-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.subscribe-season-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__append) {
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useTransparencySettings } from '@/composables/useTransparencySettings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -29,6 +25,7 @@ const emit = defineEmits<{
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
if (!value) cancelTransparencySettings()
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
@@ -38,6 +35,7 @@ const {
|
||||
adjustTransparency,
|
||||
backgroundBlur,
|
||||
backgroundPosterOpacity,
|
||||
cancelTransparencySettings,
|
||||
currentPresetLevel,
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
@@ -45,14 +43,32 @@ const {
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
saveTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
} = useTransparencySettings()
|
||||
|
||||
/** 父级控制弹窗生命周期时,未保存关闭仍需恢复持久化设置。 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value, previous) => {
|
||||
if (!value && previous) cancelTransparencySettings()
|
||||
},
|
||||
)
|
||||
|
||||
/** 保存当前透明度预览,随后关闭面板。 */
|
||||
function saveSettings() {
|
||||
saveTransparencySettings()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 弹窗的任意未保存销毁路径都应恢复持久化快照。
|
||||
onScopeDispose(cancelTransparencySettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -185,14 +201,11 @@ const {
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
{{ t('theme.transparencyReset') }}
|
||||
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetTransparencySettings">
|
||||
{{ t('common.reset') }}
|
||||
</VBtn>
|
||||
<VBtn @click="visible = false" color="primary">
|
||||
{{ t('common.confirm') }}
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" @click="saveSettings">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||