Compare commits
380 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 | ||
|
|
23d7f0dcc1 | ||
|
|
288aeed178 | ||
|
|
9a9a618136 | ||
|
|
723eb319e1 | ||
|
|
96684a8d13 | ||
|
|
fc9fe5e21e | ||
|
|
24b763d808 | ||
|
|
f761cdff00 | ||
|
|
b785769138 | ||
|
|
6d1febd70a | ||
|
|
bdbaf503ca | ||
|
|
f9e74cf436 | ||
|
|
e043669a10 | ||
|
|
78d8fdba9d | ||
|
|
5c0f0386a6 | ||
|
|
30b39283b6 | ||
|
|
de84c39d2f | ||
|
|
65152e7e37 | ||
|
|
ba343ce5fa | ||
|
|
60495668a6 | ||
|
|
f2ac624dbb | ||
|
|
6238849d3f | ||
|
|
82cb903c1f | ||
|
|
5e5eb95b55 | ||
|
|
74e6f8b03e | ||
|
|
a2bf0d2b16 | ||
|
|
7532d39978 | ||
|
|
5cc9bf7418 | ||
|
|
20bdb940cd | ||
|
|
e9b214cff8 | ||
|
|
54f5fb2877 | ||
|
|
e86cb9e1cc | ||
|
|
3f258b9016 | ||
|
|
b54e144d0e | ||
|
|
7b20a7b775 | ||
|
|
df66b3e917 | ||
|
|
a919622d08 | ||
|
|
2a9ce950b7 | ||
|
|
48c12b765d | ||
|
|
1120055eed | ||
|
|
c66b6649e2 | ||
|
|
8479099926 | ||
|
|
cab65be1c9 | ||
|
|
6689e976c2 | ||
|
|
712dfa3fe1 | ||
|
|
346121f3c2 | ||
|
|
61c073ad6c | ||
|
|
4b3733bc19 | ||
|
|
b29c6bd83f | ||
|
|
b40fc4bd30 | ||
|
|
a225ba6075 | ||
|
|
303fe39c01 | ||
|
|
d343cbcf71 | ||
|
|
0eef8c5174 | ||
|
|
46fe257585 | ||
|
|
f69a57863e | ||
|
|
8876aadcfa | ||
|
|
485e9691a0 | ||
|
|
a0e7283ae6 | ||
|
|
b44c0647f1 | ||
|
|
7e60ab9064 | ||
|
|
f05c1f42b5 | ||
|
|
672bbb4265 | ||
|
|
10c1041b06 | ||
|
|
59c73facfe | ||
|
|
ba7d4cd392 | ||
|
|
d76a50c216 | ||
|
|
617223777b | ||
|
|
6ef047050d | ||
|
|
942ecc4c04 | ||
|
|
e72f9a8374 | ||
|
|
9cf782eb5b | ||
|
|
660338688a | ||
|
|
2d50bd7536 | ||
|
|
b02a4f1347 | ||
|
|
1748fdea34 | ||
|
|
6bbaf43671 | ||
|
|
4a66aaadad | ||
|
|
e2e239f6d9 | ||
|
|
fe22403e66 | ||
|
|
3313c71805 | ||
|
|
1e60e83514 | ||
|
|
9c893abcdf | ||
|
|
ead891ca2f | ||
|
|
8713e3cc86 | ||
|
|
3cc83d10d3 | ||
|
|
192ded374a | ||
|
|
13997c7e74 | ||
|
|
71b0dd4cc2 | ||
|
|
a58a0cdffe | ||
|
|
6aeb040db4 | ||
|
|
fef20e361e | ||
|
|
a63a07701d | ||
|
|
5dd56f2db3 | ||
|
|
275b095574 | ||
|
|
05eae71fba | ||
|
|
777b3c9445 | ||
|
|
a214168b1e | ||
|
|
9d55d02557 | ||
|
|
16c084ba80 | ||
|
|
b0f4ccc186 | ||
|
|
96d0606b4d | ||
|
|
450b9ec28a | ||
|
|
2ccf03fc1b | ||
|
|
38dfb3af07 | ||
|
|
ae4c59bfdb | ||
|
|
c9f4fdbee8 | ||
|
|
d21f461dda | ||
|
|
28a5a83315 | ||
|
|
11d11b88bf | ||
|
|
ff7658b5ba | ||
|
|
351faf2891 | ||
|
|
7d66229bad | ||
|
|
2b08be1e7d | ||
|
|
8255cfd479 | ||
|
|
f356bb4407 | ||
|
|
07e60291a2 | ||
|
|
2dbe8e6685 | ||
|
|
40f36b2afd | ||
|
|
d4260d5103 | ||
|
|
45f68bc936 | ||
|
|
9469074837 | ||
|
|
193807bb6f | ||
|
|
d4548db5b9 | ||
|
|
29aaea6fe6 | ||
|
|
369cc6438f | ||
|
|
d80b39c77b | ||
|
|
626725a8ca | ||
|
|
8be96358ae | ||
|
|
f2bfbfa3c5 | ||
|
|
7c9ffd6abc | ||
|
|
b370354287 | ||
|
|
145d71e283 | ||
|
|
eeea82d815 | ||
|
|
babd267bc4 | ||
|
|
e136c931ac | ||
|
|
ae00602345 | ||
|
|
5382108ee7 | ||
|
|
514063d3fb | ||
|
|
b08f396fec | ||
|
|
d37a7f06f1 | ||
|
|
ad7bca3aae | ||
|
|
4fb70ba80e | ||
|
|
1225b2eb9e | ||
|
|
24b2f103b9 | ||
|
|
0d304b58ca | ||
|
|
f419dbd794 | ||
|
|
7854cc81a8 | ||
|
|
9ad1bd29bd | ||
|
|
b88d4f0ecb | ||
|
|
44168b62d2 | ||
|
|
1dab013436 | ||
|
|
64a4a7aff5 | ||
|
|
e43b545c89 | ||
|
|
69fcde250e | ||
|
|
63d6290166 | ||
|
|
c1d759f3f3 | ||
|
|
3a782bc69c | ||
|
|
bea752879c | ||
|
|
a48fcb3819 | ||
|
|
68a07bc952 | ||
|
|
828dba09b0 | ||
|
|
0d2189e9e8 | ||
|
|
f0f0ab81e4 | ||
|
|
64b5fa7038 | ||
|
|
1d04c9b9c9 | ||
|
|
dee719ac25 | ||
|
|
ea676876f1 | ||
|
|
c1a4d5d81e | ||
|
|
95d88804e4 | ||
|
|
1fa072790f | ||
|
|
fe19c1183c | ||
|
|
be40f55bd9 | ||
|
|
30a10eaf6d | ||
|
|
3bc0c86df4 | ||
|
|
03c8726e6e | ||
|
|
de47491ded | ||
|
|
c691cdaa0e | ||
|
|
53efdc2802 | ||
|
|
9644076463 | ||
|
|
cb4e88f8aa | ||
|
|
adc16fc58d | ||
|
|
d6860a3e24 | ||
|
|
7e6116de45 | ||
|
|
1688a2ca25 | ||
|
|
fe57acfce0 | ||
|
|
1ae49b28b1 | ||
|
|
ef4e9c8b40 | ||
|
|
5da0758e89 | ||
|
|
816cab252d | ||
|
|
843f638835 | ||
|
|
e4684b2e12 | ||
|
|
c17365b6c9 | ||
|
|
01835c0ac5 | ||
|
|
e5749bd6ef | ||
|
|
689e58737b | ||
|
|
38da061cf1 | ||
|
|
e79940e52e | ||
|
|
88dd6068b6 | ||
|
|
7dd10f9c96 | ||
|
|
94aaf83107 | ||
|
|
e84fc5f424 | ||
|
|
f342b08179 | ||
|
|
0fcad02f3b | ||
|
|
43d2406ee9 | ||
|
|
78e2d05730 | ||
|
|
425bf808ed | ||
|
|
6d2916dc9f | ||
|
|
2281e4224b | ||
|
|
95282f9883 | ||
|
|
b470f182c9 | ||
|
|
0bba1068af | ||
|
|
947a7d8296 | ||
|
|
bd36cbf888 | ||
|
|
d8fa47bff7 | ||
|
|
1132beea5e | ||
|
|
2e3314e6c3 | ||
|
|
daa8f857f8 | ||
|
|
6d14271fe8 | ||
|
|
9284d48f67 | ||
|
|
c5d1c5a468 | ||
|
|
b98512789f | ||
|
|
6b8ed8d527 | ||
|
|
ec4500dcef | ||
|
|
288e63ce68 | ||
|
|
b3885584bb | ||
|
|
968b24be1e | ||
|
|
5a23c1783a | ||
|
|
ddeeb5a7c3 | ||
|
|
0b9bbcc7b8 | ||
|
|
022c8b4515 | ||
|
|
be04991928 | ||
|
|
34770567a5 | ||
|
|
6154fc2157 | ||
|
|
e77dcdd3d4 | ||
|
|
58a3532c1b | ||
|
|
116a5eeb43 | ||
|
|
decd50cb40 | ||
|
|
355563244c | ||
|
|
51aad628b5 | ||
|
|
7dd7a2cf34 | ||
|
|
4c0ff7c7f2 | ||
|
|
8aba3cbe00 | ||
|
|
e21c3ec507 | ||
|
|
fdbb0b2ca8 | ||
|
|
180195ab7d | ||
|
|
8add4e6b46 | ||
|
|
3d622d2efe | ||
|
|
bb7ed7b963 | ||
|
|
d541ea41ad | ||
|
|
7c7ebc9eb7 | ||
|
|
22275c3b12 | ||
|
|
8744a34e8e | ||
|
|
e98836fd0e | ||
|
|
feb62196a2 | ||
|
|
9fd29a2958 | ||
|
|
546c82ca40 | ||
|
|
f132dc38f4 | ||
|
|
58c70b8ca6 | ||
|
|
147f55eefe | ||
|
|
229b7b0c12 | ||
|
|
4b7b5ff8a4 | ||
|
|
4906bde746 | ||
|
|
a87a1a8988 | ||
|
|
e05f45e681 | ||
|
|
b4acacea81 | ||
|
|
fa9645b05b | ||
|
|
1ed4052814 | ||
|
|
7dc814461f | ||
|
|
9154ec0e8c | ||
|
|
3a2ea60583 | ||
|
|
b36bff3a1e | ||
|
|
b3d8cbf280 | ||
|
|
38fb02d112 | ||
|
|
2597f893cd | ||
|
|
ebdd036654 | ||
|
|
5032f0e6a9 | ||
|
|
ad963d718d | ||
|
|
69d314bce3 | ||
|
|
4a7425a947 | ||
|
|
c172ac0d5c | ||
|
|
01a66493a8 | ||
|
|
188f8b3faa | ||
|
|
ebcf5fad71 | ||
|
|
d1a656db82 | ||
|
|
4f6a11fd7c | ||
|
|
1d09a946bb | ||
|
|
6c4eb7edbd | ||
|
|
4f9f669ac6 | ||
|
|
f9e0e78473 | ||
|
|
b004facfca | ||
|
|
fb6ee2910f | ||
|
|
3fedc9b730 | ||
|
|
b260427312 | ||
|
|
dd1447e93c | ||
|
|
dbcc213562 | ||
|
|
1c019cd5c8 | ||
|
|
e37bde77a1 | ||
|
|
57bf0d2021 | ||
|
|
88b00f7069 | ||
|
|
7b08cbb2f7 | ||
|
|
97c0ec184d | ||
|
|
d18c845088 | ||
|
|
a64d97774d | ||
|
|
2ddc51aa4f | ||
|
|
28afe2a922 | ||
|
|
c2e97bf191 | ||
|
|
c922752a1f | ||
|
|
08f36a74ca | ||
|
|
d7809dd00c | ||
|
|
27582004da | ||
|
|
3d6a176cde | ||
|
|
4a2073a038 | ||
|
|
c8a65ecbe4 | ||
|
|
3750d5cba0 | ||
|
|
55b383780e | ||
|
|
6aec0ddf88 | ||
|
|
7c8e94d1df | ||
|
|
5ecbf626c8 | ||
|
|
584f580e3b | ||
|
|
280de47dac | ||
|
|
c7c05f5897 | ||
|
|
bb86180582 | ||
|
|
aff228edd3 | ||
|
|
f65ae6d703 | ||
|
|
0fccc06883 | ||
|
|
8652966645 | ||
|
|
6d84eb9f09 | ||
|
|
1a3dccac29 | ||
|
|
fa8de34fc5 | ||
|
|
10cfd6be80 | ||
|
|
a390b36e7c | ||
|
|
d6b5994e22 | ||
|
|
08611a97e7 | ||
|
|
35bbb44ce3 | ||
|
|
8ff879661a | ||
|
|
a8f01f099d | ||
|
|
040ab1096b | ||
|
|
0cbdf24315 | ||
|
|
164ea79bd1 | ||
|
|
97f3435bb3 | ||
|
|
63b108ff6b | ||
|
|
b0880cb369 | ||
|
|
5f70ee8e18 |
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
## 3. 核心概念
|
||||
|
||||
每个插件需要提供三个标准组件:
|
||||
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||
|
||||
| 组件名称 | 文件名 | 用途 |
|
||||
|---------|-------|------|
|
||||
| Page | Page.vue | 插件详情页面 |
|
||||
| Config | Config.vue | 插件配置页面 |
|
||||
| Dashboard | Dashboard.vue | 仪表板组件 |
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
|---------|--------|--------|------|
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
|
||||
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage` → `Page`;`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
@@ -56,6 +60,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
@@ -245,17 +251,110 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<div class="dashboard-widget">
|
||||
<!-- 仪表板内容 -->
|
||||
<v-card>
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-hover>
|
||||
<!-- 仪表板内容 -->
|
||||
<template #default="{ isHovering, props: hoverProps }">
|
||||
<v-card v-bind="hoverProps">
|
||||
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 组件内容 -->
|
||||
</v-card-text>
|
||||
<!-- 只在悬停时显示拖拽图标 -->
|
||||
<div v-show="isHovering" class="absolute right-5 top-5">
|
||||
<v-icon class="cursor-move">mdi-drag</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.4 AppPage 组件(侧栏全页)
|
||||
|
||||
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-h6 mb-2">侧栏全页示例({{ pluginId }} / {{ navKey }})</div>
|
||||
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "示例订阅页",
|
||||
"icon": "mdi-rss",
|
||||
"section": "subscribe",
|
||||
"permission": "subscribe",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 同一插件多个全页界面(多 `nav_key`)
|
||||
|
||||
在 `get_sidebar_nav` 中**返回多条**记录,每条使用不同的 `nav_key` / `title` / `section` 等,侧栏与「更多」中会出现多个入口,路由形如 `#/plugin-app/<插件ID>/<nav_key>`。
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |
|
||||
|
||||
`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。
|
||||
|
||||
**两种实现方式(二选一或混用):**
|
||||
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
|
||||
```typescript
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MoviePilot 插件远程组件示例
|
||||
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
|
||||
|
||||
## 1. 开发环境准备
|
||||
|
||||
@@ -28,7 +28,9 @@ plugin-component/
|
||||
│ ├── components/
|
||||
│ │ ├── Page.vue # 插件详情页面组件
|
||||
│ │ ├── Config.vue # 插件配置页面组件
|
||||
│ │ └── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main)
|
||||
│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings)
|
||||
│ ├── App.vue # 本地开发入口组件
|
||||
│ └── main.js # 本地开发入口文件
|
||||
├── vite.config.js # Vite和模块联邦配置
|
||||
|
||||
32
examples/plugin-component/src/components/AppPage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
|
||||
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
navKey: {
|
||||
type: String,
|
||||
default: 'main',
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page pa-4">
|
||||
<div class="text-h6 mb-2">AppPage(侧栏全页)</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
pluginId: {{ pluginId }} · navKey: {{ navKey }}
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 示例:nav_key=settings 时主应用会优先加载 AppPageSettings,再回退 AppPage。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'settings' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-1">Settings 子界面(AppPageSettings)</div>
|
||||
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
|
||||
225
index.html
@@ -13,7 +13,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
@@ -91,13 +91,13 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preload" href="/logo.png" as="image" />
|
||||
<link rel="modulepreload" href="/src/main.ts" />
|
||||
|
||||
<!-- 内联关键CSS -->
|
||||
<style>
|
||||
/* 关键路径CSS - 从loader.css内联 */
|
||||
#app {
|
||||
min-block-size: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
@@ -115,14 +115,12 @@
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
}
|
||||
|
||||
/* 添加logo完成动画 - 放大虚化效果 */
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
/* 添加加载背景消失动画 - 放大虚化效果 */
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
opacity: 0;
|
||||
@@ -141,7 +139,6 @@
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* 完成时隐藏加载动画 */
|
||||
.loading-complete .loading {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -195,9 +192,37 @@
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 初始化脚本 -->
|
||||
<script>
|
||||
// 检测系统主题是否为深色模式
|
||||
function checkPrefersColorSchemeIsDark() {
|
||||
@@ -213,7 +238,7 @@
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
@@ -246,6 +271,73 @@
|
||||
updateSafeArea()
|
||||
window.addEventListener('resize', updateSafeArea)
|
||||
window.addEventListener('orientationchange', updateSafeArea)
|
||||
|
||||
// 清除缓存处理逻辑
|
||||
window.clearAndReload = async function() {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VersionChecker] 清除缓存时出错:', e)
|
||||
} finally {
|
||||
// 3. 重载页面
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
}
|
||||
|
||||
const textNode = document.createTextNode(msg.text + ' ');
|
||||
const btnLink = document.createElement('a');
|
||||
btnLink.href = 'javascript:void(0)';
|
||||
btnLink.id = 'timeout-btn';
|
||||
btnLink.onclick = window.clearAndReload;
|
||||
btnLink.textContent = msg.btn;
|
||||
|
||||
timeoutEl.innerHTML = '';
|
||||
timeoutEl.appendChild(textNode);
|
||||
timeoutEl.appendChild(btnLink);
|
||||
timeoutEl.style.display = 'block';
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -253,117 +345,18 @@
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill: none" />
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path
|
||||
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)" />
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)" />
|
||||
<clipPath id="_clip5">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
16
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.7.6",
|
||||
"version": "2.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
@@ -41,6 +42,7 @@
|
||||
"ace-builds": "^1.37.4",
|
||||
"apexcharts": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"body-scroll-lock": "^3.1.5",
|
||||
"colorthief": "^2.6.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -50,11 +52,13 @@
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
@@ -68,16 +72,24 @@
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/line-md": "^1.2.13",
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify-json/tabler": "^1.2.23",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 102 KiB |
53
public/logo.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill:none;"/>
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z" style="fill:url(#_Linear1);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z" style="fill:url(#_Linear2);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z" style="fill:url(#_Linear3);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z" style="fill:rgb(165,118,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z" style="fill:url(#_Linear4);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" style="fill:rgb(104,0,197);"/>
|
||||
<clipPath id="_clip5">
|
||||
<path d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z" style="fill:url(#_Linear6);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z" style="fill:rgb(141,81,249);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z" style="fill:url(#_Radial7);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"><stop offset="0" style="stop-color:rgb(141,81,249);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(116,50,223);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"><stop offset="0" style="stop-color:rgb(116,50,223);stop-opacity:1"/><stop offset="0.51" style="stop-color:rgb(110,38,217);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(91,0,197);stop-opacity:1"/></linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"><stop offset="0" style="stop-color:rgb(211,187,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(211,187,255);stop-opacity:0"/></radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
1
shims.d.ts
vendored
@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
|
||||
export default component
|
||||
}
|
||||
declare module 'vue-shepherd';
|
||||
declare module 'colorthief';
|
||||
|
||||
@@ -15,7 +15,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<VIcon icon="mdi-close" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "sass:map";
|
||||
@use "vuetify/lib/styles/settings" as vuetify_settings;
|
||||
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
|
||||
@use "@styles/variables/_vuetify.scss" as vuetify;
|
||||
|
||||
@mixin themed($property, $light-value, $dark-value) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.auth-wrapper {
|
||||
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.auth-footer-mask {
|
||||
|
||||
@@ -23,6 +23,13 @@ export function kFormatter(num: number) {
|
||||
: Math.abs(num).toFixed(0).replace(regex, ',')
|
||||
}
|
||||
|
||||
// 格式化下载量显示,超过1000显示为x.xk格式
|
||||
export function formatDownloadCount(num: number): string {
|
||||
if (!num || num < 1000) return num?.toLocaleString() || '0'
|
||||
|
||||
return `${(num / 1000).toFixed(1)}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and return date in Humanize format
|
||||
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
|
||||
|
||||
@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
|
||||
return outputArray
|
||||
}
|
||||
|
||||
// Uint8Array 转 Base64URL
|
||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
// Base64URL 转 Uint8Array
|
||||
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
// 判断是否为PWA
|
||||
export const isPWA = async (): Promise<boolean> => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,33 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -130,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -154,7 +143,13 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
// Sort icons by prefix
|
||||
const organizedList = organizeIconsList(sources.icons)
|
||||
for (const prefix in organizedList) {
|
||||
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
let filename
|
||||
try {
|
||||
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
|
||||
}
|
||||
catch (err) {
|
||||
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
}
|
||||
|
||||
sourcesJSON.push({
|
||||
filename,
|
||||
@@ -271,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,11 @@ export default defineComponent({
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
@@ -32,21 +37,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
@@ -142,7 +151,7 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
min-block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
@@ -224,7 +233,9 @@ export default defineComponent({
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
// 使用 clip 替代 hidden,避免 Chrome 144+ 滚动锁定问题
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -8,12 +8,13 @@ html {
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
|
||||
overflow: visible !important;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -36,7 +37,9 @@ body,
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
overflow: hidden;
|
||||
// Chrome 144+ 兼容性:使用 clip 替代 hidden,避免滚动锁定问题
|
||||
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
|
||||
overflow: clip;
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
|
||||
min-block-size: 100%;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidationRule } from 'vuetify/types/services/validation'
|
||||
type ValidationRule = (value: any) => string | boolean
|
||||
|
||||
// 必输校验
|
||||
export const requiredValidator: ValidationRule = (value: any) => {
|
||||
|
||||
82
src/App.vue
@@ -12,10 +12,11 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
@@ -38,48 +39,33 @@ const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +148,11 @@ async function removeLoadingWithStateCheck() {
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(() => {
|
||||
globalSettingsStore.initialize().then(async () => {
|
||||
// 如果已登录,加载用户相关设置
|
||||
if (isLogin.value) {
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
@@ -207,8 +197,16 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('_t')) {
|
||||
url.searchParams.delete('_t')
|
||||
const newUrl = url.pathname + url.search + url.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -223,7 +221,7 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -234,11 +232,15 @@ onMounted(async () => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -44,6 +44,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
const aceModule = ace as typeof ace & {
|
||||
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
function registerJinja2Mode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2HighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'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__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaExpressionRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-qstring',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'jinja2-qqstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-statement',
|
||||
},
|
||||
],
|
||||
'jinja2-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-qqstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\"ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
'jinja2-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
|
||||
exports.Jinja2HighlightRules = Jinja2HighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2HighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/jinja2'
|
||||
this.blockComment = { start: '{#', end: '#}' }
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
|
||||
exports.snippetText =
|
||||
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
|
||||
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
|
||||
'snippet var\n\t{{ ${1:name} }}\n'
|
||||
exports.scope = 'jinja2'
|
||||
})
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2JsonHighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'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__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-json-qstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'variable',
|
||||
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'json-string',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /0[xX][0-9a-fA-F]+\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.language.boolean',
|
||||
regex: /(?:true|false|null)\b/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /\/\/.*$/,
|
||||
},
|
||||
{
|
||||
token: 'comment.start',
|
||||
regex: /\/\*/,
|
||||
push: 'comment',
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator',
|
||||
regex: /[:,]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
],
|
||||
'json-string': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-json-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-json-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-json-statement',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /"|$/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
comment: [
|
||||
{
|
||||
token: 'comment.end',
|
||||
regex: /\*\//,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment',
|
||||
},
|
||||
],
|
||||
'jinja2-json-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-json-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
|
||||
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2JsonHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.lineCommentStart = '//'
|
||||
this.blockComment = { start: '/*', end: '*/' }
|
||||
this.$id = 'ace/mode/jinja2_json'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -61,9 +543,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -52,6 +52,10 @@ export const downloaderOptions = [
|
||||
value: 'transmission',
|
||||
title: i18n.global.t('setting.system.transmission'),
|
||||
},
|
||||
{
|
||||
value: 'rtorrent',
|
||||
title: i18n.global.t('setting.system.rtorrent'),
|
||||
},
|
||||
]
|
||||
|
||||
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
|
||||
@@ -76,6 +80,10 @@ export const mediaServerOptions = [
|
||||
value: 'trimemedia',
|
||||
title: i18n.global.t('setting.system.trimeMedia'),
|
||||
},
|
||||
{
|
||||
value: 'ugreen',
|
||||
title: i18n.global.t('setting.system.ugreen'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
|
||||
@@ -274,6 +282,10 @@ export const notificationSwitchOptions = [
|
||||
title: i18n.global.t('notificationSwitch.plugin'),
|
||||
value: '插件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.agent'),
|
||||
value: '智能体',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.other'),
|
||||
value: '其它',
|
||||
|
||||
@@ -314,6 +314,8 @@ export interface MediaInfo {
|
||||
production_countries?: any[]
|
||||
// 语种
|
||||
spoken_languages?: string[]
|
||||
// 数字/实体发行日期
|
||||
release_dates?: MediaRelease[]
|
||||
// 状态
|
||||
status?: string
|
||||
// 标签
|
||||
@@ -368,6 +370,18 @@ export interface TmdbSeason {
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// 发行信息
|
||||
export interface MediaRelease {
|
||||
// 发行日期
|
||||
date: string
|
||||
// 发行地区
|
||||
iso_code: string
|
||||
// 备注
|
||||
note?: string
|
||||
// 发行类型
|
||||
type: number
|
||||
}
|
||||
|
||||
// TMDB集信息
|
||||
export interface TmdbEpisode {
|
||||
// 上映日期
|
||||
@@ -520,7 +534,7 @@ export interface SiteUserData {
|
||||
// 用户名
|
||||
username?: string
|
||||
// 用户ID
|
||||
userid?: number
|
||||
userid?: string
|
||||
// 用户等级
|
||||
user_level?: string
|
||||
// 加入时间
|
||||
@@ -642,6 +656,17 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
nav_key: string
|
||||
title: string
|
||||
icon: string
|
||||
section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
|
||||
permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null
|
||||
order: number
|
||||
}
|
||||
|
||||
// 渲染结构
|
||||
export interface RenderProps {
|
||||
component: string
|
||||
@@ -847,6 +872,16 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
@@ -861,8 +896,8 @@ export interface MediaStatistic {
|
||||
movie_count: number
|
||||
// 电视剧总数
|
||||
tv_count: number
|
||||
// 电视剧总集数
|
||||
episode_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
}
|
||||
@@ -992,6 +1027,8 @@ export interface MediaServerPlayItem {
|
||||
percent?: number
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
@@ -1014,6 +1051,8 @@ export interface MediaServerLibrary {
|
||||
link?: string
|
||||
// 媒体服务器类型
|
||||
server_type?: string
|
||||
// 图片是否需要Cookies
|
||||
use_cookies?: boolean
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1066,6 +1105,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
@@ -1104,7 +1145,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
// 类型 emby/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1409,3 +1450,25 @@ export interface SubscribeShareStatistics {
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
export interface CategoryRule {
|
||||
genre_ids?: string
|
||||
original_language?: string
|
||||
production_countries?: string
|
||||
origin_country?: string
|
||||
release_year?: string
|
||||
}
|
||||
|
||||
// 分类配置
|
||||
export interface CategoryConfig {
|
||||
movie?: { [key: string]: CategoryRule }
|
||||
tv?: { [key: string]: CategoryRule }
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/discord.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/images/logos/qq.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/images/logos/rtorrent.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/logos/ugreen.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -4,6 +4,14 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
|
||||
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -11,7 +19,7 @@ const props = defineProps({
|
||||
tree: Boolean,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
@@ -27,6 +35,9 @@ const props = defineProps({
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -117,24 +128,47 @@ const fileIcons = {
|
||||
other: 'mdi-file-outline',
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
// 当前存储
|
||||
const activeStorage = ref('local')
|
||||
|
||||
// 刷新
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// 排序 - 从localStorage恢复
|
||||
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
// 是否显示目录树 - 从localStorage恢复
|
||||
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
|
||||
|
||||
// 拖动分隔条相关
|
||||
const navigatorWidth = ref(280) // 初始宽度
|
||||
// 拖动分隔条相关 - 从localStorage恢复宽度
|
||||
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
return props.storages?.map(item => ({
|
||||
@@ -144,15 +178,15 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
else if (loading.value > 0) loading.value--
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
@@ -235,14 +269,16 @@ function stopDrag() {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
ref="toolbarRef"
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:show-new-folder-button="!showFloatingNewFolderAction"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@@ -251,7 +287,7 @@ function stopDrag() {
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
@@ -266,7 +302,6 @@ function stopDrag() {
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
@@ -285,6 +320,18 @@ function stopDrag() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-folder-plus-outline"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openNewFolderDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -10,12 +11,18 @@ const props = defineProps({
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载失败响应
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
@@ -26,7 +33,13 @@ async function goPlay() {
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
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)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -45,7 +58,7 @@ const getImgUrl = computed(() => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
|
||||
@@ -101,19 +101,21 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -195,7 +195,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +204,8 @@ watch(
|
||||
:label="t('directory.alias')"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
|
||||
@@ -4,12 +4,10 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import custom_image from '@images/logos/downloader.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
|
||||
@@ -54,6 +52,54 @@ const download_rate = ref(0)
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
// 查找匹配的存储类型
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
// 获取存储路径前后缀
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
// 更新存储路径前缀
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
// 更新存储路径后缀
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
@@ -61,8 +107,24 @@ const downloaderInfo = ref<DownloaderConf>({
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 生成随机ID
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
@@ -94,11 +156,24 @@ async function loadDownloaderInfo() {
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
// 初始化路径映射行数据
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
async function saveDownloaderInfo() {
|
||||
// 表单校验
|
||||
const { valid } = await downloaderForm.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 同步路径映射数据
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
@@ -128,14 +203,30 @@ function saveDownloaderInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.downloader.type) {
|
||||
case 'qbittorrent':
|
||||
return qbittorrent_image
|
||||
return getLogoUrl('qbittorrent')
|
||||
case 'transmission':
|
||||
return transmission_image
|
||||
return getLogoUrl('transmission')
|
||||
case 'rtorrent':
|
||||
return getLogoUrl('rtorrent')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('downloader')
|
||||
}
|
||||
})
|
||||
|
||||
// 添加路径映射
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
// 移除路径映射
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
@@ -154,24 +245,26 @@ onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
class="app-card-shell"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
@@ -179,18 +272,21 @@ onUnmounted(() => {
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
<div
|
||||
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
|
||||
class="app-card-summary__meta text-sm"
|
||||
>
|
||||
<span class="app-card-summary__meta-item">{{ `↑ ${formatFileSize(upload_rate, 1)}/s` }}</span>
|
||||
<span class="app-card-summary__meta-item">{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">自定义下载器</span>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义下载器
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -214,7 +310,7 @@ onUnmounted(() => {
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
@@ -250,11 +346,23 @@ onUnmounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
@@ -266,6 +374,7 @@ onUnmounted(() => {
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
@@ -353,6 +462,51 @@ onUnmounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port/RPC2"
|
||||
:hint="t('downloader.rtorrentHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -375,6 +529,89 @@ onUnmounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="pe-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
|
||||
@@ -45,15 +45,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VAutocomplete
|
||||
|
||||
@@ -205,22 +205,24 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
@@ -33,6 +33,7 @@ function imageLoadHandler() {
|
||||
// 图片加载错误
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
@@ -40,7 +41,8 @@ function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return trimemedia
|
||||
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -52,31 +54,39 @@ async function goPlay() {
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string) {
|
||||
if (!url) return getDefaultImage()
|
||||
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[]) {
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
const IMAGES = [...imageList]
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
}
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return getDefaultImage()
|
||||
|
||||
// 画布参数
|
||||
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
|
||||
const POSTER_HEIGHT = 256 // 上方海报高256
|
||||
const MARGIN_WIDTH = 8 // 左右间隔为8
|
||||
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
|
||||
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
|
||||
const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
|
||||
const POSTER_HEIGHT = 256 // 上方海报高256
|
||||
const MARGIN_WIDTH = 8 // 左右间隔为8
|
||||
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
|
||||
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
|
||||
|
||||
// 获取画布上下文
|
||||
const ctx = canvas.getContext('2d')
|
||||
@@ -107,30 +117,20 @@ async function drawImages(imageList: string[]) {
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = 0 // 海报紧贴顶部
|
||||
const y = 0 // 海报紧贴顶部
|
||||
|
||||
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(0, canvas.height)
|
||||
ctx.scale(1, -1)
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
img.width,
|
||||
img.height,
|
||||
x,
|
||||
0,
|
||||
POSTER_WIDTH,
|
||||
REFLECTION_HEIGHT,
|
||||
)
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))
|
||||
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
|
||||
|
||||
@@ -147,8 +147,8 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.media?.image_list && props.media?.image_list.length > 0)
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [])
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '')
|
||||
imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)
|
||||
else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -16,13 +14,24 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
interface MediaCardMedia extends MediaInfo {
|
||||
total_episode?: number
|
||||
episode_count?: number
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
media: Object as PropType<MediaCardMedia>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
@@ -64,9 +73,9 @@ const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
themoviedb: tmdbImage,
|
||||
douban: doubanImage,
|
||||
bangumi: bangumiImage,
|
||||
themoviedb: getLogoUrl('tmdb'),
|
||||
douban: getLogoUrl('douban-black'),
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
}
|
||||
|
||||
// 绑定MediaCard元素
|
||||
@@ -120,6 +129,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -140,7 +165,7 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -155,7 +180,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
@@ -164,6 +189,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -185,8 +211,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
@@ -210,6 +236,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -224,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -234,24 +263,29 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
@@ -262,12 +296,14 @@ async function checkSubscribe(season = 0) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
@@ -302,7 +338,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import custom_image from '@images/logos/mediaserver.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -65,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
},
|
||||
])
|
||||
|
||||
const ugreenScanModeOptions = computed(() => [
|
||||
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
|
||||
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
|
||||
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
@@ -81,6 +83,15 @@ function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
if (mediaServerInfo.value.type === 'ugreen') {
|
||||
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
|
||||
if (!mediaServerInfo.value.config.scan_mode) {
|
||||
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
|
||||
}
|
||||
if (mediaServerInfo.value.config.verify_ssl === undefined) {
|
||||
mediaServerInfo.value.config.verify_ssl = true
|
||||
}
|
||||
}
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
@@ -109,15 +120,17 @@ function saveMediaServerInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return emby_image
|
||||
return getLogoUrl('emby')
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
return getLogoUrl('trimemedia')
|
||||
case 'ugreen':
|
||||
return getLogoUrl('ugreen')
|
||||
case 'plex':
|
||||
return plex_image
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('mediaserver')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,21 +199,27 @@ onMounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openMediaServerInfoDialog">
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
|
||||
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
|
||||
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
|
||||
<VCardText class="app-card-summary app-card-summary--single-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
|
||||
<div
|
||||
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
|
||||
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
|
||||
>
|
||||
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
|
||||
<span class="truncate">{{ item.amount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
|
||||
<span class="me-2 mb-1">自定义媒体服务器</span>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义媒体服务器
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -262,6 +281,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
@@ -418,6 +447,95 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</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>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.config.scan_mode"
|
||||
:label="t('mediaserver.scanMode')"
|
||||
:items="ugreenScanModeOptions"
|
||||
:hint="t('mediaserver.scanModeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-radar"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaServerInfo.config.verify_ssl"
|
||||
:label="t('mediaserver.verifySsl')"
|
||||
:hint="t('mediaserver.verifySslHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
@@ -19,6 +21,22 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -42,10 +60,10 @@ function noteToJson() {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -85,19 +103,23 @@ function replaceNewLine(value: string) {
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
@@ -116,3 +138,89 @@ function replaceNewLine(value: string) {
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import wechat_image from '@images/logos/wechat.png'
|
||||
import telegram_image from '@images/logos/telegram.webp'
|
||||
import vocechat_image from '@images/logos/vocechat.png'
|
||||
import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import custom_image from '@images/logos/notification.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -52,9 +46,11 @@ const notificationInfo = ref<NotificationConf>({
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
synologychat: t('notification.synologychat.name'),
|
||||
slack: t('notification.slack.name'),
|
||||
discord: t('notification.discord.name'),
|
||||
webpush: t('notification.webpush.name'),
|
||||
custom: t('setting.notification.custom'),
|
||||
}
|
||||
@@ -68,13 +64,43 @@ const notificationTypes = [
|
||||
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
|
||||
{ value: '手动处理', title: t('notificationSwitch.manual') },
|
||||
{ value: '插件', title: t('notificationSwitch.plugin') },
|
||||
{ value: '智能体', title: t('notificationSwitch.agent') },
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechat') {
|
||||
return
|
||||
}
|
||||
if (!notification.config) {
|
||||
notification.config = {}
|
||||
}
|
||||
if (!notification.config.WECHAT_MODE) {
|
||||
notification.config.WECHAT_MODE = 'app'
|
||||
}
|
||||
if (!notification.config.WECHAT_BOT_WS_URL) {
|
||||
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
}
|
||||
|
||||
const isWechatBotMode = computed({
|
||||
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
|
||||
set: value => {
|
||||
if (!notificationInfo.value.config) {
|
||||
notificationInfo.value.config = {}
|
||||
}
|
||||
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
|
||||
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
|
||||
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
@@ -90,6 +116,7 @@ function saveNotificationInfo() {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
@@ -99,19 +126,23 @@ function saveNotificationInfo() {
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return wechat_image
|
||||
return getLogoUrl('wechat')
|
||||
case 'telegram':
|
||||
return telegram_image
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
return getLogoUrl('qq')
|
||||
case 'vocechat':
|
||||
return vocechat_image
|
||||
return getLogoUrl('vocechat')
|
||||
case 'synologychat':
|
||||
return synologychat_image
|
||||
return getLogoUrl('synologychat')
|
||||
case 'slack':
|
||||
return slack_image
|
||||
return getLogoUrl('slack')
|
||||
case 'discord':
|
||||
return getLogoUrl('discord')
|
||||
case 'webpush':
|
||||
return chrome_image
|
||||
return getLogoUrl('chrome')
|
||||
default:
|
||||
return custom_image
|
||||
return getLogoUrl('notification')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,22 +153,24 @@ function onClose() {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -190,69 +223,129 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
<VSwitch
|
||||
v-model="isWechatBotMode"
|
||||
:label="t('notification.wechat.useBotMode')"
|
||||
:hint="t('notification.wechat.useBotModeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
<template v-if="isWechatBotMode">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_ID"
|
||||
:label="t('notification.wechat.botId')"
|
||||
:hint="t('notification.wechat.botIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-robot"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
|
||||
:label="t('notification.wechat.botSecret')"
|
||||
:hint="t('notification.wechat.botSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
|
||||
:label="t('notification.wechat.botChatId')"
|
||||
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
|
||||
:hint="t('notification.wechat.botChatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
|
||||
:label="t('notification.wechat.botWsUrl')"
|
||||
:hint="t('notification.wechat.botWsUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lan-connect"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -356,6 +449,47 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'discord'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
|
||||
:label="t('notification.discord.botToken')"
|
||||
:hint="t('notification.discord.botTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_GUILD_ID"
|
||||
:label="t('notification.discord.guildId')"
|
||||
:placeholder="t('notification.discord.guildIdPlaceholder')"
|
||||
:hint="t('notification.discord.guildIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
|
||||
:label="t('notification.discord.channelId')"
|
||||
:placeholder="t('notification.discord.channelIdPlaceholder')"
|
||||
:hint="t('notification.discord.channelIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-pound-box"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -426,6 +560,56 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'qqbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_APP_ID"
|
||||
:label="t('notification.qqbot.appId')"
|
||||
:hint="t('notification.qqbot.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_APP_SECRET"
|
||||
:label="t('notification.qqbot.appSecret')"
|
||||
:hint="t('notification.qqbot.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_OPENID"
|
||||
:label="t('notification.qqbot.openId')"
|
||||
:placeholder="t('notification.qqbot.openIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_GROUP_OPENID"
|
||||
:label="t('notification.qqbot.groupOpenId')"
|
||||
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.groupOpenIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useToast } from 'vue-toastification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -103,7 +104,7 @@ async function installPlugin() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
@@ -117,6 +118,9 @@ const iconPath: Ref<string> = computed(() => {
|
||||
function visitPluginPage() {
|
||||
// 将raw.githubusercontent.com转换为项目地址
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
@@ -244,7 +248,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
@@ -327,7 +331,7 @@ const dropdownItems = ref([
|
||||
}}</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
|
||||
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
@@ -24,6 +25,10 @@ const props = defineProps({
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -167,7 +172,7 @@ async function showPluginConfig() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
@@ -268,6 +273,14 @@ function openPluginDetail() {
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openPluginDetail()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
@@ -419,6 +432,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -432,11 +446,13 @@ watch(
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
@@ -457,7 +473,7 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -481,7 +497,11 @@ watch(
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
@@ -492,10 +512,10 @@ watch(
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
@@ -565,13 +585,13 @@ watch(
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -587,7 +607,7 @@ watch(
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
},
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -165,6 +169,14 @@ function openFolder() {
|
||||
emit('open', props.folderName)
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openFolder()
|
||||
}
|
||||
|
||||
// 重命名文件夹
|
||||
function showRenameDialog() {
|
||||
newFolderName.value = props.folderName || ''
|
||||
@@ -275,11 +287,12 @@ const dropdownItems = ref([
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="openFolder"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
@@ -302,14 +315,14 @@ const dropdownItems = ref([
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
@@ -321,7 +334,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
@@ -491,6 +504,10 @@ const dropdownItems = ref([
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
pluginStatistics?: { [key: string]: number }
|
||||
pluginActions?: { [key: string]: boolean }
|
||||
showRemoveButton?: boolean
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pluginStatistics: () => ({}),
|
||||
pluginActions: () => ({}),
|
||||
showRemoveButton: false,
|
||||
sortable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(event: DragEvent) {
|
||||
// 只有当拖拽的是插件时才允许放入文件夹
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDropToFolder(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:folder-name="item.data.name"
|
||||
:plugin-count="item.data.pluginCount"
|
||||
:folder-config="item.data.config"
|
||||
:sortable="sortable"
|
||||
@open="$emit('openFolder', item.id)"
|
||||
@delete="$emit('deleteFolder', item.id)"
|
||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:count="pluginStatistics[item.id] || 0"
|
||||
:plugin="item.data"
|
||||
:action="pluginActions[item.id] || false"
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
|
||||
<VBtn
|
||||
v-if="showRemoveButton"
|
||||
v-if="showRemoveButton && !sortable"
|
||||
icon="mdi-folder-remove"
|
||||
variant="text"
|
||||
color="warning"
|
||||
|
||||
@@ -28,7 +28,12 @@ function getChipColor(type: string) {
|
||||
const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
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)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import noImage from '@images/logos/site.webp'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
@@ -12,6 +12,7 @@ import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -25,6 +26,10 @@ const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -34,7 +39,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
const defaultSiteIcon = getLogoUrl('site')
|
||||
const siteIcon = ref<string>(defaultSiteIcon)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -59,12 +65,20 @@ const siteUserDataDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
const siteId = cardProps.site?.id
|
||||
if (!siteId) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = noImage
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +123,22 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
handleResourceBrowse()
|
||||
}
|
||||
|
||||
function handleSiteUrlClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openSitePage()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -196,31 +226,40 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
hover
|
||||
@click="handleResourceBrowse"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative flex-1 flex flex-col p-3 z-1">
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
@@ -231,22 +270,42 @@ onMounted(() => {
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="flex items-center gap-2 ml-auto mr-10">
|
||||
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,10 +313,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
@@ -289,7 +348,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
@@ -412,7 +471,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -266,6 +270,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
@@ -308,6 +313,10 @@ function onSubscribeEditRemove() {
|
||||
|
||||
// 处理卡片点击事件
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.batchMode) {
|
||||
// 批量模式下触发选择事件
|
||||
emit('select')
|
||||
@@ -325,7 +334,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
@@ -336,13 +345,14 @@ function handleCardClick() {
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-4">
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
@@ -380,7 +390,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
@@ -400,8 +410,15 @@ function handleCardClick() {
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
@@ -411,7 +428,8 @@ function handleCardClick() {
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -32,8 +34,7 @@ const downloadItem = ref(props.torrent)
|
||||
// 站点图标
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
@@ -41,8 +42,7 @@ const addDownloadDialog = ref(false)
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -53,10 +53,21 @@ function addDownloadError(error: string) {
|
||||
// 查询站点图标
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,20 +120,27 @@ async function openMoreTorrentsDialog() {
|
||||
showMoreTorrents.value = true
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -136,8 +154,8 @@ onMounted(() => {
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold text-truncate me-2">
|
||||
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<VChip
|
||||
@@ -183,14 +201,14 @@ onMounted(() => {
|
||||
<!-- 种子内容 -->
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<!-- 种子标题 -->
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,37 +24,31 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
if (!torrent?.value?.site) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ async function handleAddDownload() {
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -97,10 +92,16 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +109,7 @@ onMounted(() => {
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
@@ -140,7 +141,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle>
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<VChip
|
||||
@@ -153,12 +154,12 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
|
||||
528
src/components/dialog/AboutDialog.vue
Normal file
@@ -0,0 +1,528 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP版本
|
||||
const appVersion = __APP_VERSION__
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
// 支持站点
|
||||
const supportingSites = ref<any>({})
|
||||
|
||||
// 支持站点折叠状态
|
||||
const sitesExpanded = ref(false)
|
||||
|
||||
// 去重后的支持站点
|
||||
const uniqueSupportingSites = computed(() => {
|
||||
const sitesMap = new Map()
|
||||
|
||||
Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {
|
||||
if (!sitesMap.has(site.name)) {
|
||||
sitesMap.set(site.name, {
|
||||
name: site.name,
|
||||
urls: [{ domain, url: site.url }],
|
||||
})
|
||||
} else {
|
||||
sitesMap.get(site.name).urls.push({ domain, url: site.url })
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(sitesMap.values())
|
||||
})
|
||||
|
||||
// 显示的支持站点(折叠时只显示前5个)
|
||||
const displayedSites = computed(() => {
|
||||
if (sitesExpanded.value) {
|
||||
return uniqueSupportingSites.value
|
||||
}
|
||||
return uniqueSupportingSites.value.slice(0, 5)
|
||||
})
|
||||
|
||||
// 变更日志对话框
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 最新版本
|
||||
const latestRelease = ref('')
|
||||
|
||||
// 变更日志对话框标题
|
||||
const releaseDialogTitle = ref('')
|
||||
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body ? md.render(body) : ''
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询支持站点
|
||||
async function querySupportingSites() {
|
||||
try {
|
||||
supportingSites.value = await api.get('site/supporting')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换站点列表展开状态
|
||||
function toggleSitesExpanded() {
|
||||
sitesExpanded.value = !sitesExpanded.value
|
||||
}
|
||||
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
return formatDateDifference(releaseDate)
|
||||
}
|
||||
|
||||
// 强制清除缓存
|
||||
async function clearCache() {
|
||||
await clearCachesAndServiceWorker()
|
||||
// 刷新页面,添加时间戳参数以强制更新
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-information" class="me-2" />
|
||||
{{ t('setting.about.title') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div class="section border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.softwareVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a
|
||||
v-if="latestRelease === systemEnv.VERSION"
|
||||
href="https://github.com/jxxghp/MoviePilot/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
|
||||
>
|
||||
{{ t('setting.about.latest') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemEnv.FRONTEND_VERSION">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.frontendVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.browserVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ appVersion }}</code>
|
||||
<VBtn
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2"
|
||||
@click="clearCache"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
{{ t('setting.about.clearCache') }}
|
||||
</VBtn>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.authVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.indexerVersion') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.configDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.dataDir') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all"
|
||||
><code>{{ t('setting.about.dataDirectory') }}</code></span
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.timezone') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2 mt-1 ms-1">
|
||||
<VChip v-for="site in displayedSites" :key="site.name" variant="outlined" size="small">
|
||||
<span class="truncate max-w-32">{{ site.name }}</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="!sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span> {{ uniqueSupportingSites.length }}+ ...</span>
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="sitesExpanded && uniqueSupportingSites.length > 5"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="toggleSitesExpanded"
|
||||
>
|
||||
<span>< {{ t('setting.about.collapse') }}</span>
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.support') }}</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.documentation') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://movie-pilot.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://movie-pilot.org
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.feedback') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.channel') }}</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow break-all">
|
||||
<a
|
||||
href="https://t.me/moviepilot_channel"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">{{ t('setting.about.versions') }}</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div
|
||||
v-for="release in allRelease"
|
||||
:key="release.tag_name"
|
||||
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
|
||||
releaseTime(release.published_at)
|
||||
}}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === latestRelease"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
|
||||
>
|
||||
{{ t('setting.about.latestVersion') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.tag_name === systemEnv.VERSION"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
|
||||
>
|
||||
{{ t('setting.about.currentVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline" />
|
||||
</template>
|
||||
{{ t('setting.about.viewChangelog') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style type="scss" scoped>
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-block: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding-inline-start: 1rem;
|
||||
margin-block: 0.5rem;
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
|
||||
.markdown-body :deep(hr) {
|
||||
margin-block: 1rem;
|
||||
border: none;
|
||||
border-block-start: 1px solid rgba(127, 127, 127, 0.3);
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
width: 100%;
|
||||
margin-block: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid rgba(127, 127, 127, 0.3);
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
font-weight: 600;
|
||||
background-color: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
@@ -56,9 +78,21 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
@@ -96,6 +130,14 @@ async function addDownload() {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
@@ -181,7 +223,6 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
:label="t('dialog.addDownload.downloader')"
|
||||
variant="underlined"
|
||||
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
||||
@@ -194,7 +235,6 @@ onMounted(() => {
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addDownload.saveDirectory')"
|
||||
size="small"
|
||||
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@@ -202,6 +242,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
@@ -209,5 +296,15 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
663
src/components/dialog/CategoryEditDialog.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
235
src/components/dialog/OTPAuthDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
qrCodeImage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动获取 URI
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
getOtpUri()
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cellphone-key" class="me-2" />
|
||||
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="props.isOtp && !qrCode">
|
||||
<VAlert type="success" variant="tonal" class="mb-4">
|
||||
{{ t('profile.authenticatorEnabled') }}
|
||||
</VAlert>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.clearAuthenticatorTip') }}
|
||||
</p>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
321
src/components/dialog/PasskeyDialog.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
interface PublicKeyCredentialDescriptorJSON {
|
||||
id: string
|
||||
type: 'public-key'
|
||||
transports?: AuthenticatorTransport[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
$toast.error(t('login.passkeySecureContextRequired'))
|
||||
} else {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
user: {
|
||||
...publicKeyOptions.user,
|
||||
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||
},
|
||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.deletePasskey'),
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动加载列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
fetchPassKeyList()
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="material-symbols:passkey" class="me-2" />
|
||||
{{ t('profile.passkeyManagement') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.passkeyAppDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<VAlert type="warning" variant="tonal" class="mb-6" icon="mdi-alert">
|
||||
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||
<template #domain>
|
||||
<b>{{ t('profile.accessDomain') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
<VForm @submit.prevent="registerPassKey">
|
||||
<VTextField
|
||||
v-model="passkeyName"
|
||||
:label="t('profile.passkeyName')"
|
||||
:placeholder="t('profile.passkeyNamePlaceholder')"
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
<VBtn color="primary" type="submit" :loading="passkeyRegistering" prepend-icon="mdi-plus">
|
||||
{{ t('profile.registerPasskey') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
|
||||
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
|
||||
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
|
||||
<span v-if="passkey.last_used_at">
|
||||
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn
|
||||
variant="flat"
|
||||
color="error"
|
||||
size="small"
|
||||
class="rounded delete-btn"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-6">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn.delete-btn {
|
||||
min-width: 45px;
|
||||
padding: 0;
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn.delete-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.2);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
|
||||
// 插件仓库设置字符串
|
||||
const repoString = ref('')
|
||||
// 用于显示的仓库地址数组
|
||||
const repoArray = ref<string[]>([])
|
||||
const repoList = ref<string[]>([])
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
|
||||
// 计算属性:在数组和换行符分隔的字符串之间转换
|
||||
const displayRepos = computed({
|
||||
get: () => repoArray.value.join('\n'),
|
||||
set: (value: string) => {
|
||||
repoArray.value = value.split('\n').filter((repo: string) => repo.trim() !== '')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
// 查询已设置的插件仓库
|
||||
async function queryMarketRepoSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||
if (result && result.data && result.data.value) {
|
||||
repoString.value = result.data.value
|
||||
repoArray.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
||||
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
async function saveHandle() {
|
||||
try {
|
||||
// 将数组转换为逗号分隔的字符串
|
||||
const repoStringToSave = repoArray.value.join(',')
|
||||
const repoStringToSave = repoList.value.join(',')
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
||||
|
||||
if (result.success) {
|
||||
@@ -57,6 +42,76 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.value.trim()
|
||||
if (!url) return
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
if (repoList.value.includes(url)) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
repoList.value.push(url)
|
||||
newRepoUrl.value = ''
|
||||
}
|
||||
|
||||
function removeRepo(index: number) {
|
||||
repoList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function startEdit(index: number) {
|
||||
editingIndex.value = index
|
||||
editingUrl.value = repoList.value[index]
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (editingIndex.value === null) return
|
||||
|
||||
const url = editingUrl.value.trim()
|
||||
if (!url) return
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
repoList.value[editingIndex.value] = url
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
function formatRepoDisplay(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and fall back to the original value.
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function repoItemKey(repo: string) {
|
||||
return repo
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryMarketRepoSetting()
|
||||
})
|
||||
@@ -64,7 +119,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCard class="plugin-market-dialog-card">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||
@@ -73,21 +128,127 @@ onMounted(() => {
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea
|
||||
v-model="displayRepos"
|
||||
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
|
||||
:hint="t('dialog.pluginMarketSetting.repoHint')"
|
||||
persistent-hint
|
||||
auto-grow
|
||||
/>
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-input mb-4">
|
||||
<VTextField
|
||||
v-model="newRepoUrl"
|
||||
density="compact"
|
||||
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
|
||||
prepend-inner-icon="mdi-link-plus"
|
||||
clearable
|
||||
@keyup.enter="addRepo"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</div>
|
||||
|
||||
<div class="plugin-market-list-wrap">
|
||||
<VList v-if="repoList.length > 0" class="px-0">
|
||||
<draggable
|
||||
v-model="repoList"
|
||||
:item-key="repoItemKey"
|
||||
handle=".drag-handle"
|
||||
animation="200"
|
||||
:disabled="editingIndex !== null"
|
||||
>
|
||||
<template #item="{ element: repo, index }">
|
||||
<div>
|
||||
<VListItem class="py-2">
|
||||
<template #prepend>
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="drag-handle me-2"
|
||||
:disabled="editingIndex !== null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VListItemTitle v-if="editingIndex !== index">
|
||||
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
|
||||
</VListItemTitle>
|
||||
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="editingUrl"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@keyup.enter="saveEdit"
|
||||
@keyup.escape="cancelEdit"
|
||||
/>
|
||||
|
||||
<template #append v-if="editingIndex !== index">
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
|
||||
<IconBtn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="removeRepo(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append v-else>
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
|
||||
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</VList>
|
||||
|
||||
<div v-else class="text-center text-medium-emphasis py-8">
|
||||
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
|
||||
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
|
||||
<VBtn
|
||||
@click="saveHandle"
|
||||
prepend-icon="mdi-content-save-check"
|
||||
class="px-5 me-3"
|
||||
:disabled="repoList.length === 0"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.plugin-market-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/ap
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -63,6 +64,9 @@ const progressText = ref(t('dialog.reorganize.processing'))
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 进度SSE连接
|
||||
const progressSSE = ref<any>(null)
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
|
||||
@@ -200,25 +204,31 @@ function handleProgressMessage(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的进度SSE连接
|
||||
const progressSSE = useProgressSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
|
||||
handleProgressMessage,
|
||||
'reorganize-progress',
|
||||
progressActive,
|
||||
)
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
function startLoadingProgress(key: string) {
|
||||
progressText.value = t('dialog.reorganize.processing')
|
||||
progressActive.value = true
|
||||
progressSSE.start()
|
||||
|
||||
// 如果已经有连接,先停止
|
||||
if (progressSSE.value) {
|
||||
progressSSE.value.stop()
|
||||
}
|
||||
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`
|
||||
|
||||
// 创建新的SSE连接
|
||||
progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)
|
||||
|
||||
progressSSE.value.start()
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressActive.value = false
|
||||
progressSSE.stop()
|
||||
if (progressSSE.value) {
|
||||
progressSSE.value.stop()
|
||||
progressSSE.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
@@ -228,25 +238,30 @@ async function transfer(background: boolean = false) {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
|
||||
if (!background) {
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
}
|
||||
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
if (!background) {
|
||||
// 如果是文件,计算MD5
|
||||
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
|
||||
|
||||
// 开始监听进度
|
||||
startLoadingProgress(key)
|
||||
}
|
||||
await handleTransfer(item, background)
|
||||
}
|
||||
}
|
||||
|
||||
// 日志整理
|
||||
if (props.logids) {
|
||||
if (!background) {
|
||||
// 为日志整理任务开启进度监听
|
||||
startLoadingProgress('filetransfer')
|
||||
}
|
||||
for (const logid of props.logids) {
|
||||
await handleTransferLog(logid, background)
|
||||
}
|
||||
}
|
||||
|
||||
if (!background) {
|
||||
// 停止监听进度
|
||||
stopLoadingProgress()
|
||||
|
||||
@@ -140,7 +140,7 @@ onMounted(async () => {
|
||||
await fetchSiteInfo()
|
||||
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
|
||||
isLimit.value = true
|
||||
if (siteForm.value.apikey) siteType.value = 'api'
|
||||
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
|
||||
}
|
||||
await loadDownloaderSetting()
|
||||
})
|
||||
@@ -224,15 +224,15 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="cookie" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
|
||||
<VIcon size="20" start icon="mdi-cookie" />
|
||||
Cookie
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="api" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-api" value="api" />
|
||||
<VIcon size="20" start icon="mdi-api" />
|
||||
API
|
||||
</div>
|
||||
</VTab>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -23,6 +26,30 @@ const selectCategory = ref<number[]>([])
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 当前页
|
||||
const resourcePage = ref(1)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 移动端搜索栏是否展开
|
||||
const mobileSearchExpanded = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
@@ -30,77 +57,85 @@ const categoryOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
const resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
const resourceHeaders = computed(() => [
|
||||
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
||||
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
||||
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
||||
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
||||
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
])
|
||||
|
||||
// 输入框标签
|
||||
const keywordFieldLabel = computed(() => {
|
||||
return keyword.value ? '' : t('dialog.siteResource.searchKeyword')
|
||||
})
|
||||
|
||||
const categoryFieldLabel = computed(() => {
|
||||
return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')
|
||||
})
|
||||
|
||||
// 结果统计文案
|
||||
const resultSummaryText = computed(() => {
|
||||
if (locale.value.startsWith('zh')) {
|
||||
return `共 ${resourceTotalItems.value} 条结果`
|
||||
}
|
||||
|
||||
return `${resourceTotalItems.value} results`
|
||||
})
|
||||
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
if (!page_url) return
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
if (!enclosure) return
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
async function addDownload(_torrent: TorrentInfo) {
|
||||
torrent.value = _torrent
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
function addDownloadSuccess(_url: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
function addDownloadError(_error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
resourcePage.value = 1
|
||||
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
@@ -111,7 +146,12 @@ async function getResourceList() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
resourceLoading.value = false
|
||||
|
||||
if (isMobileLayout.value) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
@@ -123,16 +163,44 @@ async function getSiteCategoryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
watch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {
|
||||
if (display.mdAndUp.value) {
|
||||
const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))
|
||||
if (resourcePage.value > maxPage) {
|
||||
resourcePage.value = maxPage
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => display.mdAndUp.value,
|
||||
isDesktop => {
|
||||
if (isDesktop) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
|
||||
// 装载时查询站点分类和资源
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
|
||||
<VCard class="site-resource-dialog">
|
||||
<div>
|
||||
<VToolbar color="primary" density="comfortable">
|
||||
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
||||
@@ -144,45 +212,153 @@ onMounted(() => {
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
size="small"
|
||||
density="compact"
|
||||
:label="t('dialog.siteResource.searchKeyword')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
:label="t('dialog.siteResource.resourceCategory')"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
|
||||
<div class="pa-3 pb-2">
|
||||
<template v-if="!isMobileLayout">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3" class="d-flex align-center">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
size="default"
|
||||
rounded="lg"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="site-resource-search-btn"
|
||||
@click="getResourceList"
|
||||
>
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="resourceTotalItems > 0"
|
||||
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
|
||||
{{ resourceTotalItems }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="site-resource-mobile-search">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="site-resource-mobile-search__toggle"
|
||||
@click="toggleMobileSearch"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-if="mobileSearchExpanded" class="mt-2">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
autofocus
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" class="d-flex gap-2">
|
||||
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</template>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
|
||||
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-if="display.mdAndUp.value"
|
||||
v-model:page="resourcePage"
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
@@ -191,60 +367,69 @@ onMounted(() => {
|
||||
hover
|
||||
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
||||
:loading-text="t('dialog.siteResource.loading')"
|
||||
class="h-full"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
height="100%"
|
||||
class="h-full site-resource-table"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
<div class="mt-2">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-medium-emphasis">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
@@ -268,11 +453,119 @@ onMounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
||||
</VDataTable>
|
||||
|
||||
<div v-else class="site-resource-mobile">
|
||||
<div v-if="resourceLoading" class="px-4 py-6">
|
||||
<VProgressLinear color="primary" indeterminate rounded />
|
||||
<div class="text-center text-body-2 text-medium-emphasis mt-3">
|
||||
{{ t('dialog.siteResource.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
|
||||
<VCard
|
||||
v-for="(item, index) in mobileResourceList"
|
||||
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
|
||||
class="mb-3"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
{{ t('dialog.siteResource.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 添加下载对话框 -->
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -285,7 +578,160 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.site-resource-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel {
|
||||
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field) {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field__prepend-inner) {
|
||||
color: rgba(var(--v-theme-primary), 0.85);
|
||||
}
|
||||
|
||||
.site-resource-search-btn {
|
||||
box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);
|
||||
letter-spacing: 0.02em;
|
||||
min-block-size: 40px;
|
||||
}
|
||||
|
||||
.site-resource-result-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search__toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-title-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-table {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table-footer) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.site-resource-dialog {
|
||||
block-size: min(88vh, 960px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 959px) {
|
||||
.site-resource-dialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
|
||||
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<template v-if="historyList.length > 0">
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 常量定义
|
||||
const AUTH_WINDOW_WIDTH = 600
|
||||
const AUTH_WINDOW_HEIGHT = 700
|
||||
const POLL_INTERVAL = 2000
|
||||
const AUTH_STATUS_SUCCESS = 2
|
||||
const AUTH_STATUS_FAILED = -1
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
@@ -18,24 +24,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
// Events 定义
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
// 响应式状态
|
||||
const authUrl = ref('')
|
||||
const authState = ref('')
|
||||
const text = ref('')
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning'>('info')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref(t('dialog.u115Auth.scanQrCode'))
|
||||
// 授权窗口引用
|
||||
let authWindow: Window | null = null
|
||||
let pollTimer: NodeJS.Timeout | undefined
|
||||
|
||||
// 提醒类型
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||
// 清理资源
|
||||
function cleanup() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.close()
|
||||
authWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
// 设置提示消息
|
||||
function setMessage(type: typeof alertType.value, message: string) {
|
||||
alertType.value = type
|
||||
text.value = message
|
||||
}
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
// 完成授权
|
||||
function handleDone() {
|
||||
cleanup()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -44,73 +66,118 @@ async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
|
||||
if (result.success) {
|
||||
// 重置成功
|
||||
alertType.value = 'success'
|
||||
setMessage('success', t('dialog.u115Auth.authSuccess'))
|
||||
handleDone()
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 调用/u115/qrcode api生成二维码
|
||||
async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
catch (error) {
|
||||
console.error('Reset failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
// 获取授权URL
|
||||
async function fetchAuthUrl() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
authUrl.value = result.data.authUrl
|
||||
authState.value = result.data.state
|
||||
}
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Fetch auth URL failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 打开授权窗口
|
||||
function openAuthWindow() {
|
||||
if (!authUrl.value) {
|
||||
setMessage('error', t('dialog.u115Auth.urlEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const left = (window.screen.width - AUTH_WINDOW_WIDTH) / 2
|
||||
const top = (window.screen.height - AUTH_WINDOW_HEIGHT) / 2
|
||||
const features = [
|
||||
`width=${AUTH_WINDOW_WIDTH}`,
|
||||
`height=${AUTH_WINDOW_HEIGHT}`,
|
||||
`left=${left}`,
|
||||
`top=${top}`,
|
||||
'toolbar=no',
|
||||
'location=no',
|
||||
'status=no',
|
||||
'menubar=no',
|
||||
'scrollbars=yes',
|
||||
'resizable=yes',
|
||||
].join(',')
|
||||
|
||||
authWindow = window.open(authUrl.value, '115授权', features)
|
||||
|
||||
if (authWindow) {
|
||||
setMessage('info', t('dialog.u115Auth.authorizing'))
|
||||
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
|
||||
}
|
||||
else {
|
||||
setMessage('error', t('dialog.u115Auth.popupBlocked'))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查授权状态
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
const status = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (status == 0) {
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 1) {
|
||||
// 已扫码
|
||||
alertType.value = 'info'
|
||||
text.value = t('dialog.u115Auth.scanned')
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 2) {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
const { status, tip } = result.data
|
||||
|
||||
if (status === AUTH_STATUS_SUCCESS) {
|
||||
// 授权成功
|
||||
setMessage('success', t('dialog.u115Auth.authSuccess'))
|
||||
handleDone()
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
alertType.value = 'error'
|
||||
return
|
||||
}
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
|
||||
if (status === AUTH_STATUS_FAILED) {
|
||||
// 授权失败或过期
|
||||
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// status === 0 或 1,继续等待
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Check auth status failed:', error)
|
||||
}
|
||||
|
||||
// 检查窗口是否被用户关闭
|
||||
if (authWindow?.closed) {
|
||||
setMessage('warning', t('dialog.u115Auth.authCanceled'))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchAuthUrl()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -118,31 +185,63 @@ onUnmounted(() => {
|
||||
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-qrcode" class="me-2" />
|
||||
<VIcon icon="mdi-shield-key" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('dialog.u115Auth.loginTitle') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="pt-2 flex flex-col items-center justify-center">
|
||||
<div class="mt-6 rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
<!-- 授权按钮 -->
|
||||
<div class="mt-6 mb-4 text-center">
|
||||
<VBtn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
prepend-icon="mdi-login"
|
||||
:disabled="!authUrl"
|
||||
class="px-8"
|
||||
@click="openAuthWindow"
|
||||
>
|
||||
{{ t('dialog.u115Auth.openAuthWindow') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="text" class="w-full">
|
||||
<VAlert
|
||||
variant="tonal"
|
||||
:type="alertType"
|
||||
:text="text"
|
||||
class="my-4 text-center"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VBtn
|
||||
color="error"
|
||||
prepend-icon="mdi-restore"
|
||||
class="px-5 me-3"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
</VBtn>
|
||||
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
|
||||
<VBtn
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5 me-3"
|
||||
@click="handleDone"
|
||||
>
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -93,6 +93,7 @@ const userForm = ref<ExtendedUser>({
|
||||
wechat_userid: null,
|
||||
telegram_userid: null,
|
||||
slack_userid: null,
|
||||
discord_userid: null,
|
||||
vocechat_userid: null,
|
||||
synologychat_userid: null,
|
||||
},
|
||||
@@ -198,6 +199,7 @@ async function fetchUserInfo() {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
if (userForm.value) {
|
||||
userForm.value.avatar = userForm.value.avatar || avatar1
|
||||
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
currentUserName.value = userForm.value.name
|
||||
userName.value = userForm.value.name
|
||||
@@ -272,12 +274,10 @@ async function updateUser() {
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (userForm.value.nickname) {
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname ?? ''
|
||||
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
@@ -521,6 +521,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-slack"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.discord_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('dialog.userAddEdit.discord')"
|
||||
prepend-inner-icon="mdi-discord"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.vocechat_userid"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -13,6 +13,7 @@ import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -23,13 +24,16 @@ const display = useDisplay()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92
|
||||
const { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
@@ -82,6 +86,9 @@ const items = ref<FileItem[]>([])
|
||||
// 过滤条件
|
||||
const filter = ref('')
|
||||
|
||||
// 是否忽略大小写
|
||||
const ignoreCase = ref(true)
|
||||
|
||||
// 重命名弹窗
|
||||
const renamePopper = ref(false)
|
||||
|
||||
@@ -112,12 +119,26 @@ const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
// 通用过滤
|
||||
const getFilteredItems = (type: 'dir' | 'file') => {
|
||||
const filterValue = filter.value
|
||||
if (!filterValue) {
|
||||
return items.value.filter(item => item.type === type)
|
||||
}
|
||||
|
||||
if (ignoreCase.value) {
|
||||
const lowerCaseFilter = filterValue.toLowerCase()
|
||||
return items.value.filter(item => item.type === type && item.name.toLowerCase().includes(lowerCaseFilter))
|
||||
} else {
|
||||
return items.value.filter(item => item.type === type && item.name.includes(filterValue))
|
||||
}
|
||||
}
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.name.includes(filter.value)))
|
||||
const dirs = computed(() => getFilteredItems('dir'))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
|
||||
|
||||
const files = computed(() => getFilteredItems('file'))
|
||||
// 是否文件
|
||||
const isFile = computed(() => inProps.item.type == 'file')
|
||||
|
||||
@@ -127,29 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 计算列表可用高度
|
||||
const listAvailableHeight = computed(() => {
|
||||
// 获取视口高度
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
// navbar高度
|
||||
const navbarHeight = 72
|
||||
// 工具栏高度(包含搜索框和按钮)
|
||||
const toolbarHeight = 64
|
||||
// 底部导航栏高度
|
||||
const footerHeight = appMode.value ? 80 : 16
|
||||
// 安全区域高度
|
||||
const safeAreaHeight =
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
|
||||
0
|
||||
|
||||
// 计算可用高度,预留一些边距
|
||||
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
|
||||
|
||||
// 确保最小高度
|
||||
return Math.max(availableHeight, 300)
|
||||
})
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -166,6 +170,8 @@ function changeSelectMode() {
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files() {
|
||||
loading.value = true
|
||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
|
||||
const prevURI = takeURISnapshot();
|
||||
emit('loading', true)
|
||||
|
||||
// 参数
|
||||
@@ -178,7 +184,12 @@ async function list_files() {
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
|
||||
// 如果当前路径已经变化,则放弃此次加载结果
|
||||
if (prevURI !== takeURISnapshot()) {
|
||||
return;
|
||||
}
|
||||
items.value = data
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
@@ -277,10 +288,13 @@ async function download(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,9 +309,10 @@ async function getImgLink(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载二进制数据
|
||||
const result: Blob = await inProps.axios.request(config)
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -372,7 +390,7 @@ async function rename() {
|
||||
method: inProps.endpoints?.rename.method || 'post',
|
||||
data: currentItem.value,
|
||||
}
|
||||
const result: { [key: string]: any } = await inProps.axios?.request(config)
|
||||
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
|
||||
if (!result.success) {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
@@ -429,9 +447,9 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听item变化或者storage变化
|
||||
// 监听item变化
|
||||
watch(
|
||||
[() => inProps.item, () => inProps.storage],
|
||||
[() => inProps.item],
|
||||
async () => {
|
||||
// 清空列表
|
||||
items.value = []
|
||||
@@ -533,7 +551,7 @@ async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('file.scraping', { path: item.path })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
@@ -591,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -622,9 +645,11 @@ onMounted(() => {
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="ignoreCase = !ignoreCase">
|
||||
<VIcon :color="ignoreCase ? 'primary' : 'error'" icon="mdi-format-letter-case" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
@@ -789,7 +814,7 @@ onMounted(() => {
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
:target_storage="inProps.item.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
|
||||
@@ -2,47 +2,27 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 计算列表可用高度
|
||||
const availableHeight = computed(() => {
|
||||
// 获取视口高度
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
|
||||
// navbar高度
|
||||
const navbarHeight = 72
|
||||
// 工具栏高度
|
||||
const toolbarHeight = 25
|
||||
// 底部导航栏高度
|
||||
const footerHeight = appMode.value ? 80 : 16
|
||||
// 安全区域高度
|
||||
const safeAreaHeight =
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
|
||||
0
|
||||
|
||||
// 计算可用高度,预留一些边距
|
||||
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
|
||||
|
||||
// 确保最小高度
|
||||
return Math.max(availableHeight, 300)
|
||||
})
|
||||
// componentOffset = FileToolbar(48) = 48
|
||||
const { availableHeight } = useAvailableHeight(48, 300)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
required: true,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
@@ -54,7 +34,7 @@ const props = defineProps({
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
@@ -131,7 +111,7 @@ async function loadSubdirectories(path: string) {
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = await props.axios?.request(config)
|
||||
const result = (await props.axios?.request(config))
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
@@ -223,7 +203,7 @@ watch(
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
if (newItems) {
|
||||
// 过滤出目录项
|
||||
const dirs = newItems.filter(item => item.type === 'dir')
|
||||
|
||||
@@ -283,9 +263,6 @@ onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
updateHeight()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -309,7 +286,6 @@ onActivated(() => {
|
||||
<span>{{ t('file.rootDirectory') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -13,7 +13,6 @@ const display = useDisplay()
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
storages: Array as PropType<any[]>,
|
||||
storage: String,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
@@ -24,9 +23,17 @@ const inProps = defineProps({
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
sort: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
},
|
||||
showNewFolderButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -38,15 +45,10 @@ const newFolderPopper = ref(false)
|
||||
// 新建文件名称
|
||||
const newFolderName = ref('')
|
||||
|
||||
// 排序方式
|
||||
const sort = ref('name')
|
||||
|
||||
// 调整排序方式
|
||||
function changeSort() {
|
||||
if (sort.value === 'name') sort.value = 'time'
|
||||
else sort.value = 'name'
|
||||
|
||||
emit('sortchanged', sort.value)
|
||||
const newSort = inProps.sort === 'name' ? 'time' : 'name'
|
||||
emit('sortchanged', newSort)
|
||||
}
|
||||
|
||||
// 计算PATH面包屑
|
||||
@@ -67,12 +69,12 @@ const pathSegments = computed(() => {
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.value === inProps.storage)
|
||||
return inProps.storages?.find(item => item.value === inProps.item.storage)
|
||||
})
|
||||
|
||||
// 切换存储
|
||||
function changeStorage(code: string) {
|
||||
if (inProps.storage !== code) {
|
||||
if (inProps.item.storage!== code) {
|
||||
emit('storagechanged', code)
|
||||
}
|
||||
}
|
||||
@@ -111,11 +113,20 @@ async function mkdir() {
|
||||
emit('foldercreated')
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
newFolderName.value = ''
|
||||
newFolderPopper.value = true
|
||||
}
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
openNewFolderDialog,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -167,9 +178,9 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
<!-- 新建文件夹 -->
|
||||
<VDialog v-model="newFolderPopper" max-width="35rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn>
|
||||
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
|
||||
<template v-if="showNewFolderButton" #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VIcon icon="mdi-folder-plus-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VCard>
|
||||
|
||||
817
src/components/filter/TorrentFilterBar.vue
Normal file
@@ -0,0 +1,817 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps<{
|
||||
// 筛选表单
|
||||
filterForm: Record<string, string[]>
|
||||
// 筛选选项
|
||||
filterOptions: Record<string, string[]>
|
||||
// 排序字段
|
||||
sortField: string
|
||||
// 排序方向
|
||||
sortType: 'asc' | 'desc'
|
||||
// 筛选后的总数量
|
||||
totalFilteredCount: number
|
||||
// 过滤项标题映射
|
||||
filterTitles: Record<string, string>
|
||||
// 排序标题映射
|
||||
sortTitles: Record<string, string>
|
||||
// 是否启用滚动动画
|
||||
enableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
'update:sortField': [value: string]
|
||||
'update:sortType': [value: 'asc' | 'desc']
|
||||
'update:filterForm': [key: string, values: string[]]
|
||||
'selectAll': [key: string]
|
||||
'clearFilter': [key: string]
|
||||
'clearAllFilters': []
|
||||
'removeFilter': [key: string, value: string]
|
||||
}>()
|
||||
|
||||
// 过滤菜单相关
|
||||
const filterMenuOpen = ref(false)
|
||||
const currentFilter = ref('site')
|
||||
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
|
||||
const currentFilterOptions = computed(() => {
|
||||
return props.filterOptions[currentFilter.value]
|
||||
})
|
||||
|
||||
// 添加全部筛选菜单相关
|
||||
const allFilterMenuOpen = ref(false)
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in props.filterForm) {
|
||||
count += props.filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in props.filterForm) {
|
||||
if (props.filterForm[key].length > 0) {
|
||||
filters[key] = [...props.filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 开关全部筛选菜单
|
||||
function toggleAllFilterMenu() {
|
||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
||||
}
|
||||
|
||||
// 添加toggleFilterMenu函数
|
||||
function toggleFilterMenu(key: string) {
|
||||
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||
filterMenuOpen.value = false
|
||||
} else {
|
||||
currentFilter.value = key
|
||||
filterMenuOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选值变化
|
||||
function handleFilterChange(key: string, values: string[]) {
|
||||
emit('update:filterForm', key, values)
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
emit('selectAll', key)
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
emit('clearFilter', key)
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
emit('clearAllFilters')
|
||||
}
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
emit('removeFilter', key, value)
|
||||
}
|
||||
|
||||
// 滚动条引用
|
||||
const filterBarRef = ref<HTMLElement>()
|
||||
|
||||
/**
|
||||
* 自定义平滑滚动
|
||||
* @param element 元素
|
||||
* @param target 目标位置
|
||||
* @param duration 持续时间(ms)
|
||||
*/
|
||||
function smoothScroll(element: HTMLElement, target: number, duration: number) {
|
||||
const start = element.scrollLeft
|
||||
const change = target - start
|
||||
let startTime: number | null = null
|
||||
|
||||
function animate(currentTime: number) {
|
||||
if (startTime === null) startTime = currentTime
|
||||
const timeElapsed = currentTime - startTime
|
||||
const progress = Math.min(timeElapsed / duration, 1)
|
||||
|
||||
// 使用 ease-in-out 缓动函数
|
||||
const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
|
||||
element.scrollLeft = start + change * ease
|
||||
|
||||
if (timeElapsed < duration) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// 初始滚动动画
|
||||
onMounted(() => {
|
||||
if (filterBarRef.value) {
|
||||
useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault()
|
||||
filterBarRef.value!.scrollLeft += e.deltaY
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (props.enableAnimation === false) return
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const el = filterBarRef.value
|
||||
if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {
|
||||
// 检查当前视口范围内的最后一个元素(即右侧边缘处的元素)
|
||||
const containerRect = el.getBoundingClientRect()
|
||||
const children = Array.from(el.children) as HTMLElement[]
|
||||
const lastInViewport = children
|
||||
.filter(c => {
|
||||
const rect = c.getBoundingClientRect()
|
||||
return rect.left < containerRect.right
|
||||
})
|
||||
.pop()
|
||||
|
||||
if (lastInViewport) {
|
||||
const rect = lastInViewport.getBoundingClientRect()
|
||||
const visibleWidth = Math.min(rect.right, containerRect.right) - rect.left
|
||||
const visibleRatio = visibleWidth / rect.width
|
||||
|
||||
// 判断是否是列表最后一个元素
|
||||
const isLastItem = lastInViewport === children[children.length - 1]
|
||||
|
||||
// 1. 如果是最后一个元素,且显示比例超过80%,说明基本已经展示完了,不需要动画
|
||||
if (isLastItem && visibleRatio > 0.8) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 如果视口内最后一个元素显示比例在30%到80%之间(明显的截断状态),用户能感知到后面还有内容,不需要滚动提示
|
||||
// 比例过小(<0.3)可能看不清,非最后一个元素且比例过大(>0.8)可能误以为是结尾,这两种情况都需要提示
|
||||
if (visibleRatio > 0.3 && visibleRatio < 0.8) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部 (1100ms)
|
||||
smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)
|
||||
// 短暂停止后滚动回顶部 (1100ms)
|
||||
setTimeout(() => {
|
||||
smoothScroll(el, 0, 1100)
|
||||
}, 1600)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- PC端头部和筛选栏 -->
|
||||
<div class="search-header d-none d-sm-block">
|
||||
<VCard class="view-header mb-3">
|
||||
<div class="d-flex align-center pa-3">
|
||||
<!-- 固定位置:资源数量和排序 -->
|
||||
<div class="d-flex align-center flex-shrink-0">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="search-count me-3 flex-shrink-0"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<VBtn variant="text" size="small" class="sort-btn" :color="undefined">
|
||||
<template #prepend>
|
||||
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||
</template>
|
||||
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||
|
||||
<VMenu activator="parent" transition="slide-y-transition">
|
||||
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||
<!-- 升序/降序 选项 -->
|
||||
<VListItem
|
||||
value="asc"
|
||||
:active="sortType === 'asc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'asc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
value="desc"
|
||||
:active="sortType === 'desc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'desc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-1" />
|
||||
|
||||
<!-- 排序字段选项 -->
|
||||
<VListItem
|
||||
v-for="(title, key) in sortTitles"
|
||||
:key="key"
|
||||
:value="key"
|
||||
:active="sortField === key"
|
||||
color="primary"
|
||||
@click="emit('update:sortField', key as string)"
|
||||
class="px-3"
|
||||
>
|
||||
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<div class="filter-divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动区域:筛选条件 -->
|
||||
<div class="filter-bar" ref="filterBarRef">
|
||||
<!-- 筛选按钮 -->
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
>
|
||||
{{ title }}
|
||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ filterForm[key].length }}
|
||||
</VChip>
|
||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
||||
<VCard max-width="20rem">
|
||||
<VCardText class="filter-menu-content">
|
||||
<div class="flex justify-between">
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[key]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="filter-btn me-2"
|
||||
prepend-icon="mdi-filter-variant"
|
||||
rounded="pill"
|
||||
@click="toggleAllFilterMenu"
|
||||
>
|
||||
{{ t('torrent.allFilters') }}
|
||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ getFilterCount }}
|
||||
</VChip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getFilterCount > 0" class="selected-filters">
|
||||
<div class="d-flex align-center">
|
||||
<div class="d-flex flex-wrap align-center flex-grow-1">
|
||||
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||
<VChip
|
||||
v-for="(value, index) in values"
|
||||
:key="`${key}-${index}`"
|
||||
color="primary"
|
||||
size="small"
|
||||
closable
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 mt-1 filter-tag"
|
||||
@click:close="removeFilter(key as string, value)"
|
||||
>
|
||||
<VIcon size="small" :icon="getFilterIcon(key as string)" class="me-1"></VIcon>
|
||||
<strong>{{ filterTitles[key as string] }}:</strong> {{ value }}
|
||||
</VChip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 清除全部筛选按钮 -->
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
class="ms-2 flex-shrink-0"
|
||||
prepend-icon="mdi-close-circle-outline"
|
||||
>
|
||||
{{ t('torrent.clearFilters') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- 移动端头部和筛选区域 -->
|
||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
||||
<div class="view-header">
|
||||
<div class="d-flex align-center flex-wrap pa-2">
|
||||
<div class="d-flex align-center w-100">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="search-count me-auto"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<!-- 排序选择 -->
|
||||
<VBtn variant="text" size="small" class="sort-btn mobile-sort-btn" :color="undefined">
|
||||
<template #prepend>
|
||||
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||
</template>
|
||||
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||
|
||||
<VMenu activator="parent" transition="slide-y-transition">
|
||||
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||
<!-- 升序/降序 选项 -->
|
||||
<VListItem
|
||||
value="asc"
|
||||
:active="sortType === 'asc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'asc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
value="desc"
|
||||
:active="sortType === 'desc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'desc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-1" />
|
||||
|
||||
<!-- 排序字段选项 -->
|
||||
<VListItem
|
||||
v-for="(title, key) in sortTitles"
|
||||
:key="key"
|
||||
:value="key"
|
||||
:active="sortField === key"
|
||||
color="primary"
|
||||
@click="emit('update:sortField', key as string)"
|
||||
class="px-3"
|
||||
>
|
||||
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 筛选图标按钮区域 -->
|
||||
<div class="filter-buttons-grid w-100 mt-2">
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
>
|
||||
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ title }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="filterForm[key].length > 0"
|
||||
:content="filterForm[key].length"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="getFilterCount > 0"
|
||||
:content="getFilterCount"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 全部筛选弹窗 -->
|
||||
<VDialog
|
||||
v-model="allFilterMenuOpen"
|
||||
max-width="50rem"
|
||||
location="center"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||
<span>{{ t('torrent.allFilters') }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
class="me-10"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
{{ t('torrent.clearAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="all-filters-grid">
|
||||
<VCard
|
||||
v-for="(title, key) in filterTitles"
|
||||
variant="tonal"
|
||||
:key="key"
|
||||
class="filter-section"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ title }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[key]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||
<span>{{ currentFilterTitle }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="filterForm[currentFilter].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(currentFilter)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[currentFilter]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in currentFilterOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header,
|
||||
.search-header-mobile {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
height: 32px !important;
|
||||
font-weight: 500;
|
||||
padding-inline: 12px 6px !important;
|
||||
}
|
||||
|
||||
.sort-btn .v-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.sort-btn :deep(.v-btn__prepend) {
|
||||
margin-inline-end: 2px !important;
|
||||
}
|
||||
|
||||
.sort-menu-list {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.filter-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-bar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 24px;
|
||||
inline-size: 1px;
|
||||
margin-block: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filter-menu-content {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
font-size: 18px;
|
||||
margin-block-end: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.all-filters-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,44 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const menu = ref(false)
|
||||
const currentCron = ref(props.modelValue)
|
||||
const menuRoot = ref<HTMLElement>()
|
||||
const instance = getCurrentInstance()
|
||||
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
||||
const menuContentSelector = `.${menuContentClass}`
|
||||
|
||||
function isCronMenuTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) return false
|
||||
|
||||
if (menuRoot.value?.contains(target)) return true
|
||||
|
||||
const menuContent = document.querySelector(menuContentSelector)
|
||||
|
||||
if (menuContent?.contains(target)) return true
|
||||
|
||||
const overlayId = target.closest('.v-overlay')?.getAttribute('id')
|
||||
|
||||
if (!overlayId || !menuContent) return false
|
||||
|
||||
return Array.from(menuContent.querySelectorAll('[aria-owns]')).some(
|
||||
activator => activator.getAttribute('aria-owns') === overlayId,
|
||||
)
|
||||
}
|
||||
|
||||
function closeOnOutsidePointerDown(event: PointerEvent) {
|
||||
if (!menu.value || isCronMenuTarget(event.target)) return
|
||||
|
||||
menu.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
watch(currentCron, newVal => {
|
||||
emit('update:modelValue', newVal)
|
||||
@@ -23,8 +60,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
||||
<div ref="menuRoot">
|
||||
<VMenu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
:content-class="['cursor-default', menuContentClass]"
|
||||
persistent
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ const props = defineProps({
|
||||
root: {
|
||||
type: String,
|
||||
default: '/',
|
||||
required: true,
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
@@ -104,8 +103,21 @@ const selectedPath = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function isFileItem(value: unknown): value is FileItem {
|
||||
return typeof value === 'object' && value !== null && 'path' in value && 'type' in value
|
||||
}
|
||||
|
||||
function activateDir({ id }: { id: unknown }) {
|
||||
const item = isFileItem(id) ? id : typeof id === 'string' ? findPath(treeItems.value[0], id) : null
|
||||
|
||||
if (!item || item.type !== 'dir') return
|
||||
|
||||
activedDirs.value = [item]
|
||||
}
|
||||
|
||||
watch(activedDirs, newVal => {
|
||||
if (!newVal.length) return
|
||||
|
||||
emit('update:modelValue', selectedPath.value)
|
||||
})
|
||||
|
||||
@@ -166,8 +178,10 @@ watch(
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
open-on-click
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
@click:open="activateDir"
|
||||
/>
|
||||
</VMenu>
|
||||
</div>
|
||||
|
||||
@@ -91,10 +91,6 @@ onUnmounted(() => {
|
||||
<!-- Vue 渲染模式 -->
|
||||
<div v-if="pluginRenderMode === 'vue'">
|
||||
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
|
||||
<!-- Vue 模式下也可以显示拖拽句柄 -->
|
||||
<div class="absolute right-5 top-5">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VHover v-else-if="pluginRenderMode === 'vuetify'">
|
||||
|
||||
252
src/components/misc/ProgressiveCardGrid.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
estimatedItemHeight?: number
|
||||
scrollToIndex?: number
|
||||
gap?: number
|
||||
initialCount?: number
|
||||
batchSize?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
estimatedItemHeight: undefined,
|
||||
scrollToIndex: undefined,
|
||||
gap: 16,
|
||||
initialCount: 24,
|
||||
batchSize: 24,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const sentinelRef = ref<HTMLElement | null>(null)
|
||||
const renderedCount = ref(0)
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const safeInitialCount = computed(() => Math.max(1, props.initialCount))
|
||||
const safeBatchSize = computed(() => Math.max(1, props.batchSize))
|
||||
const hasMoreItems = computed(() => renderedCount.value < props.items.length)
|
||||
const visibleItems = computed(() => props.items.slice(0, renderedCount.value))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${props.gap}px`,
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${props.minItemWidth}px, 1fr))`,
|
||||
rowGap: `${props.gap}px`,
|
||||
}))
|
||||
|
||||
function getComparableKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
return getComparableKey(item, index)
|
||||
}
|
||||
|
||||
function appendNextBatch() {
|
||||
renderedCount.value = Math.min(props.items.length, renderedCount.value + safeBatchSize.value)
|
||||
}
|
||||
|
||||
function hasPageScroll() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
||||
|
||||
return scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||
}
|
||||
|
||||
async function fillViewport() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const maxIterations = Math.ceil(props.items.length / safeBatchSize.value)
|
||||
let iterations = 0
|
||||
|
||||
while (!hasPageScroll() && hasMoreItems.value && iterations < maxIterations) {
|
||||
appendNextBatch()
|
||||
iterations += 1
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
function queueFillViewport() {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null
|
||||
void fillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
async function revealItem(index: number) {
|
||||
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const minRenderedCount = Math.ceil((index + 1) / safeBatchSize.value) * safeBatchSize.value
|
||||
renderedCount.value = Math.min(props.items.length, Math.max(renderedCount.value, minRenderedCount))
|
||||
|
||||
await nextTick()
|
||||
|
||||
const target = containerRef.value?.querySelector(`[data-progressive-grid-index="${index}"]`)
|
||||
if (target instanceof HTMLElement) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetVisibleItems() {
|
||||
renderedCount.value = Math.min(props.items.length, safeInitialCount.value)
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function didItemsAppend(nextItems: any[], previousItems: any[]) {
|
||||
if (!previousItems.length || nextItems.length < previousItems.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return previousItems.every((item, index) => getComparableKey(item, index) === getComparableKey(nextItems[index], index))
|
||||
}
|
||||
|
||||
function syncVisibleItems(nextItems: any[], previousItems: any[] = []) {
|
||||
if (didItemsAppend(nextItems, previousItems)) {
|
||||
renderedCount.value = Math.min(nextItems.length, Math.max(renderedCount.value, previousItems.length))
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resetVisibleItems()
|
||||
}
|
||||
|
||||
const { stop } = useIntersectionObserver(
|
||||
sentinelRef,
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting || !hasMoreItems.value) {
|
||||
return
|
||||
}
|
||||
|
||||
appendNextBatch()
|
||||
queueFillViewport()
|
||||
},
|
||||
{
|
||||
rootMargin: '1200px 0px',
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', queueFillViewport, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
window.removeEventListener('resize', queueFillViewport)
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.minItemWidth,
|
||||
() => props.initialCount,
|
||||
() => props.batchSize,
|
||||
],
|
||||
() => {
|
||||
queueFillViewport()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(nextItems, previousItems) => {
|
||||
syncVisibleItems(nextItems, previousItems)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.scrollToIndex, () => props.items.length],
|
||||
([scrollToIndex]) => {
|
||||
if (scrollToIndex === undefined || scrollToIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
void revealItem(scrollToIndex)
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="progressive-card-grid">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="resolveItemKey(item, index)"
|
||||
class="progressive-card-grid__item"
|
||||
:data-progressive-grid-index="index"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMoreItems" ref="sentinelRef" class="progressive-card-grid__sentinel" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progressive-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.progressive-card-grid__item {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.progressive-card-grid__sentinel {
|
||||
block-size: 1px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -14,10 +37,79 @@ const props = defineProps({
|
||||
<VListItemTitle class="font-bold text-lg">
|
||||
{{ key }}
|
||||
</VListItemTitle>
|
||||
<div class="text-gray-500">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-block: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding-inline-start: 1rem;
|
||||
margin-block: 0.5rem;
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 判断是否可以触摸
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref<HTMLElement | null>(null)
|
||||
const sliderContainer = ref<HTMLElement | null>(null)
|
||||
// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用
|
||||
const disabled = ref(0)
|
||||
// 记录滚动值
|
||||
const slideview_scrollLeft = ref(0)
|
||||
// 所有卡片数量
|
||||
let slide_card_length: number
|
||||
// 卡片间距
|
||||
let slide_gap_px: number
|
||||
// 卡片宽度
|
||||
let card_width: number
|
||||
// 容器最多显示N张卡片
|
||||
let card_max: number
|
||||
// 当前定位
|
||||
let card_current: number
|
||||
// 获取传入的链接地址
|
||||
const props: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
const isScrolling = ref(false)
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const scrollTimeoutDuration = 1500 // 滚动停止后延迟时间 (ms)
|
||||
|
||||
// 分页切换
|
||||
function slideNext(next: boolean) {
|
||||
let run_to_left_px
|
||||
if (next) {
|
||||
const card_index = card_current + card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px >= slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth)
|
||||
run_to_left_px = slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
}
|
||||
slideview_content.value!.scrollTo({
|
||||
top: 0,
|
||||
left: run_to_left_px,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
// 点击后强制显示并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
// 计算最大显示数量
|
||||
function countMaxNumber() {
|
||||
if (!slideview_content.value || !slideview_content.value.firstElementChild) return
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||
card_width += slide_gap_px
|
||||
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||
countDisabled()
|
||||
}
|
||||
|
||||
// 修改分页切换按钮状态 & 处理滚动状态
|
||||
function handleContentScroll() {
|
||||
if (!slideview_content.value) return
|
||||
// 更新按钮禁用状态
|
||||
countDisabled()
|
||||
|
||||
// 更新滚动状态并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration) // 使用常量
|
||||
}
|
||||
|
||||
// 原始的 countDisabled 逻辑,现在由 handleContentScroll 调用
|
||||
function countDisabled() {
|
||||
if (!slideview_content.value) return
|
||||
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
||||
card_current =
|
||||
slideview_content.value.scrollLeft === 0
|
||||
? 0
|
||||
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
|
||||
else if (
|
||||
slideview_content.value.scrollLeft >=
|
||||
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
|
||||
)
|
||||
disabled.value = 2
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 组件加载完成
|
||||
onMounted(() => {
|
||||
// 初次获取元素参数
|
||||
countMaxNumber()
|
||||
// 窗口大小发生改变时
|
||||
window.addEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 卸载事件
|
||||
window.removeEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideview_scrollLeft.value !== 0) {
|
||||
slideview_content.value!.scrollLeft = slideview_scrollLeft.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sliderContainer" class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<!-- 查看全部按钮 -->
|
||||
<RouterLink v-if="props.linkurl" :to="props.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideview_content" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧导航按钮 -->
|
||||
<VBtn
|
||||
class="nav-button nav-button-left"
|
||||
@click.stop="slideNext(false)"
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<!-- 右侧导航按钮 -->
|
||||
<VBtn
|
||||
class="nav-button nav-button-right"
|
||||
@click.stop="slideNext(true)"
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
display: grid;
|
||||
overflow: scroll hidden !important;
|
||||
justify-content: start;
|
||||
gap: 16px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: 1fr;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸设备:滚动时显示 (通过 JS 添加的类控制)
|
||||
// 这个规则会在不支持 hover 的设备上生效
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 桌面设备:悬停时显示
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
// 这个规则会覆盖 .is-scrolling 的效果 (如果同时存在)
|
||||
// 或者在非 scrolling 状态下,hover 时也能显示
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 在 hover 设备上,即使在滚动,如果鼠标不悬停,按钮也应该隐藏
|
||||
// 因此,基础 .nav-button 的 opacity: 0 规则在这里仍然是必要的
|
||||
// (之前错误地以为 hover 会完全覆盖,但滚动时 class 和 hover 可能同时存在)
|
||||
// .nav-button { opacity: 0; pointer-events: none; } // 这行其实不需要重复,默认就是这样
|
||||
}
|
||||
</style>
|
||||
433
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
itemWidth?: number
|
||||
itemGap?: number
|
||||
overscanItems?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
itemWidth: 144,
|
||||
itemGap: 16,
|
||||
overscanItems: 4,
|
||||
getItemKey: undefined,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
|
||||
const slideContentRef = ref<HTMLElement | null>(null)
|
||||
const disabled = ref(0)
|
||||
const slideScrollLeft = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollTimeoutDuration = 1500
|
||||
const itemStep = computed(() => props.itemWidth + props.itemGap)
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
|
||||
|
||||
const visibleItemsWidth = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const totalContentWidth = computed(() => {
|
||||
if (!props.items.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = element.clientWidth
|
||||
if (!viewportWidth || !props.items.length) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||
return
|
||||
}
|
||||
|
||||
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
|
||||
const lastVisible = Math.min(
|
||||
props.items.length,
|
||||
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
|
||||
)
|
||||
|
||||
startIndex.value = firstVisible
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
slideScrollLeft.value = element.scrollLeft
|
||||
|
||||
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
|
||||
disabled.value = 3
|
||||
} else if (element.scrollLeft === 0) {
|
||||
disabled.value = 0
|
||||
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
|
||||
disabled.value = 2
|
||||
} else {
|
||||
disabled.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
|
||||
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||
let targetLeft = 0
|
||||
|
||||
if (next) {
|
||||
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
|
||||
} else {
|
||||
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
|
||||
}
|
||||
|
||||
element.scrollTo({
|
||||
behavior: 'smooth',
|
||||
left: targetLeft,
|
||||
top: 0,
|
||||
})
|
||||
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncLayoutState()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncLayoutState()
|
||||
})
|
||||
|
||||
if (slideContentRef.value) {
|
||||
resizeObserver.observe(slideContentRef.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', syncLayoutState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
scrollTimeout = null
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncLayoutState)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideContentRef.value && slideScrollLeft.value !== 0) {
|
||||
slideContentRef.value.scrollLeft = slideScrollLeft.value
|
||||
}
|
||||
|
||||
nextTick(syncLayoutState)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(syncLayoutState)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<template v-if="loading">
|
||||
<div class="loading-track" :style="{ gap: `${itemGap}px` }">
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="items.length > 0">
|
||||
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
|
||||
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
|
||||
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<div
|
||||
class="virtual-slide-item"
|
||||
:style="{
|
||||
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
|
||||
width: `${itemWidth}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="item" :index="startIndex + index" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.loading-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.virtual-spacer,
|
||||
.loading-track > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/components/toast/VersionUpdateToast.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="version-update-toast">
|
||||
<span class="message">{{ message }}</span>
|
||||
<button v-if="refreshText" class="refresh-button" @click="handleRefresh">
|
||||
{{ refreshText }}
|
||||
</button>
|
||||
<div v-else class="spinner"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 接收 props
|
||||
interface Props {
|
||||
message: string
|
||||
refreshText?: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (props.onRefresh) {
|
||||
props.onRefresh()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-update-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
padding: 6px 16px;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
src/composables/useAvailableHeight.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
/**
|
||||
* 计算页面内容的可用高度,自动适配 iOS 安全区域和底部 Dock 栏。
|
||||
*
|
||||
* 通过 DOM 测量获取布局的实际 padding(含 safe-area-inset-top/bottom),
|
||||
* 以及 Footer Dock 的实际高度,确保在任何设备上都不会被 Dock 遮挡。
|
||||
*
|
||||
* 计算公式: viewport - layoutPaddingTop - layoutPaddingBottom - footerDock - componentOffset
|
||||
*
|
||||
* @param componentOffset - 组件内部额外占用的空间(工具栏、分页栏等,默认 64)
|
||||
* @param minHeight - 最小高度(默认 300)
|
||||
*/
|
||||
export function useAvailableHeight(
|
||||
componentOffset: number = 64,
|
||||
minHeight: number = 300,
|
||||
) {
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 响应式测量值
|
||||
const viewportHeight = ref(window.innerHeight || document.documentElement.clientHeight)
|
||||
const layoutPaddingTop = ref(72)
|
||||
const layoutPaddingBottom = ref(24)
|
||||
const footerDockMeasuredHeight = ref(0)
|
||||
|
||||
function updateMeasurements() {
|
||||
viewportHeight.value = window.innerHeight || document.documentElement.clientHeight
|
||||
|
||||
// 测量 .layout-page-content 的实际 padding(含 env(safe-area-inset-top) 等)
|
||||
const layoutEl = document.querySelector('.layout-page-content') as HTMLElement | null
|
||||
if (layoutEl) {
|
||||
const style = getComputedStyle(layoutEl)
|
||||
layoutPaddingTop.value = parseFloat(style.paddingTop) || 72
|
||||
layoutPaddingBottom.value = parseFloat(style.paddingBottom) || 24
|
||||
}
|
||||
|
||||
// 直接查询 Footer Dock DOM,无论 appMode 状态
|
||||
// Dock 通过 Teleport 挂载到 body,存在即测量,不存在即为 0
|
||||
const footerEl = document.querySelector('.footer-nav-container') as HTMLElement | null
|
||||
footerDockMeasuredHeight.value = footerEl ? footerEl.offsetHeight : 0
|
||||
}
|
||||
|
||||
// appMode 异步变化时(PWA 检测完成、屏幕尺寸变化等),Dock 会出现/消失
|
||||
// 需要等 DOM 更新后重新测量
|
||||
watch(appMode, () => {
|
||||
nextTick(updateMeasurements)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateMeasurements)
|
||||
|
||||
window.addEventListener('resize', updateMeasurements)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateMeasurements)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateMeasurements)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateMeasurements)
|
||||
}
|
||||
})
|
||||
|
||||
const availableHeight = computed(() => {
|
||||
const vh = viewportHeight.value
|
||||
|
||||
// 布局顶部 padding(含 safe-area-inset-top + navbar 高度)
|
||||
const topPadding = layoutPaddingTop.value
|
||||
|
||||
// 布局底部 padding
|
||||
const bottomPadding = layoutPaddingBottom.value
|
||||
|
||||
// 底部 Dock 栏遮挡高度(通过 DOM 测量,含 safe-area-inset-bottom)
|
||||
const footerDockHeight = footerDockMeasuredHeight.value
|
||||
|
||||
const available = vh - topPadding - bottomPadding - footerDockHeight - componentOffset
|
||||
|
||||
return Math.max(available, minHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
availableHeight,
|
||||
viewportHeight,
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,24 @@ export function useBackgroundOptimization() {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟建立连接,确保组件完全挂载
|
||||
const connectDelay = options?.connectDelay || 100
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
try {
|
||||
manager.addMessageListener(listenerId, event => {
|
||||
messageHandler(event)
|
||||
@@ -44,15 +57,12 @@ export function useBackgroundOptimization() {
|
||||
}, connectDelay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
isConnected.value = false
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
isConnected,
|
||||
forceReconnect: () => manager.forceReconnect(),
|
||||
}
|
||||
@@ -104,21 +114,31 @@ export function useBackgroundOptimization() {
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}, delay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,31 +155,50 @@ export function useBackgroundOptimization() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
|
||||
const startProgress = () => {
|
||||
if (isActive.value) {
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}
|
||||
if (!isActive.value || isListening) return
|
||||
|
||||
manager ??= getManager()
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
isListening = true
|
||||
}
|
||||
|
||||
const stopProgress = () => {
|
||||
const stopProgress = (destroyManager = true) => {
|
||||
if (!manager) {
|
||||
isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
manager = null
|
||||
}
|
||||
|
||||
isListening = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopProgress()
|
||||
stopProgress(true)
|
||||
})
|
||||
|
||||
return {
|
||||
start: startProgress,
|
||||
stop: stopProgress,
|
||||
manager,
|
||||
get manager() {
|
||||
return manager
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import { ref, inject, nextTick, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
unref,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
__VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void
|
||||
__VUE_UNINJECT_DYNAMIC_BUTTON__?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
type MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
export interface DynamicButtonMenuItem {
|
||||
title?: string
|
||||
titleKey?: string
|
||||
titleParams?: Record<string, unknown>
|
||||
icon?: string
|
||||
color?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined): T | undefined
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback: T): T
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback?: T) {
|
||||
return value !== undefined ? unref(value) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态按钮钩子函数
|
||||
*
|
||||
@@ -23,12 +54,14 @@ declare global {
|
||||
* })
|
||||
*/
|
||||
export function useDynamicButton(options: {
|
||||
icon: string
|
||||
onClick: () => void
|
||||
icon: MaybeRefValue<string>
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -36,22 +69,42 @@ export function useDynamicButton(options: {
|
||||
|
||||
// 按钮注册状态
|
||||
const dynamicButtonRegistered = ref(false)
|
||||
const componentActive = ref(false)
|
||||
|
||||
const resolvedIcon = computed(() => resolveMaybeRef(icon, 'mdi-plus'))
|
||||
const resolvedShow = computed(() => resolveMaybeRef(show, true))
|
||||
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
|
||||
|
||||
function buildDynamicButton() {
|
||||
const buttonMenuItems = resolvedMenuItems.value
|
||||
|
||||
return {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态按钮
|
||||
function setupDynamicButton() {
|
||||
// 避免重复注册
|
||||
if (dynamicButtonRegistered.value) return
|
||||
if (!componentActive.value) return
|
||||
|
||||
const button = buildDynamicButton()
|
||||
|
||||
if (!button.show) {
|
||||
cleanupDynamicButton()
|
||||
return
|
||||
}
|
||||
|
||||
// 确保注册方法存在
|
||||
if (!registerDynamicButton) {
|
||||
// 尝试获取全局注册方法
|
||||
const tryUseGlobalMethod = () => {
|
||||
if (!componentActive.value) return false
|
||||
|
||||
if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {
|
||||
window.__VUE_INJECT_DYNAMIC_BUTTON__({
|
||||
icon,
|
||||
action: onClick,
|
||||
show: true,
|
||||
})
|
||||
window.__VUE_INJECT_DYNAMIC_BUTTON__(button)
|
||||
dynamicButtonRegistered.value = true
|
||||
return true
|
||||
}
|
||||
@@ -68,11 +121,9 @@ export function useDynamicButton(options: {
|
||||
|
||||
// 如果注册方法存在,直接注册
|
||||
nextTick(() => {
|
||||
registerDynamicButton({
|
||||
icon,
|
||||
action: onClick,
|
||||
show: true,
|
||||
})
|
||||
if (!componentActive.value) return
|
||||
|
||||
registerDynamicButton(button)
|
||||
dynamicButtonRegistered.value = true
|
||||
})
|
||||
}
|
||||
@@ -82,17 +133,24 @@ export function useDynamicButton(options: {
|
||||
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
|
||||
unregisterDynamicButton()
|
||||
dynamicButtonRegistered.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {
|
||||
window.__VUE_UNINJECT_DYNAMIC_BUTTON__()
|
||||
dynamicButtonRegistered.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法:手动打开对话框
|
||||
function openDialog() {
|
||||
onClick()
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
if (autoRegister) {
|
||||
onMounted(() => {
|
||||
componentActive.value = true
|
||||
// 延迟执行,确保Footer组件已加载
|
||||
setTimeout(() => {
|
||||
setupDynamicButton()
|
||||
@@ -100,18 +158,27 @@ export function useDynamicButton(options: {
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
componentActive.value = true
|
||||
// 重置注册状态,确保每次激活时都重新注册
|
||||
dynamicButtonRegistered.value = false
|
||||
setupDynamicButton()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
componentActive.value = false
|
||||
cleanupDynamicButton()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive.value = false
|
||||
cleanupDynamicButton()
|
||||
})
|
||||
|
||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
|
||||
if (!componentActive.value) return
|
||||
|
||||
setupDynamicButton()
|
||||
}, { deep: true })
|
||||
}
|
||||
|
||||
// 返回控制函数和状态
|
||||
|
||||
60
src/composables/useInfiniteScroll.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
/**
|
||||
* 无限滚动 composable
|
||||
* 用于管理分页显示和无限滚动加载
|
||||
* @param sourceData - 源数据(响应式引用)
|
||||
* @param pageSize - 每页显示数量,默认20
|
||||
*/
|
||||
export function useInfiniteScroll<T>(
|
||||
sourceData: Ref<T[]>,
|
||||
pageSize: number = 20
|
||||
) {
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<T[]>([])
|
||||
|
||||
// 剩余数据列表(用于无限滚动)
|
||||
const remainingDataList = ref<T[]>([]) as Ref<T[]>
|
||||
|
||||
// 初始化数据
|
||||
function initData() {
|
||||
if (sourceData.value?.length) {
|
||||
// 显示前 pageSize 个
|
||||
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
|
||||
// 保存剩余数据
|
||||
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
|
||||
} else {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
// 从 remainingDataList 中获取最前面的 pageSize 个元素
|
||||
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
|
||||
;(displayDataList.value as T[]).push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 重置数据
|
||||
function reset() {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
|
||||
// 监听源数据变化,重新初始化
|
||||
watch(sourceData, () => {
|
||||
initData()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
return {
|
||||
displayDataList,
|
||||
remainingDataList,
|
||||
initData,
|
||||
loadMore,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
409
src/composables/useLlmProviderDirectory.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export interface LlmProviderAuthMethod {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface LlmProviderAuthStatus {
|
||||
connected: boolean
|
||||
type?: string
|
||||
label?: string
|
||||
expires_at?: number | null
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPreset {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPresetItem {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: string
|
||||
name: string
|
||||
runtime: string
|
||||
default_base_url: string
|
||||
base_url_presets?: LlmProviderUrlPreset[]
|
||||
base_url_editable: boolean
|
||||
requires_base_url: boolean
|
||||
supports_api_key: boolean
|
||||
api_key_label: string
|
||||
api_key_hint: string
|
||||
supports_model_refresh: boolean
|
||||
oauth_methods: LlmProviderAuthMethod[]
|
||||
description?: string
|
||||
auth_status: LlmProviderAuthStatus
|
||||
}
|
||||
|
||||
export interface LlmModel {
|
||||
id: string
|
||||
name: string
|
||||
family?: string
|
||||
context_tokens?: number | null
|
||||
input_tokens?: number | null
|
||||
output_tokens?: number | null
|
||||
context_tokens_k?: number | null
|
||||
supports_reasoning?: boolean
|
||||
supports_tools?: boolean
|
||||
supports_image_input?: boolean
|
||||
supports_audio_input?: boolean
|
||||
transport?: string
|
||||
source?: string
|
||||
release_date?: string | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export interface LlmProviderAuthSession {
|
||||
session_id: string
|
||||
provider_id: string
|
||||
flow_type: string
|
||||
status: string
|
||||
message?: string
|
||||
authorize_url?: string
|
||||
verification_url?: string
|
||||
user_code?: string
|
||||
instructions?: string
|
||||
interval_seconds?: number
|
||||
expires_at?: number
|
||||
}
|
||||
|
||||
interface UseLlmProviderDirectoryOptions {
|
||||
provider: Ref<string>
|
||||
apiKey: Ref<string>
|
||||
baseUrl: Ref<string>
|
||||
baseUrlPreset?: Ref<string>
|
||||
model: Ref<string>
|
||||
maxContextTokens?: Ref<number>
|
||||
authConnected?: Ref<boolean>
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions) {
|
||||
const providers = ref<LlmProvider[]>([])
|
||||
const models = ref<LlmModel[]>([])
|
||||
const loadingProviders = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const authDialogVisible = ref(false)
|
||||
const authPolling = ref(false)
|
||||
const authPopupBlocked = ref(false)
|
||||
const authSession = ref<LlmProviderAuthSession | null>(null)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const selectedProvider = computed(
|
||||
() => providers.value.find(item => item.id === normalizeValue(options.provider.value)) || null,
|
||||
)
|
||||
const selectedModel = computed(
|
||||
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
|
||||
)
|
||||
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
|
||||
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
|
||||
(selectedProvider.value?.base_url_presets || []).map(item => ({
|
||||
id: item.id,
|
||||
title: item.value,
|
||||
value: item.value,
|
||||
subtitle: item.label,
|
||||
})),
|
||||
)
|
||||
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
|
||||
const showBaseUrlField = computed(
|
||||
() => Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
|
||||
)
|
||||
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
|
||||
const hasUsableCredential = computed(() => {
|
||||
if (providerConnected.value) return true
|
||||
return Boolean(normalizeValue(options.apiKey.value))
|
||||
})
|
||||
const canRefreshModels = computed(() => {
|
||||
if (!selectedProvider.value?.supports_model_refresh) return false
|
||||
if (!hasUsableCredential.value) return false
|
||||
if (selectedProvider.value.requires_base_url && !normalizeValue(options.baseUrl.value)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function clearPollTimer() {
|
||||
if (pollTimer !== null) {
|
||||
window.clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuthConnected() {
|
||||
if (options.authConnected) {
|
||||
options.authConnected.value = providerConnected.value
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBaseUrl(reset = false) {
|
||||
const provider = selectedProvider.value
|
||||
if (!provider) return
|
||||
|
||||
const currentBaseUrl = normalizeValue(options.baseUrl.value)
|
||||
const defaultBaseUrl = provider.default_base_url || ''
|
||||
const defaultPresetId = normalizeValue(provider.base_url_presets?.[0]?.id)
|
||||
if (reset) {
|
||||
options.baseUrl.value = defaultBaseUrl
|
||||
if (options.baseUrlPreset) {
|
||||
options.baseUrlPreset.value = defaultPresetId
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentBaseUrl && defaultBaseUrl) {
|
||||
options.baseUrl.value = defaultBaseUrl
|
||||
}
|
||||
|
||||
if (!options.baseUrlPreset) return
|
||||
|
||||
const currentPresetId = normalizeValue(options.baseUrlPreset.value)
|
||||
if (currentPresetId) return
|
||||
|
||||
const matchedPreset = (provider.base_url_presets || []).find(
|
||||
item => normalizeValue(item.value) === normalizeValue(options.baseUrl.value),
|
||||
)
|
||||
options.baseUrlPreset.value = matchedPreset?.id || defaultPresetId
|
||||
}
|
||||
|
||||
function setBaseUrlPreset(presetId?: string, presetValue?: string) {
|
||||
if (!options.baseUrlPreset) return
|
||||
|
||||
options.baseUrlPreset.value = normalizeValue(presetId)
|
||||
if (presetValue !== undefined) {
|
||||
options.baseUrl.value = presetValue || ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSelection(resetBaseUrl = true) {
|
||||
ensureBaseUrl(resetBaseUrl)
|
||||
options.apiKey.value = ''
|
||||
if (options.maxContextTokens) {
|
||||
options.maxContextTokens.value = 64
|
||||
}
|
||||
models.value = []
|
||||
options.model.value = ''
|
||||
syncAuthConnected()
|
||||
}
|
||||
|
||||
function applyModelMetadata(modelId?: string) {
|
||||
const targetId = normalizeValue(modelId ?? options.model.value)
|
||||
if (!targetId) return null
|
||||
|
||||
const matched = models.value.find(item => item.id === targetId) || null
|
||||
if (matched?.context_tokens_k && options.maxContextTokens) {
|
||||
// models.dev / provider 返回的是精确 token,这里回填到现有的 K 单位配置。
|
||||
options.maxContextTokens.value = matched.context_tokens_k
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
function updateProviderAuthStatus(providerId: string, authStatus?: LlmProviderAuthStatus) {
|
||||
if (!authStatus) return
|
||||
const index = providers.value.findIndex(item => item.id === providerId)
|
||||
if (index === -1) return
|
||||
|
||||
providers.value[index] = {
|
||||
...providers.value[index],
|
||||
auth_status: authStatus,
|
||||
}
|
||||
syncAuthConnected()
|
||||
}
|
||||
|
||||
async function loadProviders(preserveBaseUrl = true) {
|
||||
loadingProviders.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/providers')
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM providers failed')
|
||||
}
|
||||
|
||||
providers.value = Array.isArray(result.data) ? result.data : []
|
||||
if (!selectedProvider.value && providers.value.length > 0) {
|
||||
options.provider.value = providers.value[0].id
|
||||
}
|
||||
ensureBaseUrl(!preserveBaseUrl)
|
||||
syncAuthConnected()
|
||||
return providers.value
|
||||
} finally {
|
||||
loadingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(forceRefresh = false) {
|
||||
if (!selectedProvider.value) return []
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/models', {
|
||||
params: {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
||||
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
|
||||
force_refresh: forceRefresh,
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM models failed')
|
||||
}
|
||||
|
||||
const payload = result.data || {}
|
||||
models.value = Array.isArray(payload.models) ? payload.models : []
|
||||
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
|
||||
|
||||
const currentModelId = normalizeValue(options.model.value)
|
||||
const matchedModel = currentModelId
|
||||
? models.value.find(item => item.id === currentModelId)
|
||||
: null
|
||||
|
||||
if (matchedModel) {
|
||||
applyModelMetadata(matchedModel.id)
|
||||
} else if (models.value.length > 0) {
|
||||
options.model.value = models.value[0].id
|
||||
applyModelMetadata(models.value[0].id)
|
||||
}
|
||||
|
||||
return models.value
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAuthPage() {
|
||||
const session = authSession.value
|
||||
const targetUrl = session?.authorize_url || session?.verification_url
|
||||
if (!targetUrl) return
|
||||
|
||||
const popup = window.open(targetUrl, '_blank', 'noopener,noreferrer,width=960,height=780')
|
||||
authPopupBlocked.value = !popup
|
||||
}
|
||||
|
||||
async function pollAuthSession() {
|
||||
if (!authSession.value) return null
|
||||
|
||||
authPolling.value = true
|
||||
clearPollTimer()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
`llm/provider-auth/${authSession.value.session_id}/poll`,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Poll LLM auth failed')
|
||||
}
|
||||
|
||||
authSession.value = {
|
||||
...authSession.value,
|
||||
...result.data,
|
||||
}
|
||||
const nextSession = authSession.value
|
||||
if (!nextSession) return null
|
||||
|
||||
if (nextSession.status === 'pending') {
|
||||
pollTimer = window.setTimeout(
|
||||
() => pollAuthSession().catch(() => undefined),
|
||||
Math.max(nextSession.interval_seconds || 5, 1) * 1000,
|
||||
)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
await loadProviders()
|
||||
if (nextSession.status === 'authorized') {
|
||||
await loadModels(true).catch(() => undefined)
|
||||
}
|
||||
return nextSession
|
||||
} finally {
|
||||
authPolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startAuth(methodId: string) {
|
||||
if (!selectedProvider.value) {
|
||||
throw new Error('LLM provider is required')
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
method: methodId,
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Start LLM auth failed')
|
||||
}
|
||||
|
||||
authSession.value = {
|
||||
status: 'pending',
|
||||
provider_id: normalizeValue(options.provider.value),
|
||||
...result.data,
|
||||
}
|
||||
authDialogVisible.value = true
|
||||
authPopupBlocked.value = false
|
||||
openAuthPage()
|
||||
pollTimer = window.setTimeout(() => pollAuthSession().catch(() => undefined), 1200)
|
||||
return authSession.value
|
||||
}
|
||||
|
||||
async function disconnectAuth() {
|
||||
if (!selectedProvider.value) return false
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Disconnect LLM auth failed')
|
||||
}
|
||||
|
||||
await loadProviders()
|
||||
return true
|
||||
}
|
||||
|
||||
function closeAuthDialog() {
|
||||
authDialogVisible.value = false
|
||||
clearPollTimer()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
providers,
|
||||
providerItems,
|
||||
baseUrlPresetItems,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
loadingProviders,
|
||||
loadingModels,
|
||||
providerConnected,
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
hasUsableCredential,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
authSession,
|
||||
handleProviderSelection,
|
||||
applyModelMetadata,
|
||||
loadProviders,
|
||||
loadModels,
|
||||
openAuthPage,
|
||||
startAuth,
|
||||
pollAuthSession,
|
||||
disconnectAuth,
|
||||
closeAuthDialog,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,16 @@ const globalPwaStatus = ref<{
|
||||
const globalLoading = ref(false)
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// UI模式设置
|
||||
export type UIMode = 'auto' | 'desktop' | 'app'
|
||||
const uiMode = ref<UIMode>((localStorage.getItem('ui-mode') as UIMode) || 'auto')
|
||||
|
||||
// 设置UI模式
|
||||
function setUIMode(mode: UIMode) {
|
||||
uiMode.value = mode
|
||||
localStorage.setItem('ui-mode', mode)
|
||||
}
|
||||
|
||||
// 全局初始化函数
|
||||
async function initializePWAGlobally() {
|
||||
if (initPromise) return initPromise
|
||||
@@ -50,6 +60,8 @@ export function usePWA() {
|
||||
})
|
||||
|
||||
const appMode = computed(() => {
|
||||
if (uiMode.value === 'app') return true
|
||||
if (uiMode.value === 'desktop') return false
|
||||
return pwaMode.value && display.mdAndDown.value
|
||||
})
|
||||
|
||||
@@ -70,6 +82,8 @@ export function usePWA() {
|
||||
pwaMode,
|
||||
appMode,
|
||||
pwaStatus,
|
||||
uiMode,
|
||||
setUIMode,
|
||||
loading: globalLoading,
|
||||
initializePWA: initializePWAGlobally,
|
||||
}
|
||||
|
||||
@@ -236,16 +236,15 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// PWA状态确定后,一次性决定是否添加事件监听器
|
||||
// 监听 appMode 变化动态添加/移除事件监听器
|
||||
onMounted(() => {
|
||||
// 等待PWA检测完成后添加事件监听器
|
||||
const stopWatcher = watch(
|
||||
watch(
|
||||
appMode,
|
||||
newValue => {
|
||||
if (newValue) {
|
||||
addEventListeners()
|
||||
// PWA状态确定后停止监听
|
||||
stopWatcher()
|
||||
} else {
|
||||
removeEventListeners()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
1670
src/composables/useSetupWizard.ts
Normal file
502
src/composables/useTorrentFilter.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import type { Context } from '@/api/types'
|
||||
import { cloneDeepWith } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 卡片视图的分组数据类型
|
||||
interface SearchTorrent extends Context {
|
||||
more?: Array<Context>
|
||||
}
|
||||
|
||||
interface GroupedItem {
|
||||
data: SearchTorrent
|
||||
originalIndex: number
|
||||
}
|
||||
|
||||
// 筛选状态类型
|
||||
export interface FilterState {
|
||||
filterForm: Record<string, string[]>
|
||||
filterOptions: Record<string, string[]>
|
||||
sortField: string
|
||||
sortType: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// useTorrentFilter composable
|
||||
export function useTorrentFilter() {
|
||||
const { t } = useI18n()
|
||||
|
||||
// 过滤表单
|
||||
const filterForm: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 统一存储过滤选项
|
||||
const filterOptions: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
})
|
||||
|
||||
// 排序字段
|
||||
const sortField = ref('default')
|
||||
// 排序方向
|
||||
const sortType = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// 过滤项映射
|
||||
const filterTitles: Record<string, string> = {
|
||||
site: t('torrent.filterSite'),
|
||||
season: t('torrent.filterSeason'),
|
||||
freeState: t('torrent.filterFreeState'),
|
||||
videoCode: t('torrent.filterVideoCode'),
|
||||
edition: t('torrent.filterEdition'),
|
||||
resolution: t('torrent.filterResolution'),
|
||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
||||
}
|
||||
|
||||
// 排序中文名
|
||||
const sortTitles: Record<string, string> = {
|
||||
default: t('torrent.sortDefault'),
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 筛选后数据的原始索引列表
|
||||
const filteredIndices = ref<number[]>([])
|
||||
|
||||
// 筛选后的总数量
|
||||
const totalFilteredCount = ref(0)
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(data: Context) {
|
||||
const { torrent_info, meta_info } = data
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
if (value && !options.includes(value)) {
|
||||
options.push(value)
|
||||
// 如果是season选项,立即触发重新计算
|
||||
if (options === filterOptions.season) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
||||
optionValue(filterOptions.edition, meta_info?.edition)
|
||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 直接对季集选项进行排序的函数
|
||||
function sortSeasonOptions() {
|
||||
if (filterOptions.season.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
original: option,
|
||||
seasonNum: 0,
|
||||
episodeNum: 0,
|
||||
maxEpisodeNum: 0,
|
||||
isWholeSeason: false,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
const seasonNum = parseInt(match[1], 10)
|
||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
||||
const isWholeSeason = !match[3]
|
||||
|
||||
return {
|
||||
original: option,
|
||||
seasonNum,
|
||||
episodeNum,
|
||||
maxEpisodeNum,
|
||||
isWholeSeason,
|
||||
index,
|
||||
}
|
||||
})
|
||||
|
||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
||||
|
||||
wholeSeasons.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum
|
||||
}
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
episodes.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum
|
||||
}
|
||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
||||
if (aMaxEp !== bMaxEp) {
|
||||
return bMaxEp - aMaxEp
|
||||
}
|
||||
if (a.episodeNum !== b.episodeNum) {
|
||||
return b.episodeNum - a.episodeNum
|
||||
}
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
||||
filterOptions.season = sortedOptions
|
||||
}
|
||||
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
// 筛选列表视图数据(不分组)
|
||||
function filterRowData(items: Context[] | undefined): Context[] {
|
||||
// 重置状态
|
||||
filteredIndices.value = []
|
||||
|
||||
// 清空并重新初始化过滤选项
|
||||
for (const key in filterOptions) {
|
||||
filterOptions[key] = []
|
||||
}
|
||||
|
||||
if (!items?.length) {
|
||||
totalFilteredCount.value = 0
|
||||
return []
|
||||
}
|
||||
|
||||
// 首先收集所有过滤选项
|
||||
items.forEach(data => {
|
||||
initOptions(data)
|
||||
})
|
||||
|
||||
// 筛选数据
|
||||
let filteredData: Context[] = []
|
||||
|
||||
items.forEach((data, index) => {
|
||||
const { meta_info, torrent_info } = data
|
||||
if (
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
) {
|
||||
filteredData.push(data)
|
||||
filteredIndices.value.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
totalFilteredCount.value = filteredData.length
|
||||
|
||||
// 排序
|
||||
filteredData = sortData(filteredData)
|
||||
|
||||
// 确保季集选项排序
|
||||
if (filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
|
||||
return filteredData
|
||||
}
|
||||
|
||||
// 筛选卡片视图数据(分组)
|
||||
function filterCardData(items: Context[] | undefined): SearchTorrent[] {
|
||||
// 重置状态
|
||||
filteredIndices.value = []
|
||||
|
||||
// 清空并重新初始化过滤选项
|
||||
for (const key in filterOptions) {
|
||||
filterOptions[key] = []
|
||||
}
|
||||
|
||||
if (!items?.length) {
|
||||
totalFilteredCount.value = 0
|
||||
return []
|
||||
}
|
||||
|
||||
// 数据分组
|
||||
const groupMap = new Map<string, GroupedItem[]>()
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const { torrent_info, meta_info } = item
|
||||
// init options
|
||||
initOptions(item)
|
||||
// group data
|
||||
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||
const groupedItem = { data: item, originalIndex: index }
|
||||
if (groupMap.has(key)) {
|
||||
const group = groupMap.get(key)
|
||||
group?.push(groupedItem)
|
||||
} else {
|
||||
groupMap.set(key, [groupedItem])
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选数据
|
||||
const filteredData: SearchTorrent[] = []
|
||||
let matchCount = 0
|
||||
// 临时存储:每个分组的第一个原始索引
|
||||
const groupIndexMap = new Map<SearchTorrent, number>()
|
||||
|
||||
groupMap.forEach(value => {
|
||||
if (value.length > 0) {
|
||||
const matchData = value.filter(item => {
|
||||
const { meta_info, torrent_info } = item.data
|
||||
return (
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
)
|
||||
})
|
||||
if (matchData.length > 0) {
|
||||
matchCount += matchData.length
|
||||
const firstItem = matchData[0]
|
||||
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
|
||||
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
|
||||
filteredData.push(firstData)
|
||||
// 存储该分组的第一个原始索引
|
||||
groupIndexMap.set(firstData, firstItem.originalIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
totalFilteredCount.value = matchCount
|
||||
|
||||
// 排序数据
|
||||
const sortedData = sortCardData(filteredData)
|
||||
|
||||
// 在排序后重新构建 filteredIndices,保持与排序后顺序一致
|
||||
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
|
||||
|
||||
// 确保季集选项排序
|
||||
if (filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
|
||||
return sortedData
|
||||
}
|
||||
|
||||
// 排序列表数据
|
||||
function sortData(data: Context[]): Context[] {
|
||||
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||
|
||||
return data.sort((a, b) => {
|
||||
let result = 0
|
||||
switch (sortField.value) {
|
||||
case 'site':
|
||||
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||
break
|
||||
case 'size':
|
||||
result = a.torrent_info.size - b.torrent_info.size
|
||||
break
|
||||
case 'seeder':
|
||||
result = a.torrent_info.seeders - b.torrent_info.seeders
|
||||
break
|
||||
case 'publishTime':
|
||||
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
result = a.torrent_info.pri_order - b.torrent_info.pri_order
|
||||
break
|
||||
}
|
||||
return result * sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 排序卡片数据
|
||||
function sortCardData(data: SearchTorrent[]): SearchTorrent[] {
|
||||
if (sortField.value === 'default') {
|
||||
return data
|
||||
}
|
||||
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||
return data.sort((a, b) => {
|
||||
let result = 0
|
||||
switch (sortField.value) {
|
||||
case 'site':
|
||||
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||
break
|
||||
case 'size':
|
||||
result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
|
||||
break
|
||||
case 'seeder':
|
||||
result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
|
||||
break
|
||||
case 'publishTime':
|
||||
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||
break
|
||||
}
|
||||
return result * sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in filterForm) {
|
||||
count += filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key].length > 0) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
const index = filterForm[key].indexOf(value)
|
||||
if (index !== -1) {
|
||||
filterForm[key].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
filterForm[key] = [...filterOptions[key]]
|
||||
}
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 处理排序图标点击
|
||||
const handleSortIconClick = () => {
|
||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
// 获取筛选后的原始索引列表
|
||||
function getFilteredIndices() {
|
||||
return filteredIndices.value
|
||||
}
|
||||
|
||||
// 检查是否有活动的筛选条件
|
||||
function hasActiveFilters() {
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key] && filterForm[key].length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当前筛选条件
|
||||
function getFilterForm() {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
// 设置筛选条件
|
||||
function setFilterForm(filters: Record<string, string[]>) {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = filters[key] ? [...filters[key]] : []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取完整的筛选状态
|
||||
function getFilterState(): FilterState {
|
||||
return {
|
||||
filterForm: getFilterForm(),
|
||||
filterOptions: { ...filterOptions },
|
||||
sortField: sortField.value,
|
||||
sortType: sortType.value,
|
||||
}
|
||||
}
|
||||
|
||||
// 设置完整的筛选状态
|
||||
function setFilterState(state: FilterState) {
|
||||
setFilterForm(state.filterForm)
|
||||
sortField.value = state.sortField
|
||||
sortType.value = state.sortType
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filterForm,
|
||||
filterOptions,
|
||||
sortField,
|
||||
sortType,
|
||||
filteredIndices,
|
||||
totalFilteredCount,
|
||||
// 标题映射
|
||||
filterTitles,
|
||||
sortTitles,
|
||||
// 计算属性
|
||||
getFilterCount,
|
||||
getSelectedFilters,
|
||||
// 筛选方法
|
||||
filterRowData,
|
||||
filterCardData,
|
||||
// 操作方法
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
clearFilter,
|
||||
selectAll,
|
||||
getFilterIcon,
|
||||
handleSortIconClick,
|
||||
// 状态管理方法
|
||||
getFilteredIndices,
|
||||
hasActiveFilters,
|
||||
getFilterForm,
|
||||
setFilterForm,
|
||||
getFilterState,
|
||||
setFilterState,
|
||||
sortSeasonOptions,
|
||||
}
|
||||
}
|
||||
174
src/composables/useVersionChecker.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ref, h } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { Workbox } from 'workbox-window'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
||||
|
||||
// 全局状态
|
||||
const currentVersion = ref(__APP_VERSION__)
|
||||
let isUpdateToastShown = false
|
||||
let wb: Workbox | null = null
|
||||
|
||||
/**
|
||||
* 普通刷新页面
|
||||
*/
|
||||
export const reloadPage = (): void => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新页面并添加时间戳
|
||||
*/
|
||||
export const reloadWithTimestamp = (): void => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存和 Service Worker
|
||||
*/
|
||||
export const clearCachesAndServiceWorker = async (): Promise<void> => {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VersionChecker] 清除缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存并刷新
|
||||
*/
|
||||
const clearCacheAndReload = async (): Promise<void> => {
|
||||
await clearCachesAndServiceWorker()
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本检查 Composable
|
||||
*
|
||||
* 功能:
|
||||
* - 使用 Workbox 监听 Service Worker 更新
|
||||
* - 检查浏览器版本与服务端版本是否一致
|
||||
* - 显示持久化更新通知
|
||||
*/
|
||||
export function useVersionChecker() {
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* 显示版本更新通知
|
||||
* @param message 通知消息文本
|
||||
* @param refreshText 按钮文本,不传则不显示按钮
|
||||
* @param onRefresh 按钮点击事件
|
||||
*/
|
||||
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
|
||||
if (isUpdateToastShown) return
|
||||
isUpdateToastShown = true
|
||||
const component = h(VersionUpdateToast, {
|
||||
message,
|
||||
refreshText,
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
toast.info(component, {
|
||||
timeout: false, // 不自动消失
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化 Workbox
|
||||
if (!wb && 'serviceWorker' in navigator) {
|
||||
wb = new Workbox('/service-worker.js')
|
||||
|
||||
// Service Worker 激活事件 (install -> activate)
|
||||
wb.addEventListener('activated', event => {
|
||||
// 只有在更新时才显示通知
|
||||
if (event.isUpdate) {
|
||||
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
||||
|
||||
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
||||
}
|
||||
})
|
||||
|
||||
// 注册 Service Worker
|
||||
wb.register()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查版本并在需要时显示更新通知
|
||||
* @param latestVersion 服务端返回的最新版本号
|
||||
*/
|
||||
const checkVersion = async (latestVersion: string): Promise<void> => {
|
||||
// 如果已经显示过通知,说明已经检查过了
|
||||
if (isUpdateToastShown) return
|
||||
|
||||
// 版本一致,无需操作
|
||||
if (latestVersion === currentVersion.value) {
|
||||
console.log('[VersionChecker] 版本号一致,无需操作')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
|
||||
|
||||
// 尝试触发 Service Worker 更新检查
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (registration) {
|
||||
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
|
||||
|
||||
// 标记是否发现更新
|
||||
let updateFound = false
|
||||
const onUpdateFound = () => {
|
||||
updateFound = true
|
||||
}
|
||||
|
||||
// 监听 updatefound 事件
|
||||
registration.addEventListener('updatefound', onUpdateFound, { once: true })
|
||||
|
||||
// 等待检查完成
|
||||
await registration.update()
|
||||
|
||||
// 检查是否有更新正在进行
|
||||
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
|
||||
if (updateFound || registration.installing || registration.waiting) {
|
||||
console.log('[VersionChecker] Service Worker 更新中...')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
|
||||
// 失败继续向下执行,显示通知
|
||||
}
|
||||
} else {
|
||||
console.log('[VersionChecker] 无 Service Worker, 直接显示通知')
|
||||
}
|
||||
|
||||
// 最终兜底:显示版本不一致通知(清除缓存)
|
||||
showUpdateNotification(
|
||||
i18n.global.t('common.versionMismatch'),
|
||||
i18n.global.t('common.clearCache'),
|
||||
clearCacheAndReload,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
checkVersion,
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -30,6 +31,7 @@ const route = useRoute()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 响应式的超级用户状态
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
@@ -197,7 +199,7 @@ const {
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = (header: string) => {
|
||||
// 使用国际化菜单
|
||||
const menus = getNavMenus()
|
||||
const menus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||||
}
|
||||
@@ -229,7 +231,34 @@ function handlePluginClick() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function appendPluginSidebarMenus() {
|
||||
for (const { navMenu, section } of filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
t,
|
||||
userPermissions.value,
|
||||
)) {
|
||||
switch (section) {
|
||||
case 'start':
|
||||
startMenus.value.push(navMenu)
|
||||
break
|
||||
case 'discovery':
|
||||
discoveryMenus.value.push(navMenu)
|
||||
break
|
||||
case 'subscribe':
|
||||
subscribeMenus.value.push(navMenu)
|
||||
break
|
||||
case 'organize':
|
||||
organizeMenus.value.push(navMenu)
|
||||
break
|
||||
case 'system':
|
||||
default:
|
||||
systemMenus.value.push(navMenu)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||
@@ -237,6 +266,9 @@ onMounted(() => {
|
||||
organizeMenus.value = getMenuList(t('menu.organize'))
|
||||
systemMenus.value = getMenuList(t('menu.system'))
|
||||
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听全局未读消息事件
|
||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||
|
||||
@@ -386,7 +418,7 @@ onMounted(() => {
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<template #footer>
|
||||
<Footer />
|
||||
<Footer :show-nav="!showPluginQuickAccess" />
|
||||
</template>
|
||||
</VerticalNavLayout>
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
|
||||
// 是否显示的输入参数
|
||||
defineProps({
|
||||
showNav: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
@@ -41,7 +50,7 @@ const userPermissions = computed(() => {
|
||||
|
||||
// 获取导航菜单
|
||||
const navMenus = computed(() => {
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
return filterMenusByPermission(allMenus, userPermissions.value)
|
||||
})
|
||||
|
||||
@@ -112,6 +121,7 @@ interface DynamicButton {
|
||||
action: () => void
|
||||
show: boolean
|
||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||
menuItems?: DynamicButtonMenuItem[]
|
||||
}
|
||||
|
||||
// 提供动态按钮注册和获取的方法
|
||||
@@ -133,11 +143,13 @@ const unregisterDynamicButton = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 确保在浏览器环境中
|
||||
;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton
|
||||
;(window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__ = unregisterDynamicButton
|
||||
}
|
||||
|
||||
// 提供给其他组件使用
|
||||
provide('registerDynamicButton', registerDynamicButton)
|
||||
provide('unregisterDynamicButton', unregisterDynamicButton)
|
||||
provide('dynamicButton', dynamicButton)
|
||||
|
||||
// 在组件销毁时清理
|
||||
onUnmounted(() => {
|
||||
@@ -145,6 +157,7 @@ onUnmounted(() => {
|
||||
// 清理全局方法
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__
|
||||
delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,71 +170,122 @@ const showDynamicButton = computed(() => {
|
||||
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
|
||||
|
||||
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
|
||||
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
||||
'components.subscribeEdit.titleDefault': 'dialog.subscribeEdit.titleDefault',
|
||||
'components.transferQueue.title': 'dialog.transferQueue.title',
|
||||
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
|
||||
}
|
||||
|
||||
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
if (item.titleKey) {
|
||||
return t(item.titleKey, item.titleParams as any)
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizedTitleKey = legacyDynamicMenuTitleKeyMap[item.title] || item.title
|
||||
const looksLikeI18nKey = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/i.test(normalizedTitleKey)
|
||||
|
||||
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="appMode" to="body">
|
||||
<Teleport v-if="appMode && showNav" to="body">
|
||||
<div class="footer-nav-container">
|
||||
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
:key="menu.to"
|
||||
:to="menu.to"
|
||||
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
class="footer-nav-btn"
|
||||
rounded="pill"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
||||
:value="menu.to"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多按钮 -->
|
||||
<VBtn
|
||||
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
to="/apps"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
||||
value="/apps"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<Transition name="fade-slide">
|
||||
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
|
||||
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
|
||||
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 各页面的动态按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="dynamicButton?.action()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="28"></VIcon>
|
||||
</VBtn>
|
||||
<!-- 添加指示器 -->
|
||||
<div ref="indicator" class="nav-indicator"></div>
|
||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||
<!-- 遍历底部菜单项 -->
|
||||
<VBtn
|
||||
v-for="menu in footerMenus"
|
||||
:key="menu.to"
|
||||
:to="menu.to"
|
||||
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
class="footer-nav-btn"
|
||||
rounded="pill"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
||||
:value="menu.to"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多按钮 -->
|
||||
<VBtn
|
||||
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
||||
color="primary"
|
||||
:ripple="false"
|
||||
to="/apps"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
||||
value="/apps"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</Transition>
|
||||
<VCard
|
||||
v-if="showDynamicButton"
|
||||
key="dynamic-btn"
|
||||
elevation="3"
|
||||
class="footer-nav-card dynamic-btn-card border"
|
||||
rounded="pill"
|
||||
>
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 各页面的动态按钮 -->
|
||||
<div class="dynamic-btn-activator">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
<VIcon
|
||||
color="secondary"
|
||||
:icon="hasDynamicButtonMenu ? 'mdi-chevron-up' : dynamicButton?.icon || 'mdi-plus'"
|
||||
size="28"
|
||||
></VIcon>
|
||||
</VBtn>
|
||||
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, index) in dynamicButton?.menuItems"
|
||||
:key="item.titleKey || item.title || index"
|
||||
:base-color="item.color"
|
||||
@click="item.action()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="item.icon" :icon="item.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ resolveDynamicMenuItemTitle(item) }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -237,6 +301,12 @@ const showDynamicButton = computed(() => {
|
||||
inset-inline: 0;
|
||||
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer-nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// 按钮卡片之间的间距
|
||||
> .v-card + .v-card {
|
||||
@@ -251,6 +321,7 @@ const showDynamicButton = computed(() => {
|
||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||
pointer-events: auto;
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: transform, max-inline-size, opacity;
|
||||
|
||||
// 透明主题下的特殊样式
|
||||
.v-theme--transparent & {
|
||||
@@ -258,10 +329,6 @@ const showDynamicButton = computed(() => {
|
||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
|
||||
&.shift-left {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.v-btn-toggle {
|
||||
block-size: auto;
|
||||
min-block-size: 56px;
|
||||
@@ -318,6 +385,7 @@ const showDynamicButton = computed(() => {
|
||||
.dynamic-btn-card {
|
||||
block-size: auto;
|
||||
inline-size: auto;
|
||||
max-inline-size: 60px;
|
||||
min-block-size: 0;
|
||||
|
||||
.footer-card-content {
|
||||
@@ -340,23 +408,25 @@ const showDynamicButton = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 淡入滑动动画
|
||||
.fade-slide-enter-active {
|
||||
// 底部导航动画
|
||||
.footer-nav-enter-active,
|
||||
.footer-nav-leave-active {
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
.footer-nav-enter-from,
|
||||
.footer-nav-leave-to {
|
||||
padding: 0 !important;
|
||||
border-width: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
max-inline-size: 0 !important;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
.footer-nav-move {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
|
||||
@@ -57,159 +57,78 @@ const statusIcon = computed(() => {
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
|
||||
// 动画时长
|
||||
const ENTER_DURATION = 600
|
||||
const LEAVE_DURATION = 400
|
||||
|
||||
// 进入动画
|
||||
function onEnter(el: HTMLElement, done: () => void) {
|
||||
// 初始状态
|
||||
el.style.opacity = '0'
|
||||
el.style.transform = 'scale(0.9)'
|
||||
el.style.filter = 'blur(10px)'
|
||||
|
||||
// 强制重绘
|
||||
el.offsetHeight
|
||||
|
||||
// 应用过渡
|
||||
el.style.transition = `all ${ENTER_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`
|
||||
|
||||
// 目标状态
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '1'
|
||||
el.style.transform = 'scale(1)'
|
||||
el.style.filter = 'blur(0)'
|
||||
})
|
||||
|
||||
// 动画完成
|
||||
setTimeout(done, ENTER_DURATION)
|
||||
}
|
||||
|
||||
// 离开动画
|
||||
function onLeave(el: HTMLElement, done: () => void) {
|
||||
// 应用过渡
|
||||
el.style.transition = `all ${LEAVE_DURATION}ms cubic-bezier(0.4, 0, 1, 1)`
|
||||
|
||||
// 目标状态
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '0'
|
||||
el.style.transform = 'scale(1.1)'
|
||||
el.style.filter = 'blur(20px)'
|
||||
})
|
||||
|
||||
// 动画完成
|
||||
setTimeout(done, LEAVE_DURATION)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
>
|
||||
<div v-if="shouldShow" class="offline-page" ref="offlinePage">
|
||||
<div class="offline-container" :class="{ 'container-animate': shouldShow }">
|
||||
<!-- 状态图标 -->
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="64" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要信息 -->
|
||||
<div class="content-section">
|
||||
<h1 class="offline-title">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h1>
|
||||
|
||||
<p class="offline-message">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<div class="action-section">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-section">
|
||||
<p class="app-info">{{ t('app.moviepilot') }}</p>
|
||||
<VDialog :model-value="shouldShow" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<!-- 状态图标 -->
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 主要信息 -->
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-page {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgb(var(--v-theme-surface-variant)) 100%);
|
||||
inset: 0;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.offline-container {
|
||||
padding: 40px;
|
||||
border-radius: 24px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 10%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
inline-size: 100%;
|
||||
max-inline-size: 500px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.offline-page .offline-container.container-animate {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition-delay: 0.2s;
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
margin-block-end: 32px;
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
@@ -218,71 +137,61 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 120px;
|
||||
inline-size: 120px;
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
animation: iconPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -4px;
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
animation: iconGlow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%, 100% {
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconGlow {
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 32px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -290,41 +199,19 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.help-panels {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (width <= 600px) {
|
||||
.offline-container {
|
||||
padding: 24px;
|
||||
margin: 16px;
|
||||
.status-icon-bg {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
@@ -332,13 +219,4 @@ function onLeave(el: HTMLElement, done: () => void) {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式优化 */
|
||||
.v-theme--dark .offline-page {
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgba(var(--v-theme-surface-variant), 0.8) 100%);
|
||||
}
|
||||
|
||||
.v-theme--dark .offline-container {
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 30%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
||||
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
||||
import { VCard } from 'vuetify/components'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -136,8 +137,8 @@ const componentOpacity = computed(() => {
|
||||
|
||||
// 计算插件图标路径
|
||||
function getPluginIcon(plugin: Plugin): string {
|
||||
if (!plugin.plugin_icon) return noImage
|
||||
if (pluginIconLoadError.value[plugin.id]) return noImage
|
||||
if (!plugin.plugin_icon) return getLogoUrl('plugin')
|
||||
if (pluginIconLoadError.value[plugin.id]) return getLogoUrl('plugin')
|
||||
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (plugin?.plugin_icon?.startsWith('http'))
|
||||
@@ -205,6 +206,29 @@ function handleClosePluginDataDialog() {
|
||||
currentPlugin.value = null
|
||||
}
|
||||
|
||||
// 管理滚动状态
|
||||
function manageScrollLock() {
|
||||
if (isVisible.value) {
|
||||
// 使用 nextTick 确保 DOM 已经更新
|
||||
nextTick(() => {
|
||||
// 先恢复之前的锁定状态,避免重复锁定
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
// 确保元素存在且可见
|
||||
if ((scrollableElement as HTMLElement).offsetHeight > 0) {
|
||||
disableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 恢复背景滚动
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听可见性变化,加载数据
|
||||
watch(
|
||||
() => isVisible.value,
|
||||
@@ -212,6 +236,9 @@ watch(
|
||||
if (visible) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
manageScrollLock()
|
||||
} else {
|
||||
manageScrollLock()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -221,6 +248,15 @@ onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
manageScrollLock()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时确保恢复背景滚动
|
||||
onUnmounted(() => {
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -420,40 +456,41 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
<div class="section-title">{{ t('plugin.allPlugins') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<VBadge
|
||||
dot
|
||||
:color="plugin.state ? 'success' : 'secondary'"
|
||||
location="top end"
|
||||
:offset-x="-1"
|
||||
:offset-y="-1"
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
|
||||
<div class="all-plugins-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
<VBadge
|
||||
dot
|
||||
:color="plugin.state ? 'success' : 'secondary'"
|
||||
location="top end"
|
||||
:offset-x="-1"
|
||||
:offset-y="-1"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
@error="handleIconError(plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
@error="handleIconError(plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态(只有在没有插件时显示) -->
|
||||
<div v-else-if="pluginsWithPage.length === 0" class="empty-state">
|
||||
<VIcon icon="mdi-puzzle-outline" size="48" color="grey" />
|
||||
@@ -622,10 +659,34 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.all-plugins-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.all-plugins-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
max-block-size: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none; // IE/Edge
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 8px;
|
||||
padding-inline: 0;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none; // Firefox
|
||||
touch-action: pan-y;
|
||||
will-change: scroll-position;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // WebKit 浏览器
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
@@ -677,6 +738,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.2;
|
||||
max-block-size: 2.4em;
|
||||
text-align: center;
|
||||
|
||||
@@ -27,25 +27,65 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 👉 Search Icon -->
|
||||
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
|
||||
<IconBtn @click="openSearchDialog">
|
||||
<VIcon icon="ri-search-line" />
|
||||
</IconBtn>
|
||||
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||
<span class="me-3">{{ t('common.search') }}</span>
|
||||
<span class="meta-key">{{ metaKey }}</span>
|
||||
</span>
|
||||
<!-- 小屏:仅图标按钮 -->
|
||||
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上:胶囊搜索触发栏 -->
|
||||
<div v-else class="search-trigger" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" size="18" class="search-trigger-icon" />
|
||||
<span class="search-trigger-text">{{ t('common.search') }}</span>
|
||||
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
|
||||
<!-- 搜索弹窗 -->
|
||||
<SearchBarDialog v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
</template>
|
||||
<style type="scss" scoped>
|
||||
.meta-key {
|
||||
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
block-size: 1.75rem;
|
||||
padding-block: 0.1rem;
|
||||
padding-inline: 0.25rem;
|
||||
|
||||
<style scoped>
|
||||
.search-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 22px;
|
||||
block-size: 36px;
|
||||
cursor: pointer;
|
||||
padding-inline: 12px;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-trigger:hover {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.22);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
|
||||
}
|
||||
|
||||
.search-trigger-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-trigger-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
font-size: 13.5px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-trigger-kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 5px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-inline-start: 4px;
|
||||
padding-block: 3px;
|
||||
padding-inline: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,12 +5,21 @@ import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import WordsView from '@/views/system/WordsView.vue'
|
||||
import CacheView from '@/views/system/CacheView.vue'
|
||||
import AccountSettingService from '@/views/system/ServiceView.vue'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { clearAppBadge } from '@/utils/badge'
|
||||
|
||||
type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -41,6 +50,15 @@ const systemTestDialog = ref(false)
|
||||
// 消息中心弹窗
|
||||
const messageDialog = ref(false)
|
||||
|
||||
// 词表设置弹窗
|
||||
const wordsDialog = ref(false)
|
||||
|
||||
// 缓存管理弹窗
|
||||
const cacheDialog = ref(false)
|
||||
|
||||
// 定时服务弹窗
|
||||
const schedulerDialog = ref(false)
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
@@ -51,7 +69,7 @@ const sendButtonDisabled = ref(false)
|
||||
const messageDialogRef = ref<any>(null)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<any>(null)
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
// 滚动容器引用
|
||||
const messageContentRef = ref<any>()
|
||||
@@ -86,6 +104,27 @@ const shortcuts = [
|
||||
dialog: 'netTest',
|
||||
dialogRef: netTestDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.words.title'),
|
||||
subtitle: t('shortcut.words.subtitle'),
|
||||
icon: 'mdi-file-word-box',
|
||||
dialog: 'words',
|
||||
dialogRef: wordsDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.cache.title'),
|
||||
subtitle: t('shortcut.cache.subtitle'),
|
||||
icon: 'mdi-database',
|
||||
dialog: 'cache',
|
||||
dialogRef: cacheDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.scheduler.title'),
|
||||
subtitle: t('shortcut.scheduler.subtitle'),
|
||||
icon: 'mdi-list-box',
|
||||
dialog: 'scheduler',
|
||||
dialogRef: schedulerDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
@@ -120,9 +159,7 @@ async function openMessageDialog() {
|
||||
}, 600)
|
||||
// 等待对话框打开后恢复SSE连接
|
||||
nextTick(() => {
|
||||
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
|
||||
messageViewRef.value.resumeSSE()
|
||||
}
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,16 +207,23 @@ function allLoggingUrl() {
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (user_message.value) {
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${user_message.value}`)
|
||||
user_message.value = ''
|
||||
sendButtonDisabled.value = false
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
const messageText = user_message.value.trim()
|
||||
if (!messageText) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
||||
user_message.value = ''
|
||||
|
||||
// 发送成功后主动同步最新一页消息,避免SSE短暂断流时界面停留在旧状态。
|
||||
// await messageViewRef.value?.refreshLatestMessages?.()
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
sendButtonDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +239,7 @@ defineExpose({
|
||||
|
||||
// 监听消息对话框状态变化
|
||||
watch(messageDialog, newValue => {
|
||||
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
|
||||
if (!newValue && messageViewRef.value?.pauseSSE) {
|
||||
// 对话框关闭时暂停SSE连接
|
||||
messageViewRef.value.pauseSSE()
|
||||
}
|
||||
@@ -249,7 +293,15 @@ onMounted(() => {
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
||||
hover
|
||||
@click="item.dialog === 'message' ? openMessageDialog() : openDialog(item.dialogRef)"
|
||||
@click="
|
||||
item.dialog === 'message'
|
||||
? openMessageDialog()
|
||||
: item.dialog === 'words'
|
||||
? openDialog(item.dialogRef)
|
||||
: item.dialog === 'cache'
|
||||
? openDialog(item.dialogRef)
|
||||
: openDialog(item.dialogRef)
|
||||
"
|
||||
>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||
@@ -309,13 +361,13 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="70rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -331,7 +383,7 @@ onMounted(() => {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -358,6 +410,61 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 词表设置弹窗 -->
|
||||
<VDialog v-if="wordsDialog" v-model="wordsDialog" max-width="60rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-file-word-box" class="me-2" />
|
||||
{{ t('shortcut.words.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="wordsDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<WordsView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 缓存管理弹窗 -->
|
||||
<VDialog v-if="cacheDialog" v-model="cacheDialog" max-width="90rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-database" class="me-2" />
|
||||
{{ t('shortcut.cache.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="cacheDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<CacheView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 定时服务弹窗 -->
|
||||
<VDialog
|
||||
v-if="schedulerDialog"
|
||||
v-model="schedulerDialog"
|
||||
max-width="60rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-list-box" class="me-2" />
|
||||
{{ t('shortcut.scheduler.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
|
||||
<VDialogCloseBtn @click="schedulerDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<AccountSettingService />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 系统健康检查弹窗 -->
|
||||
<VDialog
|
||||
v-if="systemTestDialog"
|
||||
|
||||
@@ -12,6 +12,7 @@ const hasNewMessage = ref(false)
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
@@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import AboutDialog from '@/components/dialog/AboutDialog.vue'
|
||||
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
||||
@@ -15,15 +16,20 @@ import { saveLocalTheme } from '@/@core/utils/theme'
|
||||
import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { usePWA, type UIMode } from '@/composables/usePWA'
|
||||
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
// PWA
|
||||
const { uiMode, setUIMode } = usePWA()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -37,6 +43,9 @@ const siteAuthDialog = ref(false)
|
||||
// 自定义CSS弹窗
|
||||
const cssDialog = ref(false)
|
||||
|
||||
// UI模式菜单是否显示
|
||||
const showUIModeMenu = ref(false)
|
||||
|
||||
// 主题菜单是否显示
|
||||
const showThemeMenu = ref(false)
|
||||
|
||||
@@ -53,6 +62,9 @@ const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'med
|
||||
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
||||
const showTransparencyDialog = ref(false)
|
||||
|
||||
// 关于对话框
|
||||
const aboutDialog = ref(false)
|
||||
|
||||
// 预设值配置
|
||||
const transparencyPresets = {
|
||||
low: { opacity: 0.1, blur: 5 },
|
||||
@@ -205,6 +217,11 @@ function showSiteAuthDialog() {
|
||||
siteAuthDialog.value = true
|
||||
}
|
||||
|
||||
// 显示关于对话框
|
||||
function showAboutDialog() {
|
||||
aboutDialog.value = true
|
||||
}
|
||||
|
||||
// 用户站点认证成功
|
||||
function siteAuthDone() {
|
||||
siteAuthDialog.value = false
|
||||
@@ -217,9 +234,45 @@ const userName = computed(() => userStore.userName)
|
||||
const avatar = computed(() => userStore.avatar || avatar1)
|
||||
const userLevel = computed(() => userStore.level)
|
||||
|
||||
// 检查是否为高级模式
|
||||
const isAdvancedMode = computed(() => {
|
||||
return globalSettingsStore.get('ADVANCED_MODE') !== false
|
||||
})
|
||||
|
||||
// UI模式相关
|
||||
const uiModes = computed(() => [
|
||||
{
|
||||
name: 'auto',
|
||||
title: t('theme.autoUI'),
|
||||
icon: 'mdi-devices',
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
title: t('pwa.platforms.desktop'),
|
||||
icon: 'mdi-monitor',
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
title: t('pwa.platforms.mobile'),
|
||||
icon: 'mdi-cellphone',
|
||||
},
|
||||
])
|
||||
|
||||
// 切换UI模式
|
||||
function changeUIMode(mode: UIMode) {
|
||||
setUIMode(mode)
|
||||
showUIModeMenu.value = false
|
||||
}
|
||||
|
||||
// 获取当前UI模式图标
|
||||
const getUIModeIcon = computed(() => {
|
||||
const mode = uiModes.value.find(m => m.name === uiMode.value)
|
||||
return mode?.icon || 'mdi-devices'
|
||||
})
|
||||
|
||||
// 主题相关功能
|
||||
const { name: themeName, global: globalTheme } = useTheme()
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
|
||||
const themes: ThemeSwitcherTheme[] = [
|
||||
@@ -509,11 +562,17 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.profile') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="superUser" link @click="router.push('/setting')" class="mb-1 rounded-lg" hover>
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
link
|
||||
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
||||
class="mb-1 rounded-lg"
|
||||
hover
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog-outline" />
|
||||
<VIcon :icon="isAdvancedMode ? 'mdi-cog-outline' : 'mdi-wizard-hat'" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('user.systemSettings') }}</VListItemTitle>
|
||||
<VListItemTitle>{{ isAdvancedMode ? t('user.systemSettings') : t('user.wizardSettings') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
@@ -524,6 +583,41 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon :icon="getUIModeIcon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.uiMode') }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ uiModes.find(m => m.name === uiMode)?.title || t('theme.autoUI') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon icon="mdi-chevron-right" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="mode in uiModes"
|
||||
:key="mode.name"
|
||||
@click="changeUIMode(mode.name as UIMode)"
|
||||
:active="uiMode === mode.name"
|
||||
class="mb-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="mode.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ mode.title }}</VListItemTitle>
|
||||
<template #append v-if="uiMode === mode.name">
|
||||
<VIcon icon="mdi-check" color="primary" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
<!-- 👉 主题设置 - 使用嵌套菜单 -->
|
||||
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
@@ -531,9 +625,10 @@ onUnmounted(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="getThemeIcon" />
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ themes.find(t => t.name === currentThemeName)?.title || t('common.theme') }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon icon="mdi-chevron-right" size="small" />
|
||||
</template>
|
||||
@@ -620,6 +715,14 @@ onUnmounted(() => {
|
||||
<VListItemTitle>{{ t('user.helpDocs') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 About -->
|
||||
<VListItem @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information-outline" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('setting.about.title') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-3" />
|
||||
|
||||
@@ -764,6 +867,9 @@ onUnmounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 关于对话框 -->
|
||||
<AboutDialog v-if="aboutDialog" v-model="aboutDialog" @close="aboutDialog = false" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@ const route = useRoute()
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<keep-alive :max="12">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: '保存中',
|
||||
reset: '重置',
|
||||
theme: '主题',
|
||||
uiMode: '界面布局',
|
||||
language: '语言',
|
||||
pleaseWait: '请稍候...',
|
||||
viewDetails: '查看详情',
|
||||
@@ -45,10 +46,14 @@ export default {
|
||||
unsubscribe: '取消订阅',
|
||||
media: '媒体',
|
||||
unknown: '未知',
|
||||
notFetched: '未获取',
|
||||
notice: '注意',
|
||||
itemsPerPage: '每页条数',
|
||||
pageText: '{0}-{1} 共 {2} 条',
|
||||
noDataText: '没有数据',
|
||||
next: '下一步',
|
||||
previous: '上一步',
|
||||
skip: '跳过',
|
||||
loadingText: '加载中...',
|
||||
networkRequired: '此功能需要网络连接',
|
||||
networkDisconnected: '网络连接已断开',
|
||||
@@ -63,6 +68,15 @@ export default {
|
||||
serviceUnavailable: '服务不可用',
|
||||
status: '状态',
|
||||
preset: '预设',
|
||||
refresh: '刷新',
|
||||
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
|
||||
ascending: '升序',
|
||||
descending: '降序',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已进入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -79,6 +93,7 @@ export default {
|
||||
mediaServer: '媒体服务器',
|
||||
manual: '手动处理',
|
||||
plugin: '插件',
|
||||
agent: '智能体',
|
||||
other: '其它',
|
||||
},
|
||||
actionStep: {
|
||||
@@ -126,6 +141,7 @@ export default {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
auto: '跟随系统',
|
||||
autoUI: '自动',
|
||||
transparent: '透明',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
@@ -234,16 +250,33 @@ export default {
|
||||
wallpapers: '壁纸',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
otpCode: '双重验证码',
|
||||
otpCode: '验证码',
|
||||
stayLoggedIn: '保持登录',
|
||||
login: '登录',
|
||||
networkError: '登录失败,请检查网络连接!',
|
||||
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
|
||||
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
|
||||
permissionDenied: '登录失败,您没有权限访问!',
|
||||
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||
secondaryVerification: '二次验证',
|
||||
orDivider: '或',
|
||||
loginWithPasskey: '使用通行密钥登录',
|
||||
loginWithOtp: '使用验证码登录',
|
||||
orUsePasskey: '或使用通行密钥进行验证',
|
||||
verifyWithPasskey: '使用通行密钥验证',
|
||||
otpPlaceholder: '请输入6位验证码',
|
||||
passkeyLoginStartFailed: '启动通行密钥认证失败',
|
||||
passkeyNotSelected: '未选择通行密钥',
|
||||
passkeyLoginFailed: '通行密钥登录失败',
|
||||
passkeyAuthCanceled: '通行密钥认证被取消',
|
||||
passkeyNotSupported: '当前浏览器不支持通行密钥',
|
||||
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
|
||||
passkeyVerifyFailed: '通行密钥验证失败',
|
||||
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
|
||||
mfa: {
|
||||
selectVerificationMethod: '请选择验证方式',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: '开始',
|
||||
@@ -286,7 +319,7 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系统',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex)',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
},
|
||||
directory: {
|
||||
title: '存储 & 目录',
|
||||
@@ -320,10 +353,6 @@ export default {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
description: '软件版本',
|
||||
@@ -367,8 +396,10 @@ export default {
|
||||
deleteFailed: '用户删除失败!',
|
||||
profile: '个人信息',
|
||||
systemSettings: '系统设定',
|
||||
wizardSettings: '设置向导',
|
||||
siteAuth: '用户认证',
|
||||
helpDocs: '帮助文档',
|
||||
about: '关于',
|
||||
restart: '重启',
|
||||
management: '用户管理',
|
||||
noUsers: '没有用户',
|
||||
@@ -376,8 +407,11 @@ export default {
|
||||
addUser: '添加用户',
|
||||
editUser: '编辑用户',
|
||||
username: '用户名',
|
||||
usernameHint: '用于登录系统的用户名',
|
||||
password: '密码',
|
||||
passwordHint: '请输入登录密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordHint: '请再次输入密码以确认',
|
||||
role: '角色',
|
||||
email: '邮箱',
|
||||
enabled: '启用',
|
||||
@@ -404,18 +438,32 @@ export default {
|
||||
config: '配置',
|
||||
wechat: {
|
||||
name: '企业微信',
|
||||
useBotMode: '使用智能机器人',
|
||||
useBotModeHint: '开启后使用智能机器人长连接,固定 dmPolicy=open、groupPolicy=disabled',
|
||||
corpId: '企业ID',
|
||||
corpIdHint: '企业微信后台企业信息中的企业ID',
|
||||
corpIdRequired: '企业ID不能为空',
|
||||
appId: '应用 AgentId',
|
||||
appIdHint: '企业微信自建应用的AgentId',
|
||||
appIdRequired: '应用AgentId不能为空',
|
||||
appSecret: '应用 Secret',
|
||||
appSecretHint: '企业微信自建应用的Secret',
|
||||
appSecretRequired: '应用Secret不能为空',
|
||||
proxy: '代理地址',
|
||||
proxyHint: '微信消息的转发代理地址,2022年6月20日后创建的自建应用才需要,不使用代理时需要保留默认值',
|
||||
token: 'Token',
|
||||
tokenHint: '微信企业自建应用->API接收消息配置中的Token',
|
||||
encodingAesKey: 'EncodingAESKey',
|
||||
encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',
|
||||
botId: '机器人 BotID',
|
||||
botIdHint: '企业微信智能机器人的 BotID',
|
||||
botSecret: '机器人 Secret',
|
||||
botSecretHint: '企业微信智能机器人长连接专用 Secret',
|
||||
botChatId: '默认通知目标',
|
||||
botChatIdHint: '可填写用户 userid;如需主动发群消息可填写 group:群聊chatid,不填则默认发给已互动用户',
|
||||
botChatIdPlaceholder: 'userid 或 group:chatid',
|
||||
botWsUrl: '长连接地址',
|
||||
botWsUrlHint: '企业微信智能机器人 WebSocket 地址,通常使用默认值',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '可使用管理菜单及命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
@@ -424,8 +472,10 @@ export default {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
tokenHint: 'Telegram机器人token,格式:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
tokenRequired: 'Bot Token不能为空',
|
||||
chatId: 'Chat ID',
|
||||
chatIdHint: '接受消息通知的用户、群组或频道Chat ID',
|
||||
chatIdRequired: 'Chat ID不能为空',
|
||||
users: '用户白名单',
|
||||
usersHint: '可使用Telegram机器人的用户ID清单,多个用户用,分隔,不填写则所有用户都能使用',
|
||||
admins: '管理员白名单',
|
||||
@@ -440,15 +490,30 @@ export default {
|
||||
name: 'Slack',
|
||||
oauthToken: 'Slack Bot User OAuth Token',
|
||||
oauthTokenHint: 'Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`',
|
||||
oauthTokenRequired: 'OAuth Token不能为空',
|
||||
appToken: 'Slack App-Level Token',
|
||||
appTokenHint: 'Slack应用`OAuth & Permissions`页面中的`App-Level Token`',
|
||||
channel: '频道名称',
|
||||
channelHint: '消息发送频道,默认`全体`',
|
||||
channelRequired: '频道名称不能为空',
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
botToken: 'Bot Token',
|
||||
botTokenHint: 'Discord Bot Token(需在开发者后台开启 Message Content Intent)',
|
||||
botTokenRequired: 'Bot Token不能为空',
|
||||
guildId: '服务器 ID',
|
||||
guildIdHint: '可选,限制使用的服务器;为空则使用已加入的任意服务器',
|
||||
guildIdPlaceholder: '123456789012345678',
|
||||
channelId: '频道 ID',
|
||||
channelIdHint: '可选,默认广播频道;为空则自动选择可发送消息的频道',
|
||||
channelIdPlaceholder: '123456789012345678',
|
||||
},
|
||||
synologychat: {
|
||||
name: 'Synology Chat',
|
||||
webhook: '机器人传入URL',
|
||||
webhookHint: 'Synology Chat机器人传入URL',
|
||||
webhookRequired: 'Webhook URL不能为空',
|
||||
token: '令牌',
|
||||
tokenHint: 'Synology Chat机器人令牌',
|
||||
},
|
||||
@@ -456,8 +521,10 @@ export default {
|
||||
name: 'VoceChat',
|
||||
host: '地址',
|
||||
hostHint: 'VoceChat服务端地址,格式:http(s)://ip:port',
|
||||
hostRequired: '地址不能为空',
|
||||
apiKey: '机器人密钥',
|
||||
apiKeyHint: 'VoceChat机器人密钥',
|
||||
apiKeyRequired: 'API密钥不能为空',
|
||||
channelId: '频道ID',
|
||||
channelIdHint: 'VoceChat的频道ID,不包含#号',
|
||||
},
|
||||
@@ -465,6 +532,22 @@ export default {
|
||||
name: 'WebPush',
|
||||
username: '登录用户名',
|
||||
usernameHint: '只有对应的用户登录后才会推送消息',
|
||||
usernameRequired: '用户名不能为空',
|
||||
},
|
||||
qqbot: {
|
||||
name: 'QQ',
|
||||
appId: 'AppID',
|
||||
appIdHint: 'QQ 开放平台机器人 AppID',
|
||||
appIdRequired: 'AppID 不能为空',
|
||||
appSecret: 'AppSecret',
|
||||
appSecretHint: 'QQ 开放平台机器人 AppSecret',
|
||||
appSecretRequired: 'AppSecret 不能为空',
|
||||
openId: '用户 OpenID',
|
||||
openIdHint: '默认接收者 openid(单聊),用户需曾与机器人交互过',
|
||||
openIdPlaceholder: '32位十六进制',
|
||||
groupOpenId: '群组 OpenID',
|
||||
groupOpenIdHint: '默认群组 openid(群聊),与用户 OpenID 二选一',
|
||||
groupOpenIdPlaceholder: '群组 openid',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
@@ -493,6 +576,18 @@ export default {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
subtitle: '词表设置',
|
||||
},
|
||||
cache: {
|
||||
title: '缓存',
|
||||
subtitle: '管理缓存',
|
||||
},
|
||||
scheduler: {
|
||||
title: '服务',
|
||||
subtitle: '定时服务',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: '动作组件',
|
||||
@@ -763,6 +858,8 @@ export default {
|
||||
originalTitle: '原始标题',
|
||||
status: '状态',
|
||||
releaseDate: '上映日期',
|
||||
digitalRelease: '数字发行',
|
||||
physicalRelease: '实体发行',
|
||||
originalLanguage: '原始语言',
|
||||
productionCountries: '出品国家',
|
||||
productionCompanies: '制作公司',
|
||||
@@ -846,6 +943,7 @@ export default {
|
||||
batchEnableError: '批量启用操作失败',
|
||||
batchPauseError: '批量暂停操作失败',
|
||||
batchDeleteError: '批量删除操作失败',
|
||||
minSubscribers: '最小订阅人数',
|
||||
},
|
||||
recommend: {
|
||||
all: '全部',
|
||||
@@ -895,6 +993,10 @@ export default {
|
||||
searching: '正在搜索,请稍候...',
|
||||
noData: '没有数据',
|
||||
noResourceFound: '未搜索到任何资源',
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
refreshSearch: '重新搜索',
|
||||
},
|
||||
browse: {
|
||||
actor: '演员',
|
||||
@@ -1130,6 +1232,17 @@ export default {
|
||||
content: '内容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日志内容',
|
||||
allLevels: '全部级别',
|
||||
followTail: '跟随最新日志',
|
||||
wrapLines: '自动换行',
|
||||
pauseStream: '暂停日志流',
|
||||
resumeStream: '恢复日志流',
|
||||
waitingForLogs: '等待日志输出...',
|
||||
paused: '已暂停',
|
||||
connected: '实时更新中',
|
||||
lineCount: '显示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1168,6 +1281,7 @@ export default {
|
||||
title: '关于 MoviePilot',
|
||||
softwareVersion: '软件版本',
|
||||
frontendVersion: '前端版本',
|
||||
browserVersion: '浏览器缓存版本',
|
||||
authVersion: '认证资源版本',
|
||||
indexerVersion: '站点资源版本',
|
||||
configDir: '配置目录',
|
||||
@@ -1175,7 +1289,7 @@ export default {
|
||||
timezone: '时区',
|
||||
latest: '最新',
|
||||
supportingSites: '支持站点',
|
||||
support: '支援',
|
||||
support: '支持',
|
||||
documentation: '文档',
|
||||
feedback: '问题反馈',
|
||||
channel: '发布频道',
|
||||
@@ -1187,6 +1301,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1211,13 +1326,101 @@ export default {
|
||||
apiTokenLength: 'API Token不得低于16位',
|
||||
githubToken: 'Github Token',
|
||||
githubTokenFormat: 'ghp_**** 或 github_pat_****',
|
||||
githubTokenHint: '用于提高插件等访问Github API时的限流阈值',
|
||||
githubTokenHint: '用于提高插件等访问Github API时的限流阈值,建议配置,否则插件可能无法正常使用',
|
||||
ocrHost: '验证码识别服务器',
|
||||
ocrHostHint: '用于站点签到、更新站点Cookie等识别验证码',
|
||||
aiAgent: '启用智能助手',
|
||||
aiAgentEnable: '启用智能助手',
|
||||
aiAgentEnableHint: '启用后可使用智能助手功能,需要配置LLM相关参数',
|
||||
aiAgentSectionTitle: '智能助手配置',
|
||||
aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
llmProvider: 'LLM提供商',
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如deepseek-v4-flash、gpt-5.4等',
|
||||
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K,来源:{source}',
|
||||
llmThinking: '思考模式 / 深度',
|
||||
llmThinkingHint:
|
||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支持的级别会按 provider 能力自动映射到最近值',
|
||||
llmThinkingLevelOff: '关闭 (off)',
|
||||
llmThinkingLevelAuto: '自动 (auto)',
|
||||
llmThinkingLevelMinimal: '最小 (minimal)',
|
||||
llmThinkingLevelLow: '低 (low)',
|
||||
llmThinkingLevelMedium: '中 (medium)',
|
||||
llmThinkingLevelHigh: '高 (high)',
|
||||
llmThinkingLevelMax: '极高 (max)',
|
||||
llmThinkingLevelXhigh: '超高 (xhigh)',
|
||||
llmSupportImageInput: '模型支持图片输入',
|
||||
llmSupportImageInputHint:
|
||||
'启用后,消息中的图片会按多模态图片发送给 LLM;关闭后图片会作为附件保存到本地,并将文件路径提供给智能助手处理',
|
||||
llmSupportAudioInputOutput: '支持音频输入输出',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||
llmApiKey: 'LLM API密钥',
|
||||
llmApiKeyHint: 'LLM服务提供商的API密钥,用于身份验证',
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
llmProviderAuth: '提供商授权',
|
||||
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
||||
llmProviderConnectedAs: '当前已连接:{label}',
|
||||
llmProviderDisconnect: '断开授权',
|
||||
llmProviderDisconnected: '已断开提供商授权',
|
||||
llmProviderAuthDialogTitle: '提供商授权',
|
||||
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
|
||||
llmProviderDeviceCode: '设备码',
|
||||
llmProviderOpenAuthPage: '打开授权页面',
|
||||
llmProviderCheckAuthStatus: '检查授权状态',
|
||||
aiVoiceApiKey: '音频 API密钥',
|
||||
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
||||
aiVoiceBaseUrl: '音频基础URL',
|
||||
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL,留空时回退到当前 LLM 基础 URL',
|
||||
aiVoiceSttModel: '音频转写模型',
|
||||
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
aiVoiceTtsModel: '语音合成模型',
|
||||
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
aiVoiceTtsVoice: '语音音色',
|
||||
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
|
||||
aiVoiceLanguage: '识别语言',
|
||||
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
aiVoiceReplyWithText: '语音回复附带文字',
|
||||
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
|
||||
llmTestAction: '测试调用',
|
||||
llmTestSuccessToast: 'LLM 调用测试成功',
|
||||
llmTestFailedToast: 'LLM 调用测试失败',
|
||||
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
|
||||
aiAgentJobInterval: '定时唤醒',
|
||||
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
||||
aiAgentVerbose: '啰嗦模式',
|
||||
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
||||
aiAgentJobIntervalDisabled: '不启用',
|
||||
aiAgentJobInterval1h: '1小时',
|
||||
aiAgentJobInterval3h: '3小时',
|
||||
aiAgentJobInterval6h: '6小时',
|
||||
aiAgentJobInterval12h: '12小时',
|
||||
aiAgentJobInterval24h: '24小时',
|
||||
aiAgentJobInterval1w: '1周',
|
||||
aiAgentJobInterval1M: '1个月',
|
||||
advancedSettings: '高级设置',
|
||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||
downloaders: '下载器',
|
||||
downloadersDesc: '只有默认下载器才会被默认使用。',
|
||||
aiAgentRetryTransfer: '文件整理失败智能接管',
|
||||
aiAgentRetryTransferHint:
|
||||
'启用后,当文件整理失败时,智能助手将自动接管并尝试重新整理,利用AI能力解决识别和匹配问题',
|
||||
aiRecommendEnabled: '搜索结果智能推荐',
|
||||
aiRecommendEnabledHint:
|
||||
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
|
||||
aiRecommendUserPreference: '用户偏好',
|
||||
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好,例如:4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: '智能推荐分析条目上限',
|
||||
aiRecommendMaxItemsHint:
|
||||
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
|
||||
mediaServers: '媒体服务器',
|
||||
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
|
||||
trimeMedia: '飞牛影视',
|
||||
@@ -1225,6 +1428,7 @@ export default {
|
||||
media: '媒体',
|
||||
network: '网络',
|
||||
log: '日志',
|
||||
data: '数据',
|
||||
lab: '实验室',
|
||||
downloaderSaveSuccess: '下载器设置保存成功',
|
||||
downloaderSaveFailed: '下载器设置保存失败!',
|
||||
@@ -1240,9 +1444,11 @@ export default {
|
||||
reloading: '正在应用配置...',
|
||||
qbittorrent: 'Qbittorrent',
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '绿联影视',
|
||||
reloadSuccess: '系统配置已生效',
|
||||
reloadFailed: '重载系统失败!',
|
||||
auxAuthEnable: '用户辅助认证',
|
||||
@@ -1282,6 +1488,10 @@ export default {
|
||||
fanartEnableHint: '使用 fanart.tv 的图片数据',
|
||||
fanartLang: 'Fanart语言',
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
recognizePluginFirst: "优先使用插件识别",
|
||||
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
|
||||
mediaRecognizeShare: '共享使用媒体识别数据',
|
||||
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID,识别失败时优先回查共享识别结果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github访问速度',
|
||||
@@ -1310,10 +1520,27 @@ export default {
|
||||
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
|
||||
logFileFormat: '日志文件格式',
|
||||
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
|
||||
dataCleanupEnable: '启用数据清理',
|
||||
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
|
||||
dataCleanupDaysRequired: '请输入清理周期',
|
||||
dataCleanupDaysMin: '清理周期必须大于等于0',
|
||||
dataCleanupMessageDays: '消息表保留天数',
|
||||
dataCleanupMessageDaysHint: '单位:天,0 表示不清理消息表数据',
|
||||
dataCleanupDownloadHistoryDays: '下载历史表保留天数',
|
||||
dataCleanupDownloadHistoryDaysHint: '单位:天,0 表示不清理下载历史及其关联的下载文件孤儿记录',
|
||||
dataCleanupSiteUserDataDays: '站点数据表保留天数',
|
||||
dataCleanupSiteUserDataDaysHint: '单位:天,0 表示不清理站点用户数据表',
|
||||
dataCleanupTransferHistoryDays: '整理历史表保留天数',
|
||||
dataCleanupTransferHistoryDaysHint: '单位:天,0 表示不清理整理历史表',
|
||||
downloadFilesCleanupNotice: '下载文件表没有独立时间字段,会跟随下载历史表的保留周期清理其孤儿记录。',
|
||||
pluginAutoReload: '插件热加载',
|
||||
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
||||
pluginLocalRepoPaths: '本地插件仓库路径',
|
||||
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
|
||||
encodingDetectionPerformanceMode: '编码探测性能模式',
|
||||
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
|
||||
transferThreads: '文件整理线程数',
|
||||
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
|
||||
tokenizedSearch: '分词搜索',
|
||||
tokenizedSearchHint: '提升整理历史记录搜索精度,但可能增加性能开销和意外结果',
|
||||
tmdbLanguage: {
|
||||
@@ -1349,6 +1576,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小时',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定义壁纸API地址',
|
||||
customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片,需要同步设置安全域名地址',
|
||||
customizeWallpaperApiRequired: '必填项;请输入自定义壁纸API',
|
||||
@@ -1390,6 +1618,11 @@ export default {
|
||||
episodeThumb: '缩略图',
|
||||
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
|
||||
scrapingSwitchSaveError: '刮削开关设置保存失败',
|
||||
policy: {
|
||||
skipDesc: '跳过刮削,不生成该文件',
|
||||
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
|
||||
overwriteDesc: '始终刮削,已存在则覆盖',
|
||||
}
|
||||
},
|
||||
site: {
|
||||
siteSync: '站点同步',
|
||||
@@ -1484,6 +1717,7 @@ export default {
|
||||
synologyChat: 'SynologyChat',
|
||||
voceChat: 'VoceChat',
|
||||
webPush: 'WebPush',
|
||||
qq: 'QQ',
|
||||
custom: '自定义通知',
|
||||
},
|
||||
words: {
|
||||
@@ -1583,6 +1817,25 @@ export default {
|
||||
storageSaveSuccess: '存储设置保存成功',
|
||||
storageSaveFailed: '存储设置保存失败!',
|
||||
},
|
||||
category: {
|
||||
title: '分类策略',
|
||||
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
|
||||
movie: '电影 (Movie)',
|
||||
tv: '电视剧 (TV)',
|
||||
name: '分类名称 (目录名)',
|
||||
genre: '内容类型 (Genre)',
|
||||
language: '语种 (Language)',
|
||||
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
|
||||
country: '国家/地区 (Country)',
|
||||
countryPlaceholder: '如: US,CN,JP',
|
||||
year: '年份 (Year)',
|
||||
yearPlaceholder: '如: 2023, 2020-2024',
|
||||
addMovie: '添加电影分类',
|
||||
addTv: '添加电视剧分类',
|
||||
saveSuccess: '分类策略保存成功',
|
||||
loadFailed: '加载分类配置失败',
|
||||
saveFailed: '保存失败: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: '自定义规则',
|
||||
customRulesDesc: '自定义优先级规则项',
|
||||
@@ -1648,7 +1901,9 @@ export default {
|
||||
bestVersionRuleGroup: '洗版优先级规则组',
|
||||
bestVersionRuleGroupHint: '按选定的过滤规则组对洗版订阅进行过滤',
|
||||
timedSearch: '订阅定时搜索',
|
||||
timedSearchHint: '每隔24小时全站搜索,以补全订阅可能漏掉的资源',
|
||||
timedSearchHint: '每隔指定时间全站搜索,以补全订阅可能漏掉的资源',
|
||||
searchInterval: '订阅搜索时间间隔',
|
||||
searchIntervalHint: '设置订阅搜索的时间间隔,仅在开启订阅定时搜索时生效',
|
||||
checkLocalMedia: '检查文件系统资源',
|
||||
checkLocalMediaHint: '扫描存储目录中是否已存在相应资源文件,以避免重复下载;不管是否开启都会检查媒体服务器',
|
||||
modes: {
|
||||
@@ -1663,6 +1918,8 @@ export default {
|
||||
hour1: '1小时',
|
||||
hour12: '12小时',
|
||||
day1: '1天',
|
||||
day3: '3天',
|
||||
week1: '一周',
|
||||
},
|
||||
saveSuccess: '订阅站点保存成功',
|
||||
saveFailed: '订阅站点保存失败!',
|
||||
@@ -1672,6 +1929,8 @@ export default {
|
||||
cache: {
|
||||
title: '缓存管理',
|
||||
subtitle: '管理缓存的站点资源',
|
||||
totalCount: '总条数',
|
||||
siteCount: '站点数',
|
||||
filterByTitle: '按标题筛选',
|
||||
filterBySite: '按站点筛选',
|
||||
selectSite: '选择站点',
|
||||
@@ -1744,8 +2003,12 @@ export default {
|
||||
add: '添加用户',
|
||||
edit: '编辑用户',
|
||||
username: '用户名',
|
||||
usernameRequired: '用户名不能为空',
|
||||
password: '密码',
|
||||
passwordMinLength: '密码长度不能少于6位',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordRequired: '请确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
email: '邮箱',
|
||||
nickname: '昵称',
|
||||
status: '状态',
|
||||
@@ -1761,14 +2024,13 @@ export default {
|
||||
wechat: '微信ID',
|
||||
telegram: 'Telegram ID',
|
||||
slack: 'Slack ID',
|
||||
discord: 'Discord ID',
|
||||
vocechat: 'VoceChat ID',
|
||||
synologyChat: 'SynologyChat ID',
|
||||
webPush: 'WebPush',
|
||||
creatingUser: '正在创建【{name}】用户,请稍后',
|
||||
updatingUser: '正在更新【{name}】用户,请稍后',
|
||||
usernameRequired: '用户名不能为空',
|
||||
usernameExists: '用户名已存在',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
userCreated: '用户【{name}】创建成功',
|
||||
userCreateFailed: '创建用户失败:{message}',
|
||||
userUpdateSuccess: '用户【{name}】更新成功',
|
||||
@@ -1800,7 +2062,7 @@ export default {
|
||||
},
|
||||
searchBar: {
|
||||
search: '搜索',
|
||||
searchPlaceholder: '搜索功能、订阅、设置...',
|
||||
searchPlaceholder: '搜索电影、剧集以及更多...',
|
||||
recentSearches: '最近搜索',
|
||||
noRecentSearches: '没有最近搜索记录',
|
||||
functions: '功能',
|
||||
@@ -1820,6 +2082,9 @@ export default {
|
||||
searchInSites: '在站点中搜索种子资源',
|
||||
relatedResources: '相关资源',
|
||||
searchTip: '可搜索电影、电视剧、演员、资源等',
|
||||
emptySearchHint: '输入关键字开始搜索',
|
||||
escClose: '关闭',
|
||||
openSearch: '打开搜索',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: '选择站点',
|
||||
@@ -1844,6 +2109,8 @@ export default {
|
||||
startDownload: '开始下载',
|
||||
downloadSuccess: '{site} {title} 下载成功!',
|
||||
downloadFailed: '{site} {title} 下载失败:{message}!',
|
||||
showAdvancedOptions: '显示高级选项',
|
||||
hideAdvancedOptions: '隐藏高级选项',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享订阅',
|
||||
@@ -1871,9 +2138,15 @@ export default {
|
||||
securityWarningMessage: '分享前请确保工作流没有敏感信息,比如RSS链接中的PassKey等,避免产生信息泄露。',
|
||||
},
|
||||
u115Auth: {
|
||||
loginTitle: '115网盘登录',
|
||||
scanQrCode: '请使用微信或115客户端扫码',
|
||||
scanned: '已扫码,请确认登录',
|
||||
loginTitle: '115网盘授权',
|
||||
openAuthWindow: '打开授权窗口',
|
||||
authorizing: '请在新窗口中完成授权...',
|
||||
authSuccess: '授权成功!',
|
||||
authFailed: '授权失败或已过期',
|
||||
authCanceled: '授权已取消,请重试',
|
||||
urlEmpty: '授权URL为空',
|
||||
urlFetchFailed: '获取授权URL失败',
|
||||
popupBlocked: '无法打开授权窗口,请检查浏览器弹窗设置',
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
@@ -2025,6 +2298,10 @@ export default {
|
||||
repoUrl: '插件仓库地址',
|
||||
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||
repoHint: '多个地址使用换行分隔,仅支持Github仓库',
|
||||
urlPlaceholder: '输入插件仓库地址',
|
||||
noRepos: '暂无插件仓库地址',
|
||||
invalidUrl: '请输入有效的URL地址',
|
||||
duplicateUrl: '该地址已存在',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
saveSuccess: '插件仓库保存成功',
|
||||
@@ -2375,6 +2652,7 @@ export default {
|
||||
settings: '设置',
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
local: '本地',
|
||||
installToLocal: '安装到本地',
|
||||
totalDownloads: '共 {count} 次下载',
|
||||
viewData: '查看数据',
|
||||
@@ -2444,6 +2722,7 @@ export default {
|
||||
noRecentPlugins: '无',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
|
||||
personalInfo: '个人信息',
|
||||
uploadNewAvatar: '上传新头像',
|
||||
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
|
||||
@@ -2464,21 +2743,58 @@ export default {
|
||||
wechatUser: '微信用户',
|
||||
telegramUser: 'Telegram用户',
|
||||
slackUser: 'Slack用户',
|
||||
discordUser: 'Discord用户',
|
||||
vocechatUser: 'VoceChat用户',
|
||||
synologychatUser: 'SynologyChat用户',
|
||||
doubanUser: '豆瓣用户',
|
||||
twoFactorAuthentication: '登录双重验证',
|
||||
setupAuthenticator: '设置身份验证器',
|
||||
authenticatorManagement: '身份验证器管理',
|
||||
authenticatorEnabled: '您已启用身份验证器双重验证',
|
||||
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
|
||||
clearAuthenticator: '清除身份验证器',
|
||||
enableTwoFactor: '开启双重验证',
|
||||
disableTwoFactor: '关闭双重验证',
|
||||
setupMfa: '设置双重验证',
|
||||
enableMfa: '开启双重验证',
|
||||
useAuthenticator: '使用身份验证器',
|
||||
usePasskey: '使用通行密钥',
|
||||
enabled: '已启用',
|
||||
keysCount: '{count} 个密钥',
|
||||
passkeyManagement: '通行密钥管理',
|
||||
registerNewPasskey: '注册新通行密钥',
|
||||
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
|
||||
passkeyAppDescription:
|
||||
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
|
||||
passkeyName: '通行密钥名称',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '注册通行密钥',
|
||||
createdAt: '创建于',
|
||||
lastUsedAt: '最后使用时间',
|
||||
noPasskeys: '您还没有注册任何通行密钥',
|
||||
passkeyNameRequired: '请输入通行密钥名称',
|
||||
passkeyRegisterSuccess: '通行密钥注册成功',
|
||||
passkeyRegisterFailed: '注册失败',
|
||||
passkeyRegisterCancelled: '注册被取消',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyDeleteFailed: '删除失败',
|
||||
deletePasskey: '删除通行密钥',
|
||||
passkeyDomainWarning:
|
||||
'通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey:
|
||||
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
accessDomain: '访问域名',
|
||||
otpAuthenticator: 'OTP 身份验证器',
|
||||
otpGenerateFailed: '获取otp uri失败:{message}!',
|
||||
otpDisableSuccess: '关闭登录双重验证成功!',
|
||||
otpDisableFailed: '关闭otp失败:{message}!',
|
||||
otpCodeRequired: '请填写6位验证码',
|
||||
otpEnableSuccess: '开启登录双重验证成功!',
|
||||
otpEnableFailed: '开启otp失败:{message}!',
|
||||
authenticatorApp: '身份验证器',
|
||||
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
|
||||
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
|
||||
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
||||
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。',
|
||||
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
|
||||
enterVerificationCode: '输入验证码以确认开启双重验证',
|
||||
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
|
||||
@@ -2520,10 +2836,19 @@ export default {
|
||||
loading: '加载中...',
|
||||
pageSize: '每页条数',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
|
||||
aiRedoQueued: '已提交智能助手整理任务:{title}',
|
||||
aiRedoFailed: '提交智能助手整理任务失败',
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '删除',
|
||||
batchRedo: '批量重新整理',
|
||||
batchDelete: '批量删除',
|
||||
},
|
||||
batchOperationTitle: '批量操作',
|
||||
progress: {
|
||||
processing: '处理中',
|
||||
pleaseWait: '请稍候...',
|
||||
@@ -2578,10 +2903,13 @@ export default {
|
||||
type: '类型',
|
||||
enabled: '启用',
|
||||
customTypeHint: '自定义下载器类型,用于插件等场景',
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
|
||||
default: '默认',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填写后将优先使用 API Key 登录。',
|
||||
category: '自动分类管理',
|
||||
sequentail: '顺序下载',
|
||||
force_resume: '强制继续',
|
||||
@@ -2591,6 +2919,14 @@ export default {
|
||||
nameRequired: '不能为空,且不能重名',
|
||||
nameDuplicate: '名称已存在',
|
||||
defaultChanged: '存在默认下载器,已替换',
|
||||
hostRequired: '地址不能为空',
|
||||
usernameRequired: '用户名不能为空',
|
||||
passwordRequired: '密码不能为空',
|
||||
pathMapping: '路径映射',
|
||||
pathMappingRequired: '路径不能为空',
|
||||
pathMappingError: '必须以 / 开头',
|
||||
storagePath: '存储路径',
|
||||
downloadPath: '下载路径',
|
||||
},
|
||||
filterRule: {
|
||||
title: '过滤规则',
|
||||
@@ -2635,10 +2971,25 @@ export default {
|
||||
plexToken: 'X-Plex-Token',
|
||||
plexTokenHint: '浏览器F12->网络,从Plex请求URL中获取的X-Plex-Token',
|
||||
username: '用户名',
|
||||
usernameHint: '登录用户名',
|
||||
password: '密码',
|
||||
syncLibraries: '同步媒体库',
|
||||
syncLibrariesHint: '只有选中的媒体库才会被同步',
|
||||
scanMode: '扫描模式',
|
||||
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
|
||||
verifySsl: '校验 SSL 证书',
|
||||
verifySslHint: '开启后会校验 HTTPS 证书;如使用自签名证书可关闭',
|
||||
scanModeOptions: {
|
||||
newAndModified: '新添加和修改',
|
||||
supplementMissing: '补充缺失',
|
||||
fullOverride: '覆盖扫描',
|
||||
},
|
||||
nameExists: '【{name}】已存在,请替换为其他名称',
|
||||
hostRequired: '地址不能为空',
|
||||
apiKeyRequired: 'API密钥不能为空',
|
||||
tokenRequired: 'Token不能为空',
|
||||
usernameRequired: '用户名不能为空',
|
||||
passwordRequired: '密码不能为空',
|
||||
},
|
||||
bangumi: {
|
||||
category: '类别',
|
||||
@@ -2671,6 +3022,9 @@ export default {
|
||||
firstAirDateAsc: '首播日期升序',
|
||||
voteAverageDesc: '评分降序',
|
||||
voteAverageAsc: '评分升序',
|
||||
time: '最新',
|
||||
count: '热门',
|
||||
rating: '评分',
|
||||
},
|
||||
genreType: {
|
||||
action: '动作',
|
||||
@@ -2800,7 +3154,9 @@ export default {
|
||||
libraryStorage: '媒体库存储',
|
||||
libraryDirectory: '媒体库目录',
|
||||
transferType: '整理方式',
|
||||
transferTypeHint: '文件操作整理方式,硬链接节省空间,复制更安全',
|
||||
overwriteMode: '覆盖模式',
|
||||
overwriteModeHint: '当目标文件已存在时的处理方式',
|
||||
smartRename: '智能重命名',
|
||||
scrapingMetadata: '刮削元数据',
|
||||
sendNotification: '发送通知',
|
||||
@@ -2841,4 +3197,193 @@ export default {
|
||||
customBackgroundImageHint: '支持网络图片URL,留空则使用渐变背景',
|
||||
pluginCount: '{count} 个插件',
|
||||
},
|
||||
setupWizard: {
|
||||
title: '欢迎使用 MoviePilot !',
|
||||
subtitle: '按向导完成配置,即刻开始使用。',
|
||||
completed: '配置向导完成!',
|
||||
failed: '配置向导失败,请重试',
|
||||
complete: '完成配置',
|
||||
loading: '正在加载配置数据...',
|
||||
testing: '正在测试',
|
||||
connectivityTestSuccess: '连通性测试通过',
|
||||
connectivityTestFailed: '连通性测试失败',
|
||||
testingStorage: '正在测试存储目录',
|
||||
checkingStorage: '检查存储目录连通性',
|
||||
storageTestFailed: '存储目录测试失败',
|
||||
testingDownloader: '正在测试下载器',
|
||||
checkingDownloader: '检查下载器连通性',
|
||||
downloaderTestFailed: '下载器测试失败',
|
||||
downloaderNotSelected: '未选择下载器',
|
||||
unsupportedDownloaderType: '不支持的下载器类型: {type}',
|
||||
testingMediaServer: '正在测试媒体服务器',
|
||||
checkingMediaServer: '检查媒体服务器连通性',
|
||||
mediaServerTestFailed: '媒体服务器测试失败',
|
||||
mediaServerNotSelected: '未选择媒体服务器',
|
||||
unsupportedMediaServerType: '不支持的媒体服务器类型: {type}',
|
||||
testingNotification: '正在测试消息通知',
|
||||
checkingNotification: '检查消息通知连通性',
|
||||
notificationTestFailed: '消息通知测试失败',
|
||||
notificationNotSelected: '未选择通知类型',
|
||||
unsupportedNotificationType: '不支持的通知类型: {type}',
|
||||
testFailedHint: '请检查配置是否正确,修改后可以重新测试',
|
||||
saveStepFailed: '保存步骤设置失败',
|
||||
basicSettingsSaved: '基础设置保存成功',
|
||||
saveBasicSettingsFailed: '保存基础设置失败',
|
||||
storageSettingsSaved: '存储设置保存成功',
|
||||
saveStorageSettingsFailed: '保存存储设置失败',
|
||||
downloaderSettingsSaved: '下载器设置保存成功',
|
||||
saveDownloaderSettingsFailed: '保存下载器设置失败',
|
||||
mediaServerSettingsSaved: '媒体服务器设置保存成功',
|
||||
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
|
||||
notificationSettingsSaved: '通知设置保存成功',
|
||||
saveNotificationSettingsFailed: '保存通知设置失败',
|
||||
saveSiteAuthSettingsFailed: '保存用户站点认证设置失败:{message}',
|
||||
saveAgentSettingsFailed: '保存智能助手设置失败',
|
||||
preferenceSettingsSaved: '偏好设置保存成功',
|
||||
savePreferenceSettingsFailed: '保存偏好设置失败',
|
||||
passwordUpdateSuccess: '密码更新成功',
|
||||
passwordUpdateFailed: '密码更新失败',
|
||||
userCreateSuccess: '用户创建成功',
|
||||
basic: {
|
||||
title: '基础设置',
|
||||
description: '设置访问域名、用户名密码和网络配置',
|
||||
appDomain: '访问域名',
|
||||
appDomainHint: '用于发送通知时,添加快捷跳转地址',
|
||||
wallpaper: '背景壁纸',
|
||||
wallpaperHint: '选择登录页面背景来源',
|
||||
recognizeSource: '识别数据源',
|
||||
recognizeSourceHint: '设置默认媒体信息识别数据源',
|
||||
apiToken: 'API 令牌',
|
||||
apiTokenHint: '访问MoviePilot API 需要的访问令牌,请记录下来以便后续使用',
|
||||
currentUserHint: '当前用户,不可修改',
|
||||
passwordOptionalHint: '留空表示不修改密码',
|
||||
confirmPasswordHint: '确认新密码',
|
||||
apiTokenRequired: 'API Token不能为空',
|
||||
},
|
||||
siteAuth: {
|
||||
title: '用户认证',
|
||||
description: '配置用户站点认证与辅助认证',
|
||||
info: '用户站点认证说明',
|
||||
infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选,后续也可在个人菜单中继续配置。',
|
||||
selectSiteHint: '选择一个支持认证的站点,并填写该站点要求的认证参数',
|
||||
submitHint: '点击下一步时将立即向认证站点发起校验,认证成功后会保存当前参数。',
|
||||
siteConfigNotExist: '认证站点配置不存在',
|
||||
fieldRequired: '请输入{name}',
|
||||
},
|
||||
storage: {
|
||||
title: '存储',
|
||||
description: '配置下载目录和媒体库目录',
|
||||
info: '存储配置说明',
|
||||
infoDesc: '配置本地存储目录,用于下载和媒体库管理',
|
||||
downloadPath: '下载目录',
|
||||
downloadPathHint: '设置下载文件的存储路径',
|
||||
libraryPath: '媒体库目录',
|
||||
libraryPathHint: '设置媒体文件的存储路径',
|
||||
downloadPathRequired: '下载目录不能为空',
|
||||
libraryPathRequired: '媒体库目录不能为空',
|
||||
},
|
||||
downloader: {
|
||||
title: '下载器',
|
||||
description: '配置下载器',
|
||||
info: '下载器配置说明',
|
||||
infoDesc: '配置下载器用于下载资源,可选择qBittorrent或Transmission',
|
||||
type: '下载器类型',
|
||||
typeHint: '选择要使用的下载器类型',
|
||||
name: '下载器名称',
|
||||
nameHint: '为下载器设置一个名称',
|
||||
qbittorrentConfig: 'qBittorrent 配置',
|
||||
transmissionConfig: 'Transmission 配置',
|
||||
host: '服务器地址',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
downloadPath: '下载路径',
|
||||
},
|
||||
mediaServer: {
|
||||
title: '媒体服务器',
|
||||
description: '配置媒体服务器',
|
||||
info: '媒体服务器配置说明',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
type: '媒体服务器类型',
|
||||
typeHint: '选择要使用的媒体服务器类型',
|
||||
name: '服务器名称',
|
||||
nameHint: '为媒体服务器设置一个名称',
|
||||
embyConfig: 'Emby 配置',
|
||||
jellyfinConfig: 'Jellyfin 配置',
|
||||
plexConfig: 'Plex 配置',
|
||||
host: '服务器地址',
|
||||
apiKey: 'API 密钥',
|
||||
token: '访问令牌',
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '配置通知渠道',
|
||||
info: '通知配置说明',
|
||||
infoDesc: '配置通知渠道用于接收系统消息(可选)',
|
||||
type: '通知类型',
|
||||
typeHint: '选择要使用的通知渠道类型',
|
||||
name: '通知名称',
|
||||
nameHint: '为通知渠道设置一个名称',
|
||||
telegramConfig: 'Telegram 配置',
|
||||
emailConfig: '邮件配置',
|
||||
botToken: '机器人令牌',
|
||||
chatId: '聊天ID',
|
||||
smtpServer: 'SMTP 服务器',
|
||||
smtpPort: 'SMTP 端口',
|
||||
senderEmail: '发送邮箱',
|
||||
senderPassword: '发送密码',
|
||||
receiverEmail: '接收邮箱',
|
||||
},
|
||||
agent: {
|
||||
title: '智能助手',
|
||||
description: '配置 Agent 助手与 LLM 参数',
|
||||
info: '智能助手配置说明',
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
},
|
||||
preferences: {
|
||||
title: '资源偏好',
|
||||
description: '设置资源下载偏好',
|
||||
info: '资源偏好说明',
|
||||
infoDesc: '设置资源下载的偏好,系统将根据这些偏好自动选择最佳资源',
|
||||
quality: '质量偏好',
|
||||
qualityHint: '选择偏好的视频质量',
|
||||
subtitle: '字幕偏好',
|
||||
subtitleHint: '选择偏好的字幕类型',
|
||||
resolution: '分辨率偏好',
|
||||
resolutionHint: '选择偏好的视频分辨率',
|
||||
presetRules: '预设规则',
|
||||
detailedConfig: '详细配置',
|
||||
quickPresets: '快速预设',
|
||||
quickPresetsDesc: '选择预设配置,系统将自动应用对应的规则',
|
||||
personalizationOptions: '个性化选项',
|
||||
personalizationOptionsDesc: '根据您的需求调整规则',
|
||||
excludeDolbyVision: '排除杜比视界',
|
||||
excludeDolbyVisionHint: '选中后规则中将排除杜比视界资源',
|
||||
excludeBluray: '排除蓝光原盘',
|
||||
excludeBlurayHint: '选中后规则中将排除蓝光原盘资源',
|
||||
presets: {
|
||||
'4k-enthusiast': {
|
||||
name: '4K发烧友',
|
||||
description: '追求最高画质,优先4K',
|
||||
},
|
||||
'balanced': {
|
||||
name: '平衡模式',
|
||||
description: '画质与存储空间的平衡选择',
|
||||
},
|
||||
'space-saver': {
|
||||
name: '节省空间',
|
||||
description: '优先较小文件,节省存储空间',
|
||||
},
|
||||
'free-priority': {
|
||||
name: '免费优先',
|
||||
description: '优先免费资源,其它的没有要求',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
68
src/main.ts
@@ -1,11 +1,9 @@
|
||||
// 1. 配置与兼容性
|
||||
import './ace-config'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
|
||||
// 2. 核心插件和 UI 框架
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, defineAsyncComponent } from 'vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import pinia from '@/stores/index'
|
||||
@@ -13,9 +11,7 @@ import i18n from '@/plugins/i18n'
|
||||
|
||||
// 3. 全局组件
|
||||
import App from '@/App.vue'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
@@ -23,22 +19,12 @@ import { loadRemoteComponents } from './utils/federationLoader'
|
||||
// 5. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 6. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
|
||||
import PageContentTitle from './@core/components/PageContentTitle.vue'
|
||||
import MediaCard from './components/cards/MediaCard.vue'
|
||||
import PosterCard from './components/cards/PosterCard.vue'
|
||||
import BackdropCard from './components/cards/BackdropCard.vue'
|
||||
import PersonCard from './components/cards/PersonCard.vue'
|
||||
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import CronField from './components/field/CronField.vue'
|
||||
import PathField from './components/field/PathField.vue'
|
||||
import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
@@ -50,6 +36,34 @@ import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
import { backgroundManager } from '@/utils/backgroundManager'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
|
||||
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
await import('./ace-config')
|
||||
return (await import('vue3-ace-editor')).VAceEditor
|
||||
})
|
||||
|
||||
const AsyncApexChart = defineAsyncComponent(async () => {
|
||||
const component = (await import('vue3-apexcharts')).default
|
||||
const themeName = document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'light'
|
||||
configureApexChartsTheme(themeName)
|
||||
return component
|
||||
})
|
||||
|
||||
const AsyncCronVuetify = defineAsyncComponent(async () => {
|
||||
return (await import('@vue-js-cron/vuetify')).CronVuetify
|
||||
})
|
||||
|
||||
const AsyncCronField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/CronField.vue')).default
|
||||
})
|
||||
|
||||
const AsyncPathField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/PathField.vue')).default
|
||||
})
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -72,21 +86,13 @@ app.use(stateRestorePlugin)
|
||||
|
||||
// 5. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VAceEditor', AsyncAceEditor)
|
||||
.component('VApexChart', AsyncApexChart)
|
||||
.component('VCronVuetify', AsyncCronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VScrollToTopBtn', ScrollToTopBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
.component('VBackdropCard', BackdropCard)
|
||||
.component('VPersonCard', PersonCard)
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
.component('VHeaderTab', HeaderTab)
|
||||
.component('VCronField', AsyncCronField)
|
||||
.component('VPathField', AsyncPathField)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 6. 注册其他插件
|
||||
@@ -98,7 +104,9 @@ app
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
await iconBundlePromise
|
||||
app.mount('#app')
|
||||
|
||||
// 页面卸载时清理后台管理器
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 Store 中获取用户信息
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
@@ -20,14 +21,22 @@ const userPermissions = computed(() => ({
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
const allMenus = getNavMenus()
|
||||
// 根据header属性对应用进行分类(含插件侧栏项,与桌面端侧栏一致)
|
||||
async function categorizeApps() {
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
let menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
if (pluginSidebarNavStore.items.length > 0) {
|
||||
const pluginNavMenus = filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
t,
|
||||
userPermissions.value,
|
||||
).map(e => e.navMenu)
|
||||
menus = [...menus, ...pluginNavMenus]
|
||||
}
|
||||
|
||||
// 按header属性分组
|
||||
const groupedMenus: Record<string, NavMenu[]> = {}
|
||||
|
||||
menus.forEach(menu => {
|
||||
@@ -38,11 +47,9 @@ function categorizeApps() {
|
||||
groupedMenus[header].push(menu)
|
||||
})
|
||||
|
||||
// 将分组结果赋值给响应式变量
|
||||
appGroups.value = groupedMenus
|
||||
}
|
||||
|
||||
// 页面加载时对应用进行分类
|
||||
onMounted(() => {
|
||||
categorizeApps()
|
||||
})
|
||||
@@ -60,7 +67,7 @@ onMounted(() => {
|
||||
<VList lines="one" class="settings-list">
|
||||
<VListItem
|
||||
v-for="(app, appIndex) in apps"
|
||||
:key="appIndex"
|
||||
:key="`${header}-${appIndex}-${String(app.to)}`"
|
||||
:to="app.to || ''"
|
||||
color="primary"
|
||||
class="settings-list-item"
|
||||
|
||||