Compare commits

...

64 Commits
v2.9.31 ... v2

Author SHA1 Message Date
jxxghp
a9403c9c34 chore: bump version to 2.10.11 2026-05-07 08:23:20 +08:00
jxxghp
dc4914e3ca style: adjust downloader card API key field to span full width 2026-05-07 08:22:39 +08:00
jxxghp
f3dbc4afad feat: add qBittorrent API key setup support
Expose qBittorrent WebUI API Key fields in settings and setup so 5.2 users can connect without requiring username/password.

Refs jxxghp/MoviePilot#5724
2026-05-07 07:41:05 +08:00
jxxghp
e3e22aebd9 feat: replace log level chips with VSelect dropdown in LoggingView and adjust layout spacing 2026-05-06 13:04:35 +08:00
jxxghp
0ca2f20b24 refactor: update logging record layout to use block-level elements for better alignment and structure 2026-05-06 08:02:50 +08:00
jxxghp
14279c773d fix: update LoggingView layout to support responsive height for mobile devices 2026-05-05 12:37:30 +08:00
jxxghp
8372f63eb6 refactor: dynamic logging view height calculation and remove redundant LLM model refresh on settings save 2026-05-05 12:34:09 +08:00
jxxghp
b7b62d7922 feat: overhaul logging view with advanced filtering, grouped display, and real-time streaming controls 2026-05-05 11:53:21 +08:00
jxxghp
162cce1f50 feat: replace VSelect with VAutocomplete for LLM provider selection in settings 2026-05-04 20:04:14 +08:00
jxxghp
aa49c6ccbc refactor(llm): merge preset selection into base URL field
Use a single editable Base URL combobox for LLM providers so preset endpoints and manual input share one field, with preset types shown as subtitles.
2026-05-03 11:31:06 +08:00
jxxghp
a40e52079f 更新 package.json 2026-05-03 09:44:37 +08:00
jxxghp
c29e329548 feat(llm): add provider URL presets
Expose provider-specific preset endpoints in the setup and system settings flows so users can start from the correct base URL while still editing it manually.
2026-05-03 09:38:28 +08:00
mcgrady.sun
e2d26f6a25 fix(resource): 解决重新搜索按钮 review 问题
- 简化 refreshSearch:移除多余的 switchToOriginalResults 调用,
  直接置 showingAiResults=false,其余状态由 fetchData 内部重置
- 标题栏 v-if 去掉 !progressActive 条件,避免点击重新搜索时
  整个标题栏 unmount 导致按钮 :loading 不可见、页面跳动
2026-05-02 16:33:19 +08:00
mcgrady.sun
1752256868 feat(resource): 资源搜索结果页增加重新搜索按钮
- 在搜索结果页右侧操作区新增"重新搜索"按钮(mdi-refresh 图标)
- 点击后使用相同搜索参数重新触发请求;正在请求或加载中时按钮禁用
- 若当前展示的是 AI 推荐结果,先切回原始结果再重新搜索,避免状态不一致
- 同步补充 zh-CN / zh-TW / en-US 三份本地化文案
2026-05-02 16:33:19 +08:00
jxxghp
23d7f0dcc1 更新 package.json 2026-04-30 11:55:22 +08:00
jxxghp
288aeed178 chore: update LLM model hint examples in localization files 2026-04-30 11:29:17 +08:00
jxxghp
9a9a618136 refactor: extract LLM provider management logic into composable and add OAuth support for system settings 2026-04-30 09:49:05 +08:00
jxxghp
723eb319e1 feat: add batch AI reorganization support to Transfer History view 2026-04-29 20:37:52 +08:00
jxxghp
96684a8d13 feat: add configuration for AI voice input/output settings and models 2026-04-29 18:15:50 +08:00
jxxghp
fc9fe5e21e chore: bump version to 2.10.8 and adjust UI styling for scraping settings 2026-04-29 17:10:36 +08:00
jxxghp
24b763d808 refactor: comment out redundant refresh call in ShortcutBar message submission 2026-04-28 13:10:37 +08:00
jxxghp
f761cdff00 chore: bump version to 2.10.7 and improve message sending reliability with explicit refresh and type safety 2026-04-28 13:04:05 +08:00
jxxghp
b785769138 feat: add transparent theme support for AI agent settings card and adjust border opacity 2026-04-25 12:30:30 +08:00
jxxghp
6d1febd70a 更新 AccountSettingSystem.vue 2026-04-25 11:41:35 +08:00
jxxghp
bdbaf503ca feat: implement custom Jinja2 syntax highlighting for rename templates and notification templates using VAceEditor 2026-04-25 11:20:15 +08:00
jxxghp
f9e74cf436 style: group AI assistant settings in system page 2026-04-25 10:48:55 +08:00
jxxghp
e043669a10 更新 package.json 2026-04-24 20:25:09 +08:00
jxxghp
78d8fdba9d fix: restore setup agent grid layout 2026-04-24 20:20:36 +08:00
jxxghp
5c0f0386a6 chore: reorder llm thinking setting 2026-04-24 20:17:22 +08:00
jxxghp
30b39283b6 feat: add unified llm thinking level setting 2026-04-24 19:50:23 +08:00
jxxghp
de84c39d2f fix card action hit areas 2026-04-22 18:01:26 +08:00
jxxghp
65152e7e37 fix: 修复媒体服务器卡片关闭按钮被背景图遮挡的问题 2026-04-22 17:07:41 +08:00
jxxghp
ba343ce5fa style: adjust login page layout spacing and formatting for improved visual alignment 2026-04-21 22:20:04 +08:00
jxxghp
60495668a6 Simplify LLM settings connectivity test 2026-04-21 22:14:19 +08:00
jxxghp
f2ac624dbb fix: refine ai assistant settings feedback 2026-04-21 21:25:21 +08:00
jxxghp
6238849d3f refactor: optimize ai assistant settings actions 2026-04-21 21:11:29 +08:00
笨笨
82cb903c1f fix: localize llm test result labels 2026-04-21 20:41:39 +08:00
笨笨
5e5eb95b55 feat: add llm test button 2026-04-21 20:41:39 +08:00
jxxghp
74e6f8b03e bump version to 2.10.3 2026-04-21 08:55:50 +08:00
jxxghp
a2bf0d2b16 refine settings card layouts 2026-04-21 08:50:14 +08:00
jxxghp
7532d39978 style: update plugin repository icon and increase compact FAB icon size 2026-04-19 14:45:35 +08:00
jxxghp
5cc9bf7418 style: reduce compact-fab size and standardize padding across filter menus 2026-04-19 13:35:25 +08:00
jxxghp
20bdb940cd refactor: standardize floating action buttons with a compact stack layout and migrate menu items to key-based i18n resolution 2026-04-19 13:00:04 +08:00
jxxghp
e9b214cff8 refactor: enhance dynamic button system to support menus, reactive properties, and improved PWA floating action button integration 2026-04-19 12:29:02 +08:00
jxxghp
54f5fb2877 更新 login.vue 2026-04-19 07:21:25 +08:00
jxxghp
e86cb9e1cc Merge pull request #461 from InfinityPacer/codex/feat/local-plugin-paths 2026-04-19 07:07:55 +08:00
InfinityPacer
3f258b9016 fix(plugin): resort market list when statistics load 2026-04-19 04:21:00 +08:00
InfinityPacer
b54e144d0e feat(plugin): show local repo paths in repository filter 2026-04-19 04:20:49 +08:00
InfinityPacer
7b20a7b775 refactor(setting): rename local repo paths setting 2026-04-19 03:03:22 +08:00
InfinityPacer
df66b3e917 fix(plugin): local source label and detection 2026-04-19 02:54:09 +08:00
jxxghp
a919622d08 Document nettest target loading flow 2026-04-18 17:52:01 +08:00
jxxghp
2a9ce950b7 Use backend-managed nettest targets 2026-04-18 17:43:38 +08:00
InfinityPacer
48c12b765d feat(setting): expose local plugin paths 2026-04-18 03:11:55 +08:00
InfinityPacer
1120055eed feat(plugin): support local plugin sources 2026-04-18 03:01:16 +08:00
jxxghp
c66b6649e2 feat: enable drag sorting for plugin market repos 2026-04-17 21:01:33 +08:00
jxxghp
8479099926 fix: simplify plugin market repo display 2026-04-17 20:42:11 +08:00
jxxghp
cab65be1c9 feat: update plugin market settings UI layout and refine localization strings 2026-04-17 15:25:36 +08:00
jxxghp
6689e976c2 更新 package.json 2026-04-17 15:12:00 +08:00
jxxghp
712dfa3fe1 feat: improve transfer history footer actions and plugin market settings 2026-04-17 15:02:56 +08:00
jxxghp
346121f3c2 chore: bump version to 2.10.1 2026-04-16 19:51:02 +08:00
jxxghp
61c073ad6c refactor: remove recognize source selection and hardcode to themoviedb in setup wizard 2026-04-16 19:50:37 +08:00
jxxghp
4b3733bc19 feat: add site auth step to setup wizard 2026-04-16 19:21:17 +08:00
jxxghp
b29c6bd83f feat: add AI agent configuration step and expand basic settings with OCR and recognition source options 2026-04-16 17:36:27 +08:00
jxxghp
b40fc4bd30 更新 package.json 2026-04-16 10:42:02 +08:00
49 changed files with 5710 additions and 1005 deletions

View File

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

View File

@@ -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" />

View File

@@ -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')

View File

@@ -5,6 +5,8 @@ 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'
@@ -33,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 = {
// 压缩包
@@ -123,6 +128,18 @@ 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)
@@ -254,12 +271,14 @@ function stopDrag() {
<div class="mx-auto" :loading="loading > 0">
<div v-if="item">
<FileToolbar
ref="toolbarRef"
:sort="sort"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:endpoints="endpoints"
:axios="axios"
:show-new-folder-button="!showFloatingNewFolderAction"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@@ -301,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>

View File

@@ -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

View File

@@ -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>

View File

@@ -252,18 +252,19 @@ onUnmounted(() => {
<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
@@ -271,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>
@@ -342,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"
@@ -358,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"

View File

@@ -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

View File

@@ -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

View File

@@ -199,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" class="mt-8 me-3 max-h-12" 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>

View File

@@ -153,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>

View File

@@ -118,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 += '/'

View File

@@ -566,13 +566,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>
@@ -588,7 +588,7 @@ watch(
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>

View File

@@ -216,11 +216,17 @@ onMounted(() => {
<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': 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,11 +237,11 @@ 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 class="ml-auto flex shrink-0 items-center gap-2">
<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>
@@ -254,7 +260,7 @@ onMounted(() => {
<!-- 中间部分网址 -->
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>

View File

@@ -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>

View File

@@ -30,6 +30,10 @@ const inProps = defineProps({
type: String,
default: 'name',
},
showNewFolderButton: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -109,11 +113,20 @@ async function mkdir() {
emit('foldercreated')
}
function openNewFolderDialog() {
newFolderName.value = ''
newFolderPopper.value = true
}
// 计算排序图标
const sortIcon = computed(() => {
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending'
})
defineExpose({
openNewFolderDialog,
})
</script>
<template>
@@ -165,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>

View File

@@ -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 })
}
// 返回控制函数和状态

View File

@@ -0,0 +1,380 @@
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 {
label: string
value: string
}
export interface LlmProviderUrlPresetItem {
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>
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 => ({
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 || ''
if (reset) {
options.baseUrl.value = defaultBaseUrl
return
}
if (!currentBaseUrl && defaultBaseUrl) {
options.baseUrl.value = defaultBaseUrl
}
}
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,
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,
authDialogVisible,
authPolling,
authPopupBlocked,
authSession,
handleProviderSelection,
applyModelMetadata,
loadProviders,
loadModels,
openAuthPage,
startAuth,
pollAuthSession,
disconnectAuth,
closeAuthDialog,
}
}

View File

@@ -13,9 +13,16 @@ export interface WizardData {
username: string
password: string
confirmPassword: string
recognizeSource: string
ocrHost: string
proxyHost: string
githubToken: string
}
siteAuth: {
auxiliaryAuthEnable: boolean
site: string
params: Record<string, string | number>
}
storage: {
downloadPath: string
libraryPath: string
@@ -41,6 +48,32 @@ export interface WizardData {
config: any
switchs: any[]
}
agent: {
enabled: boolean
global: boolean
verbose: boolean
provider: string
authConnected: boolean
model: string
thinkingLevel: string
supportImageInput: boolean
supportAudioInputOutput: boolean
apiKey: string
baseUrl: string
maxContextTokens: number
voiceApiKey: string
voiceBaseUrl: string
voiceSttModel: string
voiceTtsModel: string
voiceTtsVoice: string
voiceLanguage: string
voiceReplyWithText: boolean
jobInterval: number
retryTransfer: boolean
recommendEnabled: boolean
recommendUserPreference: string
recommendMaxItems: number
}
preferences: {
quality: string
subtitle: string
@@ -67,9 +100,14 @@ export interface ConnectivityTestState {
}
export interface ValidationErrorState {
siteAuth: {
site: boolean
[key: string]: boolean
}
downloader: {
name: boolean
host: boolean
apikey: boolean
username: boolean
password: boolean
}
@@ -85,11 +123,45 @@ export interface ValidationErrorState {
name: boolean
[key: string]: boolean
}
agent: {
provider: boolean
apiKey: boolean
model: boolean
maxContextTokens: boolean
recommendMaxItems: boolean
}
}
function normalizeThinkingLevelValue(value?: unknown) {
const normalized = String(value ?? '').trim().toLowerCase()
if (!normalized) return ''
const aliasMap: Record<string, string> = {
none: 'off',
disabled: 'off',
disable: 'off',
enabled: 'auto',
enable: 'auto',
default: 'auto',
dynamic: 'auto',
}
return aliasMap[normalized] || normalized
}
function resolveThinkingLevelValue(data?: Record<string, any>) {
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
if (explicit) return explicit
const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)
if (data?.LLM_DISABLE_THINKING === true) return 'off'
if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'
return legacyEffort || 'off'
}
// 全局状态,所有组件共享
const currentStep = ref(1)
const totalSteps = 6
const totalSteps = 8
// 加载状态
const isLoading = ref(false)
@@ -97,6 +169,22 @@ const isLoading = ref(false)
// 选中的预设规则
const selectedPreset = ref('')
// 可认证站点列表
const authSites = ref<{
[key: string]: {
name: string
icon: string
params: {
[key: string]: {
name: string
type: string
placeholder?: string
tooltip?: string
}
}
}
}>({})
// 向导数据
const wizardData = ref<WizardData>({
basic: {
@@ -105,9 +193,16 @@ const wizardData = ref<WizardData>({
username: '',
password: '',
confirmPassword: '',
recognizeSource: 'themoviedb',
ocrHost: '',
proxyHost: '',
githubToken: '',
},
siteAuth: {
auxiliaryAuthEnable: false,
site: '',
params: {},
},
storage: {
downloadPath: '',
libraryPath: '',
@@ -133,6 +228,32 @@ const wizardData = ref<WizardData>({
config: {},
switchs: [],
},
agent: {
enabled: false,
global: false,
verbose: false,
provider: 'deepseek',
authConnected: false,
model: 'deepseek-chat',
thinkingLevel: 'off',
supportImageInput: true,
supportAudioInputOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
maxContextTokens: 64,
voiceApiKey: '',
voiceBaseUrl: '',
voiceSttModel: 'gpt-4o-mini-transcribe',
voiceTtsModel: 'gpt-4o-mini-tts',
voiceTtsVoice: 'alloy',
voiceLanguage: 'zh',
voiceReplyWithText: false,
jobInterval: 0,
retryTransfer: false,
recommendEnabled: false,
recommendUserPreference: '',
recommendMaxItems: 50,
},
preferences: {
quality: '4K',
subtitle: 'chinese',
@@ -151,9 +272,13 @@ const connectivityTest = ref<ConnectivityTestState>({
// 验证错误状态
const validationErrors = ref<ValidationErrorState>({
siteAuth: {
site: false,
},
downloader: {
name: false,
host: false,
apikey: false,
username: false,
password: false,
},
@@ -168,6 +293,13 @@ const validationErrors = ref<ValidationErrorState>({
notification: {
name: false,
},
agent: {
provider: false,
apiKey: false,
model: false,
maxContextTokens: false,
recommendMaxItems: false,
},
})
export function useSetupWizard() {
@@ -181,6 +313,7 @@ export function useSetupWizard() {
downloader: {
'qbittorrent': 'QbittorrentModule',
'transmission': 'TransmissionModule',
'rtorrent': 'RtorrentModule',
},
// 媒体服务器映射
mediaServer: {
@@ -196,6 +329,7 @@ export function useSetupWizard() {
'wechat': 'WechatModule',
'slack': 'SlackModule',
'synologychat': 'SynologyChatModule',
'qqbot': 'QQBotModule',
'vocechat': 'VoceChatModule',
'webpush': 'WebPushModule',
},
@@ -204,20 +338,24 @@ export function useSetupWizard() {
// 步骤标题
const stepTitles = computed(() => [
t('setupWizard.basic.title'),
t('setupWizard.siteAuth.title'),
t('setupWizard.storage.title'),
t('setupWizard.downloader.title'),
t('setupWizard.mediaServer.title'),
t('setupWizard.notification.title'),
t('setupWizard.agent.title'),
t('setupWizard.preferences.title'),
])
// 步骤描述
const stepDescriptions = computed(() => [
t('setupWizard.basic.description'),
t('setupWizard.siteAuth.description'),
t('setupWizard.storage.description'),
t('setupWizard.downloader.description'),
t('setupWizard.mediaServer.description'),
t('setupWizard.notification.description'),
t('setupWizard.agent.description'),
t('setupWizard.preferences.description'),
])
@@ -324,9 +462,13 @@ export function useSetupWizard() {
// 清除验证错误状态
function clearValidationErrors() {
validationErrors.value.siteAuth = {
site: false,
}
validationErrors.value.downloader = {
name: false,
host: false,
apikey: false,
username: false,
password: false,
}
@@ -341,6 +483,54 @@ export function useSetupWizard() {
validationErrors.value.notification = {
name: false,
}
validationErrors.value.agent = {
provider: false,
apiKey: false,
model: false,
maxContextTokens: false,
recommendMaxItems: false,
}
}
// 验证用户站点认证字段
function validateSiteAuthFields(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
clearValidationErrors()
if (!wizardData.value.siteAuth.site) {
return {
isValid: true,
errors,
}
}
const selectedSite = authSites.value[wizardData.value.siteAuth.site]
if (!selectedSite) {
errors.push(t('setupWizard.siteAuth.siteConfigNotExist'))
validationErrors.value.siteAuth.site = true
return {
isValid: false,
errors,
}
}
const fields = Object.keys(selectedSite.params || {}).filter(key => {
return selectedSite.params[key]?.name && selectedSite.params[key]?.type
})
fields.forEach(key => {
const fieldKey = `${wizardData.value.siteAuth.site.toUpperCase()}_${key.toUpperCase()}`
const value = wizardData.value.siteAuth.params[fieldKey]
if (value === undefined || value === null || value === '') {
errors.push(t('setupWizard.siteAuth.fieldRequired', { name: selectedSite.params[key].name }))
validationErrors.value.siteAuth[fieldKey] = true
}
})
return {
isValid: errors.length === 0,
errors,
}
}
// 验证下载器字段
@@ -361,7 +551,20 @@ export function useSetupWizard() {
}
// 根据下载器类型验证其他必输项
if (wizardData.value.downloader.type === 'qbittorrent' || wizardData.value.downloader.type === 'transmission') {
if (wizardData.value.downloader.type === 'qbittorrent') {
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
}
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
errors.push(t('downloader.passwordRequired'))
validationErrors.value.downloader.password = true
}
} else if (
wizardData.value.downloader.type === 'transmission'
|| wizardData.value.downloader.type === 'rtorrent'
) {
if (!wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
@@ -487,6 +690,12 @@ export function useSetupWizard() {
validationErrors.value.notification.VOCECHAT_API_KEY = true
}
break
case 'webpush':
if (!config.WEBPUSH_USERNAME?.trim()) {
errors.push(t('notification.webpush.usernameRequired'))
validationErrors.value.notification.WEBPUSH_USERNAME = true
}
break
case 'qqbot':
if (!config.QQ_APP_ID?.trim()) {
errors.push(t('notification.qqbot.appIdRequired'))
@@ -505,6 +714,49 @@ export function useSetupWizard() {
}
}
// 验证智能助手字段
function validateAgentFields(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
clearValidationErrors()
if (!wizardData.value.agent.enabled) {
return {
isValid: true,
errors,
}
}
if (!wizardData.value.agent.provider?.trim()) {
errors.push(t('setupWizard.agent.providerRequired'))
validationErrors.value.agent.provider = true
}
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
validationErrors.value.agent.apiKey = true
}
if (!wizardData.value.agent.model?.trim()) {
errors.push(t('setupWizard.agent.modelRequired'))
validationErrors.value.agent.model = true
}
if (!wizardData.value.agent.maxContextTokens || wizardData.value.agent.maxContextTokens < 1) {
errors.push(t('setupWizard.agent.maxContextTokensRequired'))
validationErrors.value.agent.maxContextTokens = true
}
if (wizardData.value.agent.recommendEnabled && (!wizardData.value.agent.recommendMaxItems || wizardData.value.agent.recommendMaxItems < 1)) {
errors.push(t('setupWizard.agent.recommendMaxItemsRequired'))
validationErrors.value.agent.recommendMaxItems = true
}
return {
isValid: errors.length === 0,
errors,
}
}
// 验证当前步骤的必输项
function validateCurrentStep(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
@@ -531,6 +783,13 @@ export function useSetupWizard() {
break
case 2: // 存储设置
if (wizardData.value.siteAuth.site) {
const validation = validateSiteAuthFields()
errors.push(...validation.errors)
}
break
case 3: // 存储设置
if (!wizardData.value.storage.downloadPath) {
errors.push(t('setupWizard.storage.downloadPathRequired'))
}
@@ -539,7 +798,7 @@ export function useSetupWizard() {
}
break
case 3: // 下载器设置
case 4: // 下载器设置
if (wizardData.value.downloader.type) {
// 如果选择了下载器,则验证必输项
const validation = validateDownloaderFields()
@@ -547,7 +806,7 @@ export function useSetupWizard() {
}
break
case 4: // 媒体服务器设置
case 5: // 媒体服务器设置
if (wizardData.value.mediaServer.type) {
// 如果选择了媒体服务器,则验证必输项
const validation = validateMediaServerFields()
@@ -555,7 +814,7 @@ export function useSetupWizard() {
}
break
case 5: // 通知设置
case 6: // 通知设置
if (wizardData.value.notification.type) {
// 如果选择了通知,则验证必输项
const validation = validateNotificationFields()
@@ -563,7 +822,14 @@ export function useSetupWizard() {
}
break
case 6: // 偏好设置
case 7: // 智能助手设置
if (wizardData.value.agent.enabled) {
const validation = validateAgentFields()
errors.push(...validation.errors)
}
break
case 8: // 偏好设置
// 偏好设置有默认值,不需要验证
break
}
@@ -578,12 +844,14 @@ export function useSetupWizard() {
function shouldPerformTest(step: number): boolean {
switch (step) {
case 2: // 存储目录测试 - 总是需要测试
return false
case 3: // 存储目录测试 - 总是需要测试
return true
case 3: // 下载器测试 - 只有选择了下载器才测试
case 4: // 下载器测试 - 只有选择了下载器才测试
return !!wizardData.value.downloader.type
case 4: // 媒体服务器测试 - 只有选择了媒体服务器才测试
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
return !!wizardData.value.mediaServer.type
case 5: // 消息通知测试 - 只有选择了通知才测试
case 6: // 消息通知测试 - 只有选择了通知才测试
return !!wizardData.value.notification.type
default:
return false
@@ -603,15 +871,17 @@ export function useSetupWizard() {
switch (step) {
case 2: // 存储目录测试
break
case 3: // 存储目录测试
testResult = await testStorageConnectivity()
break
case 3: // 下载器测试
case 4: // 下载器测试
testResult = await testDownloaderConnectivity()
break
case 4: // 媒体服务器测试
case 5: // 媒体服务器测试
testResult = await testMediaServerConnectivity()
break
case 5: // 消息通知测试
case 6: // 消息通知测试
testResult = await testNotificationConnectivity()
break
}
@@ -794,18 +1064,21 @@ export function useSetupWizard() {
validation.errors.forEach(error => {
$toast.error(error)
})
return
return false
}
// 保存当前步骤的设置
await saveCurrentStepSettings()
const saved = await saveCurrentStepSettings()
if (!saved) {
return false
}
// 检查是否需要进行测试
const needsTest = shouldPerformTest(currentStep.value)
if (needsTest) {
const testResult = await testConnectivity(currentStep.value)
if (!testResult) {
return
return false
}
}
@@ -814,6 +1087,8 @@ export function useSetupWizard() {
currentStep.value++
connectivityTest.value.showResult = false
}
return true
}
// 上一步
@@ -829,35 +1104,38 @@ export function useSetupWizard() {
try {
switch (currentStep.value) {
case 1:
await saveBasicSettings()
break
return await saveBasicSettings()
case 2:
await saveStorageSettings()
break
return await saveSiteAuthSettings()
case 3:
await saveDownloaderSettings()
break
return await saveStorageSettings()
case 4:
await saveMediaServerSettings()
break
return await saveDownloaderSettings()
case 5:
await saveNotificationSettings()
break
return await saveMediaServerSettings()
case 6:
await savePreferenceSettings()
break
return await saveNotificationSettings()
case 7:
return await saveAgentSettings()
case 8:
return await savePreferenceSettings()
}
} catch (error) {
console.error('Save current step settings failed:', error)
$toast.error(t('setupWizard.saveStepFailed'))
return false
}
return true
}
// 完成向导
async function completeWizard() {
try {
// 先处理下一步(保存当前步骤设置)
await nextStep()
const saved = await nextStep()
if (!saved) {
return
}
// 保存设置向导完成状态
await saveSetupWizardState()
@@ -910,6 +1188,8 @@ export function useSetupWizard() {
const basicSettings = {
APP_DOMAIN: wizardData.value.basic.appDomain,
API_TOKEN: wizardData.value.basic.apiToken,
RECOGNIZE_SOURCE: 'themoviedb',
OCR_HOST: wizardData.value.basic.ocrHost,
PROXY_HOST: wizardData.value.basic.proxyHost,
GITHUB_TOKEN: wizardData.value.basic.githubToken,
}
@@ -917,21 +1197,23 @@ export function useSetupWizard() {
// 保存基础设置
const response: { [key: string]: any } = await api.post('system/env', basicSettings)
if (!response.success) {
return
return false
}
// 如果输入了密码,验证密码一致性
if (wizardData.value.basic.password) {
if (wizardData.value.basic.password !== wizardData.value.basic.confirmPassword) {
$toast.error(t('dialog.userAddEdit.passwordMismatch'))
return
return false
}
// 更新用户密码
await updateUserPassword()
}
return true
} catch (error) {
console.error('Save basic settings failed:', error)
$toast.error(t('setupWizard.saveBasicSettingsFailed'))
return false
}
}
@@ -970,9 +1252,44 @@ export function useSetupWizard() {
}
await api.post('system/setting/Directories', [directory])
return true
} catch (error) {
console.error('Save storage settings failed:', error)
$toast.error(t('setupWizard.saveStorageSettingsFailed'))
return false
}
}
// 保存用户站点认证设置
async function saveSiteAuthSettings() {
try {
const envResponse: { [key: string]: any } = await api.post('system/env', {
AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable,
})
if (!envResponse.success) {
return false
}
if (!wizardData.value.siteAuth.site) {
return true
}
const response: { [key: string]: any } = await api.post('site/auth', {
site: wizardData.value.siteAuth.site,
params: wizardData.value.siteAuth.params,
})
if (!response.success) {
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))
return false
}
return true
} catch (error) {
console.error('Save site auth settings failed:', error)
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: (error as Error).message || '' }))
return false
}
}
@@ -992,13 +1309,16 @@ export function useSetupWizard() {
}
await api.post('system/setting/Downloaders', [downloader])
return true
} catch (error) {
console.error('Save downloader settings failed:', error)
$toast.error(t('setupWizard.saveDownloaderSettingsFailed'))
return false
}
} else {
// 没有选择下载器时,清空现有配置
console.log('No downloader selected, skipping save')
return true
}
}
@@ -1019,13 +1339,16 @@ export function useSetupWizard() {
}
await api.post('system/setting/MediaServers', [mediaServer])
return true
} catch (error) {
console.error('Save media server settings failed:', error)
$toast.error(t('setupWizard.saveMediaServerSettingsFailed'))
return false
}
} else {
// 没有选择媒体服务器时,清空现有配置
console.log('No media server selected, skipping save')
return true
}
}
@@ -1046,13 +1369,55 @@ export function useSetupWizard() {
}
await api.post('system/setting/Notifications', [notification])
return true
} catch (error) {
console.error('Save notification settings failed:', error)
$toast.error(t('setupWizard.saveNotificationSettingsFailed'))
return false
}
} else {
// 没有选择通知时,清空现有配置
console.log('No notification selected, skipping save')
return true
}
}
// 保存智能助手设置
async function saveAgentSettings() {
try {
const agentSettings = {
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
AI_AGENT_VERBOSE: wizardData.value.agent.enabled ? wizardData.value.agent.verbose : false,
LLM_PROVIDER: wizardData.value.agent.provider,
LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
AI_RECOMMEND_ENABLED:
wizardData.value.agent.enabled && wizardData.value.agent.recommendEnabled,
AI_RECOMMEND_USER_PREFERENCE: wizardData.value.agent.recommendUserPreference,
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
}
await api.post('system/env', agentSettings)
return true
} catch (error) {
console.error('Save agent settings failed:', error)
$toast.error(t('setupWizard.saveAgentSettingsFailed'))
return false
}
}
@@ -1081,9 +1446,11 @@ export function useSetupWizard() {
console.error('Save rule sequences failed:', error)
}
}
return true
} catch (error) {
console.error('Save preference settings failed:', error)
$toast.error(t('setupWizard.savePreferenceSettingsFailed'))
return false
}
}
@@ -1115,12 +1482,40 @@ export function useSetupWizard() {
if (result.data.PROXY_HOST) {
wizardData.value.basic.proxyHost = result.data.PROXY_HOST
}
if (result.data.OCR_HOST) {
wizardData.value.basic.ocrHost = result.data.OCR_HOST
}
if (result.data.GITHUB_TOKEN) {
wizardData.value.basic.githubToken = result.data.GITHUB_TOKEN
}
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.data.AUXILIARY_AUTH_ENABLE)
if (result.data.SUPERUSER) {
wizardData.value.basic.username = result.data.SUPERUSER
}
wizardData.value.agent.enabled = Boolean(result.data.AI_AGENT_ENABLE)
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
wizardData.value.agent.authConnected = false
wizardData.value.agent.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)
wizardData.value.agent.recommendUserPreference = result.data.AI_RECOMMEND_USER_PREFERENCE || ''
wizardData.value.agent.recommendMaxItems = result.data.AI_RECOMMEND_MAX_ITEMS || 50
// 如果没有API Token则创建一个随机的
if (!wizardData.value.basic.apiToken) {
@@ -1132,6 +1527,28 @@ export function useSetupWizard() {
}
}
// 加载用户站点认证列表
async function loadAuthSites() {
try {
authSites.value = (await api.get('site/auth')) || {}
} catch (error) {
console.log('Load auth sites failed:', error)
}
}
// 加载用户站点认证设置
async function loadSiteAuthSettings() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserSiteAuthParams')
if (result.success && result.data?.value) {
wizardData.value.siteAuth.site = result.data.value.site || ''
wizardData.value.siteAuth.params = result.data.value.params || {}
}
} catch (error) {
console.log('Load site auth settings failed:', error)
}
}
// 加载存储设置
async function loadStorageSettings() {
try {
@@ -1201,6 +1618,8 @@ export function useSetupWizard() {
isLoading.value = true
try {
await loadSystemSettings()
await loadAuthSites()
await loadSiteAuthSettings()
await loadStorageSettings()
await loadDownloaderSettings()
await loadMediaServerSettings()
@@ -1217,6 +1636,7 @@ export function useSetupWizard() {
stepTitles,
stepDescriptions,
wizardData,
authSites,
selectedPreset,
connectivityTest,
validationErrors,
@@ -1231,9 +1651,11 @@ export function useSetupWizard() {
selectPreset,
updatePreferences,
validateCurrentStep,
validateSiteAuthFields,
validateDownloaderFields,
validateMediaServerFields,
validateNotificationFields,
validateAgentFields,
clearValidationErrors,
testConnectivity,
nextStep,

View File

@@ -6,6 +6,7 @@ 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({
@@ -120,6 +121,7 @@ interface DynamicButton {
action: () => void
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: DynamicButtonMenuItem[]
}
// 提供动态按钮注册和获取的方法
@@ -141,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(() => {
@@ -153,6 +157,7 @@ onUnmounted(() => {
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__
delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__
}
})
@@ -165,6 +170,30 @@ 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>
@@ -223,16 +252,37 @@ const showDynamicButton = computed(() => {
>
<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 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>

View File

@@ -14,6 +14,12 @@ 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()
@@ -63,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>()
@@ -153,9 +159,7 @@ async function openMessageDialog() {
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
messageViewRef.value.resumeSSE()
}
messageViewRef.value?.resumeSSE?.()
})
}
@@ -203,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
}
}
@@ -228,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()
}
@@ -350,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>
@@ -372,7 +383,7 @@ onMounted(() => {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>

View File

@@ -460,7 +460,8 @@ export default {
botSecret: 'Bot Secret',
botSecretHint: 'WebSocket secret of the WeChat Work AI bot',
botChatId: 'Default Target',
botChatIdHint: 'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdHint:
'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdPlaceholder: 'userid or group:chatid',
botWsUrl: 'WebSocket URL',
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
@@ -997,6 +998,7 @@ export default {
aiRecommend: 'AI Recommendation',
reRecommend: 'Regenerate Recommendation',
aiRecommendError: 'AI Recommendation Failed',
refreshSearch: 'Re-search',
},
browse: {
actor: 'Actor',
@@ -1232,6 +1234,17 @@ export default {
content: 'Content',
refreshing: 'Refreshing',
initializing: 'Initializing',
searchPlaceholder: 'Search logs',
allLevels: 'All Levels',
followTail: 'Follow latest logs',
wrapLines: 'Wrap lines',
pauseStream: 'Pause stream',
resumeStream: 'Resume stream',
waitingForLogs: 'Waiting for logs...',
paused: 'Paused',
connected: 'Live',
lineCount: 'Showing {visible}/{total} lines',
jumpToLatest: 'Jump to latest ({count})',
},
moduleTest: {
normal: 'Normal',
@@ -1322,10 +1335,31 @@ export default {
aiAgent: 'Enable AI Assistant',
aiAgentEnable: 'Enable AI Assistant',
aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',
aiAgentSectionTitle: 'AI Assistant Configuration',
aiAgentSectionDesc:
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
llmProvider: 'LLM Provider',
llmProviderHint: 'Select the LLM service provider to use',
llmModel: 'LLM Model Name',
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
llmModelHint: 'Specify the LLM model to use, such as deepseek-v4-flash, gpt-5.4, etc.',
llmModelResolvedHint: 'Max context has been auto-filled to {context}K from the model catalog. Source: {source}',
llmThinking: 'Thinking Mode / Depth',
llmThinkingHint:
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
llmThinkingLevelOff: 'Off (off)',
llmThinkingLevelAuto: 'Auto (auto)',
llmThinkingLevelMinimal: 'Minimal (minimal)',
llmThinkingLevelLow: 'Low (low)',
llmThinkingLevelMedium: 'Medium (medium)',
llmThinkingLevelHigh: 'High (high)',
llmThinkingLevelMax: 'Max (max)',
llmThinkingLevelXhigh: 'XHigh (xhigh)',
llmSupportImageInput: 'Model Supports Image Input',
llmSupportImageInputHint:
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
llmSupportAudioInputOutput: 'Support Audio Input and Output',
llmSupportAudioInputOutputHint:
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
@@ -1334,6 +1368,39 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
llmProviderConnectedAs: 'Connected as: {label}',
llmProviderDisconnect: 'Disconnect Authorization',
llmProviderDisconnected: 'Provider authorization disconnected',
llmProviderAuthDialogTitle: 'Provider Authorization',
llmProviderPopupBlocked:
'The browser blocked the authorization popup. Use the button below to continue manually.',
llmProviderDeviceCode: 'Device Code',
llmProviderOpenAuthPage: 'Open Authorization Page',
llmProviderCheckAuthStatus: 'Check Authorization Status',
aiVoiceApiKey: 'Audio API Key',
aiVoiceApiKeyHint:
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
aiVoiceBaseUrl: 'Audio Base URL',
aiVoiceBaseUrlHint:
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
aiVoiceSttModel: 'Audio Transcription Model',
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
aiVoiceTtsModel: 'Speech Synthesis Model',
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
aiVoiceTtsVoice: 'Voice Preset',
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
aiVoiceLanguage: 'Recognition Language',
aiVoiceLanguageHint:
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
aiVoiceReplyWithText: 'Include Text with Voice Replies',
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
llmTestAction: 'Test Call',
llmTestSuccessToast: 'LLM test call succeeded',
llmTestFailedToast: 'LLM test call failed',
llmTestFailedToastWithMessage: 'LLM test call failed: {message}',
aiAgentGlobal: 'Global AI Assistant',
aiAgentGlobalHint:
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
@@ -1434,8 +1501,9 @@ export default {
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
recognizePluginFirst: "Prioritize Plugin Recognition",
recognizePluginFirstHint: "Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped",
recognizePluginFirst: 'Prioritize Plugin Recognition',
recognizePluginFirstHint:
'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped',
githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -1467,6 +1535,9 @@ export default {
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
pluginAutoReload: 'Plugin Hot Reload',
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
pluginLocalRepoPathsHint:
'Local plugin repository directories. Separate multiple directories with commas. Relative and absolute paths are supported.',
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
@@ -1554,7 +1625,7 @@ export default {
skipDesc: 'Skip scraping, this file will not be generated',
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
overwriteDesc: 'Always scrape, existing file will be overwritten',
}
},
},
site: {
siteSync: 'Site Synchronization',
@@ -2248,6 +2319,10 @@ export default {
repoUrl: 'Plugin Repository URL',
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
urlPlaceholder: 'Enter plugin repository URL',
noRepos: 'No plugin repository URLs',
invalidUrl: 'Please enter a valid URL',
duplicateUrl: 'This URL already exists',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
@@ -2598,6 +2673,7 @@ export default {
settings: 'Settings',
projectHome: 'Project Home',
updateHistory: 'Update History',
local: 'Local',
installToLocal: 'Install to Local',
totalDownloads: 'Total {count} downloads',
viewData: 'View Data',
@@ -2793,9 +2869,13 @@ export default {
actions: {
aiRedo: 'Assistant Organize',
aiRedoPending: 'Assistant Organizing...',
batchAiRedo: 'Assistant Batch Organize',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',
batchDelete: 'Batch Delete',
},
batchOperationTitle: 'Batch Operation',
progress: {
processing: 'Processing',
pleaseWait: 'Please wait...',
@@ -2853,8 +2933,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default',
host: 'Host',
apiKey: 'API Key',
username: 'Username',
password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management',
sequentail: 'Sequential Download',
force_resume: 'Force Resume',
@@ -3183,6 +3265,8 @@ export default {
saveMediaServerSettingsFailed: 'Failed to save media server settings',
notificationSettingsSaved: 'Notification settings saved successfully',
saveNotificationSettingsFailed: 'Failed to save notification settings',
saveSiteAuthSettingsFailed: 'Failed to save user site authentication settings: {message}',
saveAgentSettingsFailed: 'Failed to save AI assistant settings',
preferenceSettingsSaved: 'Preference settings saved successfully',
savePreferenceSettingsFailed: 'Failed to save preference settings',
passwordUpdateSuccess: 'Password updated successfully',
@@ -3204,6 +3288,18 @@ export default {
confirmPasswordHint: 'Confirm new password',
apiTokenRequired: 'API Token is required',
},
siteAuth: {
title: 'User Authentication',
description: 'Configure site authentication and auxiliary authentication',
info: 'User Site Authentication',
infoDesc:
'Completing site authentication unlocks site capabilities and some plugin permissions. This step is optional and can also be configured later from the user menu.',
selectSiteHint: 'Choose a supported auth site and fill in the required credentials for that site',
submitHint:
'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
siteConfigNotExist: 'Authentication site configuration does not exist',
fieldRequired: 'Please enter {name}',
},
storage: {
title: 'Storage',
description: 'Configure download directory and media library directory',
@@ -3268,6 +3364,19 @@ export default {
senderPassword: 'Sender Password',
receiverEmail: 'Receiver Email',
},
agent: {
title: 'AI Assistant',
description: 'Configure the Agent assistant and LLM parameters',
info: 'AI Assistant Configuration',
infoDesc:
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
providerRequired: 'LLM provider is required',
apiKeyRequired: 'LLM API key is required',
authOrApiKeyRequired: 'Provide an LLM API key or complete provider authorization first',
modelRequired: 'LLM model name is required',
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
},
preferences: {
title: 'Resource Preferences',
description: 'Set resource download preferences',

View File

@@ -993,6 +993,7 @@ export default {
aiRecommend: '智能推荐',
reRecommend: '重新生成推荐',
aiRecommendError: '智能推荐失败',
refreshSearch: '重新搜索',
},
browse: {
actor: '演员',
@@ -1228,6 +1229,17 @@ export default {
content: '内容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日志内容',
allLevels: '全部级别',
followTail: '跟随最新日志',
wrapLines: '自动换行',
pauseStream: '暂停日志流',
resumeStream: '恢复日志流',
waitingForLogs: '等待日志输出...',
paused: '已暂停',
connected: '实时更新中',
lineCount: '显示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1317,13 +1329,30 @@ export default {
aiAgent: '启用智能助手',
aiAgentEnable: '启用智能助手',
aiAgentEnableHint: '启用后可使用智能助手功能需要配置LLM相关参数',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
llmProvider: 'LLM提供商',
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
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 限制',
@@ -1332,6 +1361,34 @@ export default {
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: '定时唤醒',
@@ -1459,6 +1516,8 @@ export default {
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
pluginAutoReload: '插件热加载',
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
pluginLocalRepoPaths: '本地插件仓库路径',
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
transferThreads: '文件整理线程数',
@@ -2219,6 +2278,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: '插件仓库保存成功',
@@ -2569,6 +2632,7 @@ export default {
settings: '设置',
projectHome: '项目主页',
updateHistory: '更新说明',
local: '本地',
installToLocal: '安装到本地',
totalDownloads: '共 {count} 次下载',
viewData: '查看数据',
@@ -2758,9 +2822,13 @@ export default {
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',
batchDelete: '批量删除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '处理中',
pleaseWait: '请稍候...',
@@ -2818,8 +2886,10 @@ export default {
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: '强制继续',
@@ -3147,6 +3217,8 @@ export default {
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
notificationSettingsSaved: '通知设置保存成功',
saveNotificationSettingsFailed: '保存通知设置失败',
saveSiteAuthSettingsFailed: '保存用户站点认证设置失败:{message}',
saveAgentSettingsFailed: '保存智能助手设置失败',
preferenceSettingsSaved: '偏好设置保存成功',
savePreferenceSettingsFailed: '保存偏好设置失败',
passwordUpdateSuccess: '密码更新成功',
@@ -3168,6 +3240,16 @@ export default {
confirmPasswordHint: '确认新密码',
apiTokenRequired: 'API Token不能为空',
},
siteAuth: {
title: '用户认证',
description: '配置用户站点认证与辅助认证',
info: '用户站点认证说明',
infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选,后续也可在个人菜单中继续配置。',
selectSiteHint: '选择一个支持认证的站点,并填写该站点要求的认证参数',
submitHint: '点击下一步时将立即向认证站点发起校验,认证成功后会保存当前参数。',
siteConfigNotExist: '认证站点配置不存在',
fieldRequired: '请输入{name}',
},
storage: {
title: '存储',
description: '配置下载目录和媒体库目录',
@@ -3231,6 +3313,18 @@ export default {
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: '设置资源下载偏好',
@@ -3273,7 +3367,3 @@ export default {
},
},
}
// Apply patch to add category strings
// This is a temporary placeholder command to show intent.
// I will use replace_file_content to actually edit the file safely.

View File

@@ -316,7 +316,8 @@ export default {
settingTabs: {
system: {
title: '系統',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
description:
'基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -993,6 +994,7 @@ export default {
aiRecommend: '智能推薦',
reRecommend: '重新生成推薦',
aiRecommendError: '智能推薦失敗',
refreshSearch: '重新搜尋',
},
browse: {
actor: '演員',
@@ -1229,6 +1231,17 @@ export default {
content: '內容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日誌內容',
allLevels: '全部級別',
followTail: '跟隨最新日誌',
wrapLines: '自動換行',
pauseStream: '暫停日誌流',
resumeStream: '恢復日誌流',
waitingForLogs: '等待日誌輸出...',
paused: '已暫停',
connected: '實時更新中',
lineCount: '顯示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1318,13 +1331,30 @@ export default {
aiAgent: '啟用智能助手',
aiAgentEnable: '啟用智能助手',
aiAgentEnableHint: '啟用後可使用智能助手功能需要配置LLM相關參數',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
llmProvider: 'LLM提供商',
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
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 限制',
@@ -1333,6 +1363,34 @@ export default {
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: '定時喚醒',
@@ -1428,8 +1486,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的圖片數據',
fanartLang: 'Fanart語言',
fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列',
recognizePluginFirst: "優先使用插件識別",
recognizePluginFirstHint: "優先調用插件識別媒體信息,若插件命中則不再調用原生識別",
recognizePluginFirst: '優先使用插件識別',
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github訪問速度',
@@ -1460,6 +1518,8 @@ export default {
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
pluginAutoReload: '插件熱加載',
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
pluginLocalRepoPaths: '本地插件倉庫路徑',
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
transferThreads: '文件整理線程數',
@@ -1544,7 +1604,7 @@ export default {
skipDesc: '跳過刮削,不生成該文件',
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
overwriteDesc: '始終刮削,已存在則覆蓋',
}
},
},
site: {
siteSync: '站點同步',
@@ -2220,6 +2280,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: '插件倉庫儲存成功',
@@ -2570,6 +2634,7 @@ export default {
settings: '設置',
projectHome: '項目主頁',
updateHistory: '更新說明',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
viewData: '查看數據',
@@ -2759,9 +2824,13 @@ export default {
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '刪除',
batchRedo: '批量重新整理',
batchDelete: '批量刪除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '處理中',
pleaseWait: '請稍候...',
@@ -2819,8 +2888,10 @@ export default {
enabled: '啟用',
default: '預設',
host: '地址',
apiKey: 'API Key',
username: '用戶名',
password: '密碼',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填寫後將優先使用 API Key 登入。',
category: '自動分類管理',
sequentail: '順序下載',
force_resume: '強制繼續',
@@ -3148,6 +3219,8 @@ export default {
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
notificationSettingsSaved: '通知設置保存成功',
saveNotificationSettingsFailed: '保存通知設置失敗',
saveSiteAuthSettingsFailed: '保存用戶站點認證設置失敗:{message}',
saveAgentSettingsFailed: '保存智能助手設置失敗',
preferenceSettingsSaved: '偏好設置保存成功',
savePreferenceSettingsFailed: '保存偏好設置失敗',
passwordUpdateSuccess: '密碼更新成功',
@@ -3169,6 +3242,16 @@ export default {
confirmPasswordHint: '確認新密碼',
apiTokenRequired: 'API Token 不能為空',
},
siteAuth: {
title: '用戶認證',
description: '配置用戶站點認證與輔助認證',
info: '用戶站點認證說明',
infoDesc: '完成站點認證後可解鎖站點能力與部分插件權限。此步驟可選,後續也可在個人選單中繼續配置。',
selectSiteHint: '選擇一個支援認證的站點,並填寫該站點要求的認證參數',
submitHint: '點擊下一步時將立即向認證站點發起校驗,認證成功後會保存當前參數。',
siteConfigNotExist: '認證站點配置不存在',
fieldRequired: '請輸入{name}',
},
storage: {
title: '儲存',
description: '設定下載目錄和媒體庫目錄',
@@ -3232,6 +3315,18 @@ export default {
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: '設定資源下載偏好',

View File

@@ -376,16 +376,15 @@ onDeactivated(() => {
<!-- 底部操作按钮只在非移动设备上显示 -->
<Teleport to="body" v-if="route.path === '/dashboard'">
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<div v-if="!appMode" class="compact-fab-stack">
<VFab
icon="mdi-view-dashboard-edit"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="dialog = true"
/>
</div>
</Teleport>
<!-- 弹窗根据配置生成选项 -->

View File

@@ -234,8 +234,8 @@ async function handlePassKeyAuth(
isConditional && conditionalAbortController
? conditionalAbortController.signal
: !isConditional && manualAbortController
? manualAbortController.signal
: undefined,
? manualAbortController.signal
: undefined,
})
await onSuccess(finishResponse)
@@ -528,7 +528,7 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card px-7 py-3 w-full h-full"
class="auth-card px-7 pt-3 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }"
max-width="24rem"
border
@@ -539,7 +539,7 @@ onUnmounted(() => {
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
<VCardTitle class="font-weight-bold text-3xl text-uppercase"> MoviePilot </VCardTitle>
<!-- 语言切换按钮 -->
<template #append>
@@ -602,7 +602,7 @@ onUnmounted(() => {
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VCol cols="12" class="py-0">
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -732,15 +732,15 @@ onUnmounted(() => {
&::before,
&::after {
content: '';
flex: 1;
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
content: '';
}
.or-divider-text {
padding-inline: 12px;
font-size: 0.8125rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
padding-inline: 12px;
white-space: nowrap;
}
}

View File

@@ -95,6 +95,9 @@ const cardScroll = useInfiniteScroll(filteredCardDataList)
// 是否刷新过
const isRefreshed = ref(false)
// 是否正在重新搜索
const isRefreshing = ref(false)
// 加载进度文本
const progressText = ref(t('common.pleaseWait'))
@@ -464,6 +467,21 @@ async function fetchData() {
}
}
// 重新搜索(使用相同参数重新触发搜索)
async function refreshSearch() {
if (isRefreshing.value || progressActive.value) return
isRefreshing.value = true
try {
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
showingAiResults.value = false
await fetchData()
} catch (error) {
console.error('重新搜索失败:', error)
} finally {
isRefreshing.value = false
}
}
// 切换到智能推荐结果(自动保存筛选条件)
async function switchToAiResults() {
if (showingAiResults.value) {
@@ -808,8 +826,8 @@ onUnmounted(() => {
</div>
</VFadeTransition>
<!-- 精简标题栏 -->
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
<!-- 精简标题栏搜索过后保持挂载加载中由按钮 :disabled / :loading 表达状态 -->
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
<div class="search-info-container">
<div class="search-title text-moviepilot">
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
@@ -833,6 +851,22 @@ onUnmounted(() => {
<VSpacer />
<!-- 重新搜索按钮 -->
<VBtn
variant="text"
size="small"
icon
class="me-2 refresh-search-btn"
:loading="isRefreshing"
:disabled="isRefreshing || progressActive"
@click="refreshSearch"
>
<VIcon icon="mdi-refresh" size="20" />
<VTooltip activator="parent" location="top">
{{ t('resource.refreshSearch') }}
</VTooltip>
</VBtn>
<!-- AI操作按钮组 -->
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
<div class="ai-toggle-buttons">
@@ -1180,6 +1214,14 @@ onUnmounted(() => {
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* 重新搜索按钮 */
.refresh-search-btn {
block-size: 44px !important;
inline-size: 44px !important;
border-radius: 8px !important;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
}
/* AI按钮组样式 */
.ai-toggle-container {
position: relative;
@@ -1371,6 +1413,11 @@ onUnmounted(() => {
inline-size: 36px;
}
.refresh-search-btn {
block-size: 36px !important;
inline-size: 36px !important;
}
.ai-toggle-buttons {
block-size: 36px;
}

View File

@@ -4,10 +4,12 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSetupWizard } from '@/composables/useSetupWizard'
import BasicSettingsStep from '@/views/setup/BasicSettingsStep.vue'
import SiteAuthSettingsStep from '@/views/setup/SiteAuthSettingsStep.vue'
import StorageSettingsStep from '@/views/setup/StorageSettingsStep.vue'
import DownloaderSettingsStep from '@/views/setup/DownloaderSettingsStep.vue'
import MediaServerSettingsStep from '@/views/setup/MediaServerSettingsStep.vue'
import NotificationSettingsStep from '@/views/setup/NotificationSettingsStep.vue'
import AgentSettingsStep from '@/views/setup/AgentSettingsStep.vue'
import PreferencesSettingsStep from '@/views/setup/PreferencesSettingsStep.vue'
import ConnectivityTest from '@/views/setup/ConnectivityTest.vue'
import { useDisplay } from 'vuetify'
@@ -101,28 +103,38 @@ onMounted(async () => {
<BasicSettingsStep />
</VStepperWindowItem>
<!-- 步骤2存储目录 -->
<!-- 步骤2用户认证 -->
<VStepperWindowItem :value="2">
<SiteAuthSettingsStep />
</VStepperWindowItem>
<!-- 步骤3存储目录 -->
<VStepperWindowItem :value="3">
<StorageSettingsStep />
</VStepperWindowItem>
<!-- 步骤3下载器 -->
<VStepperWindowItem :value="3">
<!-- 步骤4下载器 -->
<VStepperWindowItem :value="4">
<DownloaderSettingsStep />
</VStepperWindowItem>
<!-- 步骤4媒体服务器 -->
<VStepperWindowItem :value="4">
<!-- 步骤5媒体服务器 -->
<VStepperWindowItem :value="5">
<MediaServerSettingsStep />
</VStepperWindowItem>
<!-- 步骤5通知 -->
<VStepperWindowItem :value="5">
<!-- 步骤6通知 -->
<VStepperWindowItem :value="6">
<NotificationSettingsStep />
</VStepperWindowItem>
<!-- 步骤6资源偏好 -->
<VStepperWindowItem :value="6">
<!-- 步骤7智能助手 -->
<VStepperWindowItem :value="7">
<AgentSettingsStep />
</VStepperWindowItem>
<!-- 步骤8资源偏好 -->
<VStepperWindowItem :value="8">
<PreferencesSettingsStep />
</VStepperWindowItem>
</VStepperWindow>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
@@ -6,6 +7,9 @@ import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
@@ -13,11 +17,13 @@ import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const { appMode } = usePWA()
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref((route.query.tab as string) || '')
const shareViewKey = ref(0)
const subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)
// 获取标签页
const subscribeTabs = computed(() => {
@@ -48,12 +54,7 @@ const subscribeStatusFilter = ref<string | null>(null)
// 分享搜索词
const shareKeyword = ref('')
// 搜索分享
const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
const shareKeywordInput = ref('')
// 筛选选项
const filterOptions = computed(() => {
@@ -103,7 +104,102 @@ function selectFilter(value: string) {
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
function openDefaultRuleDialog() {
subscribeEditDialog.value = true
}
function openSubscribeHistoryDialog() {
subscribeListViewRef.value?.openHistoryDialog()
}
function openShareStatisticsDialog() {
shareStatisticsDialog.value = true
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
const subscribeDynamicMenuItems = computed(() => {
if (!appMode.value) return undefined
if (activeTab.value === 'mysub') {
const items: Array<{
titleKey: string
titleParams?: Record<string, unknown>
icon: string
action: () => void
}> = []
if (showSubscribeHistoryAction.value) {
items.push({
titleKey: 'dialog.subscribeHistory.title',
titleParams: { type: subType },
icon: 'mdi-history',
action: openSubscribeHistoryDialog,
})
}
items.push({
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
action: openDefaultRuleDialog,
})
return items.length > 1 ? items : undefined
}
return undefined
})
const subscribeDynamicIcon = computed(() => {
if (showShareStatisticsAction.value) return 'mdi-chart-line'
if (showSubscribeHistoryAction.value) return 'mdi-history'
return 'mdi-clipboard-edit-outline'
})
function handleSubscribeDynamicAction() {
if (showShareStatisticsAction.value) {
openShareStatisticsDialog()
return
}
if (showSubscribeHistoryAction.value) {
openSubscribeHistoryDialog()
return
}
if (showDefaultRuleAction.value) {
openDefaultRuleDialog()
}
}
useDynamicButton({
icon: subscribeDynamicIcon,
onClick: handleSubscribeDynamicAction,
menuItems: subscribeDynamicMenuItems,
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
})
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -137,37 +233,16 @@ registerHeaderTab({
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-chart-line',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: 'gray',
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'statistics-btn',
action: () => {
shareStatisticsDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
dataAttr: 'share-filter-btn',
action: () => {
searchShareDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-clipboard-edit-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
subscribeEditDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
],
})
@@ -187,6 +262,7 @@ onMounted(() => {
<transition name="fade-slide" appear>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
@@ -205,7 +281,7 @@ onMounted(() => {
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" />
<SubscribeShareView :keyword="shareKeyword" />
</div>
</transition>
</VWindowItem>
@@ -221,7 +297,7 @@ onMounted(() => {
>
<VCard min-width="220">
<!-- 名称搜索 -->
<div class="px-3 pt-3 pb-1">
<div class="pa-3">
<VTextField
v-model="subscribeFilter"
:placeholder="t('subscribe.name')"
@@ -248,7 +324,12 @@ onMounted(() => {
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="(subscribeStatusFilter || 'all') === option.value" icon="mdi-check" color="primary" size="small" />
<VIcon
v-if="(subscribeStatusFilter || 'all') === option.value"
icon="mdi-check"
color="primary"
size="small"
/>
</template>
</VListItem>
</VList>
@@ -260,30 +341,56 @@ onMounted(() => {
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="pa-3">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('subscribe.keyword')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<div class="compact-fab-stack">
<VFab
v-if="showSubscribeHistoryAction"
icon="mdi-history"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openSubscribeHistoryDialog"
/>
<VFab
v-if="showDefaultRuleAction"
icon="mdi-clipboard-edit-outline"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openDefaultRuleDialog"
/>
<VFab
v-if="showShareStatisticsAction"
icon="mdi-chart-line"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openShareStatisticsDialog"
/>
</div>
</Teleport>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"

View File

@@ -1,42 +1,66 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getWorkflowTabs } from '@/router/i18n-menu'
// 国际化
const { t } = useI18n()
const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list')
const shareViewKey = ref(0)
const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页
const workflowTabs = computed(() => {
return getWorkflowTabs(t)
})
// 新增工作流对话框
const addWorkflowDialog = ref(false)
// 分享搜索词
const shareKeyword = ref('')
const shareKeywordInput = ref('')
// 搜索分享对话框
const searchShareDialog = ref(false)
// 搜索分享激活器
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
// 搜索分享
const searchShares = () => {
shareViewKey.value++
function openAddWorkflowDialog() {
workflowListViewRef.value?.openAddDialog()
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
useDynamicButton({
icon: 'mdi-plus',
onClick: openAddWorkflowDialog,
show: computed(() => appMode.value && activeTab.value === 'list'),
})
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -46,11 +70,11 @@ registerHeaderTab({
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-search',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
dataAttr: 'share-filter-btn',
show: computed(() => activeTab.value === 'share'),
action: () => {
searchShareDialog.value = true
@@ -74,54 +98,54 @@ onMounted(() => {
<VWindowItem value="list">
<transition name="fade-slide" appear>
<div>
<WorkflowListView :key="listViewKey" />
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<WorkflowShareView :keyword="shareKeyword" :key="shareViewKey" @update="listViewKey++" />
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
</div>
</transition>
</VWindowItem>
</VWindow>
<!-- 新增工作流对话框 -->
<WorkflowAddEditDialog
v-if="addWorkflowDialog"
v-model="addWorkflowDialog"
@close="addWorkflowDialog = false"
@save="addWorkflowDialog = false"
/>
<!-- 搜索工作流分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('workflow.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('workflow.searchShares')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="pa-3">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('workflow.searchShares')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
<div class="compact-fab-stack">
<VFab
icon="mdi-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddWorkflowDialog"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -48,6 +48,143 @@ html.v-overlay-scroll-blocked body {
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
.app-card-shell {
position: relative;
block-size: 100%;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
}
.app-card-summary {
position: relative;
display: flex;
overflow: hidden;
align-items: stretch;
justify-content: flex-start;
block-size: 7.5rem;
min-block-size: 7.5rem;
}
.app-card-summary__content {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
min-inline-size: 0;
padding-block: 0.25rem 0.5rem;
row-gap: 0.25rem;
}
.app-card-summary__title-row {
display: flex;
align-items: flex-start;
column-gap: 0.25rem;
min-inline-size: 0;
}
.app-card-summary__title-row > .v-badge {
flex-shrink: 0;
align-self: center;
}
.app-card-summary__subtitle,
.app-card-summary__meta-item {
overflow: hidden;
min-inline-size: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-card-summary__title {
display: -webkit-box;
overflow: hidden;
flex: 0 0 auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.35;
max-block-size: calc(1.35em * 2);
min-inline-size: 0;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
}
.app-card-summary__title-row .app-card-summary__title {
flex: 1 1 auto;
}
.app-card-summary__meta {
display: flex;
overflow: hidden;
align-items: center;
column-gap: 0.5rem;
min-block-size: 1.5rem;
min-inline-size: 0;
}
.app-card-summary--single-action .app-card-summary__content {
padding-inline-end: 3.75rem;
}
.app-card-summary--double-action .app-card-summary__content {
padding-inline-end: 5rem;
}
.app-card-summary--title-subtitle {
padding-block: 0.75rem !important;
}
.app-card-summary--title-subtitle .app-card-summary__content {
justify-content: space-between;
block-size: 100%;
padding-block: 0;
}
.app-card-summary--title-subtitle .app-card-summary__title {
flex: 0 1 auto;
}
.app-card-summary--title-subtitle .app-card-summary__subtitle {
flex-shrink: 0;
}
.app-card-summary__media {
position: absolute;
z-index: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
inset-block-end: 0.75rem;
inset-inline-end: 1rem;
pointer-events: none;
}
.app-card-summary--single-action .app-card-summary__media,
.app-card-summary--double-action .app-card-summary__media {
inset-inline-end: 1rem;
}
.app-card-summary__image {
flex-shrink: 0;
block-size: 3.5rem;
inline-size: 3.5rem;
max-block-size: 3.5rem;
max-inline-size: 3.5rem;
min-block-size: 3.5rem;
min-inline-size: 3.5rem;
}
.app-card-summary__image .v-img__img {
object-fit: contain;
}
// Toast通知样式
.Vue-Toastification__container {
z-index: 2500;
@@ -238,6 +375,122 @@ html.v-overlay-scroll-blocked body {
opacity:0.75;
}
// 紧凑型悬浮操作按钮
.compact-fab-stack {
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));
inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));
pointer-events: none;
}
.compact-fab-stack > * {
pointer-events: auto;
}
.compact-fab-stack--history {
inset-block-end: max(4.5rem, calc(env(safe-area-inset-bottom) + 4.5rem));
}
.compact-fab.v-fab {
display: inline-flex;
overflow: visible;
flex: none;
min-inline-size: 0 !important;
pointer-events: auto;
}
.compact-fab .v-fab__container {
position: static;
display: inline-flex;
overflow: visible;
margin: 0 !important;
}
.compact-fab .v-btn {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
backdrop-filter: blur(14px);
box-shadow:
0 16px 34px rgb(15 23 42 / 16%),
0 6px 16px rgb(15 23 42 / 10%);
opacity: 0.98;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
filter 0.18s ease,
opacity 0.18s ease;
}
.compact-fab--primary .v-btn {
block-size: 3rem !important;
box-shadow:
0 20px 40px rgb(15 23 42 / 20%),
0 8px 18px rgb(15 23 42 / 12%);
inline-size: 3rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
.compact-fab--primary .v-icon {
font-size: 1.75rem !important;
}
.compact-fab--secondary .v-icon {
font-size: 1.75rem !important;
}
@media (hover: hover) {
.compact-fab .v-btn:hover {
box-shadow:
0 22px 42px rgb(15 23 42 / 22%),
0 8px 18px rgb(15 23 42 / 12%);
filter: saturate(1.03);
transform: translateY(-2px);
}
.compact-fab--primary .v-btn:hover {
box-shadow:
0 26px 46px rgb(15 23 42 / 24%),
0 10px 22px rgb(15 23 42 / 14%);
}
}
.compact-fab .v-btn:active {
box-shadow:
0 10px 22px rgb(15 23 42 / 16%),
0 3px 8px rgb(15 23 42 / 10%);
transform: translateY(0) scale(0.98);
}
@media (width <= 768px) {
.compact-fab-stack {
gap: 0.625rem;
inset-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.875rem));
inset-inline-end: max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem));
}
.compact-fab-stack--history {
inset-block-end: max(4rem, calc(env(safe-area-inset-bottom) + 4rem));
}
.compact-fab--primary .v-btn {
block-size: 3.5rem !important;
inline-size: 3.5rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
@@ -311,7 +564,28 @@ html.v-overlay-scroll-blocked body {
.settings-icon-button {
flex-shrink: 0;
min-inline-size: auto;
border-radius: 0.95rem;
block-size: 2.75rem;
inline-size: 2.75rem;
margin-inline-start: 0.25rem;
min-inline-size: 2.75rem;
}
.settings-icon-button .v-icon {
font-size: 1.35rem;
}
@media (width <= 768px) {
.settings-icon-button {
border-radius: 0.825rem;
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.settings-icon-button .v-icon {
font-size: 1.25rem;
}
}
.v-infinite-scroll__side {

View File

@@ -34,6 +34,9 @@ const activeTab = ref('installed')
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs(t))
// 本地插件来源显示名称
const localRepoLabel = computed(() => t('plugin.local'))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -76,26 +79,6 @@ registerHeaderTab({
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-store-cog',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
MarketSettingDialog.value = true
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-folder-plus',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
showNewFolderDialog()
},
show: computed(() => activeTab.value === 'installed' && !currentFolder.value),
},
{
icon: 'mdi-arrow-left',
variant: 'text',
@@ -610,15 +593,21 @@ async function saveFolderPluginOrder() {
// 初始化过滤选项
function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: string | undefined) => {
value && !options.includes(value) && options.push(value)
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {
if (!value || options.includes(value)) return
if (preferred) options.unshift(value)
else options.push(value)
}
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
}
optionValue(authorFilterOptions.value, item.plugin_author)
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
optionValue(repoFilterOptions.value, handleRepoUrl(item.repo_url))
optionValue(
repoFilterOptions.value,
handleRepoUrl(item),
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
)
}
// 关闭插件市场窗口
@@ -650,7 +639,7 @@ async function installPlugin(item: Plugin) {
enabledFilter.value = false
installedFilter.value = null
// 刷新
refreshData()
await refreshData()
} else {
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
}
@@ -750,6 +739,7 @@ async function fetchUninstalledPlugins(force: boolean = false) {
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
// 初始化过滤选项
repoFilterOptions.value = []
marketList.value.forEach(initOptions)
// 设置APP市场加载完成
isAppMarketLoaded.value = true
@@ -770,13 +760,14 @@ async function getPluginStatistics() {
// 加载所有数据
async function refreshData() {
await fetchInstalledPlugins()
fetchUninstalledPlugins()
await fetchUninstalledPlugins()
getPluginStatistics()
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
await loadPluginFolders()
}
// 对uninstalledList进行排序到sortedUninstalledList
watch([marketList, filterForm, activeSort], () => {
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
@@ -794,7 +785,7 @@ watch([marketList, filterForm, activeSort], () => {
filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) &&
match(filterForm.author, value.plugin_author) &&
matchMultiple(filterForm.label, value.plugin_label) &&
match(filterForm.repo, handleRepoUrl(value.repo_url))
match(filterForm.repo, handleRepoUrl(value))
) {
sortedUninstalledList.value.push(value)
}
@@ -805,7 +796,7 @@ watch([marketList, filterForm, activeSort], () => {
if (!isNullOrEmptyObject(PluginStatistics.value)) {
if (!activeSort.value || activeSort.value === 'count') {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
return PluginStatistics.value[b.id || '0'] - PluginStatistics.value[a.id || '0']
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
})
} else if (activeSort.value) {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a: any, b: any) => {
@@ -825,9 +816,9 @@ function pluginLabels(label: string | undefined) {
}
// 新安装了插件
function pluginInstalled() {
async function pluginInstalled() {
pluginDialogClose()
refreshData()
await refreshData()
}
// 插件市场设置完成
@@ -842,7 +833,7 @@ async function refreshMarket() {
isMarketRefreshing.value = true
try {
await fetchUninstalledPlugins(true)
await getPluginStatistics()
getPluginStatistics()
} catch (error) {
console.error(error)
} finally {
@@ -850,9 +841,22 @@ async function refreshMarket() {
}
}
function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return ''
try {
return new URL(repoUrl).searchParams.get('path') || ''
} catch (error) {
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')
}
}
// 处理掉github地址的前缀
function handleRepoUrl(url: string | undefined) {
function handleRepoUrl(item: Plugin | string | undefined) {
const url = typeof item === 'string' ? item : item?.repo_url
if (!url) return ''
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
}
@@ -886,7 +890,6 @@ onMounted(async () => {
await loadPluginOrderConfig()
await loadPluginFolders() // 加载文件夹配置
await refreshData()
getPluginStatistics()
if (activeTab.value != 'market' && pluginId.value) {
// 找到这个插件
const plugin = dataList.value.find(item => item.id === pluginId.value)
@@ -896,12 +899,54 @@ onMounted(async () => {
}
})
// 使用动态按钮钩子
function openPluginSearchDialog() {
SearchDialog.value = true
}
function openMarketSettingDialog() {
MarketSettingDialog.value = true
}
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
const showMarketSettingAction = computed(() => activeTab.value === 'market')
const pluginDynamicMenuItems = computed(() => {
if (!appMode.value) return undefined
if (!showSearchAction.value) return undefined
const items = [
{
titleKey: 'plugin.searchPlugins',
icon: 'mdi-magnify',
action: openPluginSearchDialog,
},
]
if (showNewFolderAction.value) {
items.push({
titleKey: 'plugin.newFolder',
icon: 'mdi-folder-plus',
action: showNewFolderDialog,
})
}
if (showMarketSettingAction.value) {
items.push({
titleKey: 'dialog.pluginMarketSetting.title',
icon: 'mdi-store-cog',
action: openMarketSettingDialog,
})
}
return items.length > 1 ? items : undefined
})
useDynamicButton({
icon: 'mdi-magnify',
onClick: () => {
SearchDialog.value = true
},
onClick: openPluginSearchDialog,
menuItems: pluginDynamicMenuItems,
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
})
// 获取插件文件夹配置
@@ -1309,7 +1354,7 @@ function onDragStartPlugin(evt: any) {
>
<VCard min-width="220">
<!-- 名称搜索 -->
<div class="px-3 pt-3 pb-1">
<div class="pa-3">
<VCombobox
v-model="installedFilter"
:items="installedPluginNames"
@@ -1325,11 +1370,7 @@ function onDragStartPlugin(evt: any) {
<!-- 快捷筛选 -->
<VList density="compact" class="px-2 py-1">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
:active="enabledFilter"
@click="enabledFilter = !enabledFilter"
density="compact"
>
<VListItem :active="enabledFilter" @click="enabledFilter = !enabledFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-play-circle" color="success" size="small" />
</template>
@@ -1338,11 +1379,7 @@ function onDragStartPlugin(evt: any) {
<VIcon v-if="enabledFilter" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VListItem
:active="hasUpdateFilter"
@click="hasUpdateFilter = !hasUpdateFilter"
density="compact"
>
<VListItem :active="hasUpdateFilter" @click="hasUpdateFilter = !hasUpdateFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle" color="info" size="small" />
</template>
@@ -1366,7 +1403,7 @@ function onDragStartPlugin(evt: any) {
>
<VCard min-width="260" max-width="320">
<!-- 名称搜索 -->
<div class="px-3 pt-3 pb-1">
<div class="pa-3">
<VTextField
v-model="filterForm.name"
:placeholder="t('plugin.name')"
@@ -1566,18 +1603,31 @@ function onDragStartPlugin(evt: any) {
<!-- 插件搜索图标 -->
<Teleport to="body" v-if="route.path === '/plugins'">
<div v-if="isRefreshed">
<div v-if="isRefreshed && !appMode && showSearchAction" class="compact-fab-stack">
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
v-if="showMarketSettingAction"
icon="mdi-store-cog"
color="warning"
variant="tonal"
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
class="compact-fab compact-fab--secondary"
@click="openMarketSettingDialog"
/>
<VFab
v-if="showNewFolderAction"
icon="mdi-folder-plus"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="showNewFolderDialog"
/>
<VFab
icon="mdi-magnify"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openPluginSearchDialog"
/>
</div>
</Teleport>

View File

@@ -12,6 +12,7 @@ import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores'
@@ -58,7 +59,7 @@ const aiRedoProgressDialog = ref(false)
const aiRedoProgressActive = ref(false)
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
const aiRedoProgressSSE = ref<any>(null)
const aiRedoProgressHistoryId = ref<number>()
const aiRedoProgressHistoryIds = ref<number[]>([])
// 重新整理IDS
const redoIds = ref<number[]>([])
@@ -373,6 +374,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
// 批量删除记录
async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
if (hasRunningAiRedo.value) return
// 关闭弹窗
deleteConfirmDialog.value = false
// 总条数
@@ -408,6 +410,7 @@ async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
// 批量删除历史记录
async function removeHistoryBatch() {
if (hasRunningAiRedo.value) return
if (selected.value.length === 0) return
// 清空当前操作记录
@@ -418,9 +421,9 @@ async function removeHistoryBatch() {
// 打开确认弹窗
deleteConfirmDialog.value = true
}
// 批量重新整理
async function retransferBatch() {
if (hasRunningAiRedo.value) return
if (selected.value.length === 0) return
// 清空当前操作记录
@@ -462,15 +465,14 @@ function stopAiRedoProgress() {
// AI整理完成
async function finishAiRedo(success: boolean, errorMessage?: string) {
const historyId = aiRedoProgressHistoryId.value
const historyIds = [...aiRedoProgressHistoryIds.value]
const historyIdSet = new Set(historyIds)
stopAiRedoProgress()
aiRedoProgressDialog.value = false
aiRedoProgressHistoryId.value = undefined
if (historyId !== undefined) {
aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)
}
aiRedoProgressHistoryIds.value = []
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIdSet.has(id))
selected.value = selected.value.filter(item => !historyIdSet.has(item.id))
await fetchData()
@@ -493,9 +495,14 @@ async function handleAiRedoProgressMessage(event: MessageEvent) {
// 开始监听整理进度
function startAiRedoProgress(historyId: number, progressKey: string) {
startAiRedoProgressBatch([historyId], progressKey)
}
// 开始监听批量整理进度
function startAiRedoProgressBatch(historyIds: number[], progressKey: string) {
stopAiRedoProgress()
aiRedoProgressHistoryId.value = historyId
aiRedoProgressHistoryIds.value = historyIds
aiRedoProgressDialog.value = true
aiRedoProgressActive.value = true
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
@@ -543,6 +550,44 @@ async function triggerAiRedo(item: TransferHistory) {
}
}
// 批量触发AI整理
async function triggerBatchAiRedo() {
if (!aiAgentEnabled.value) {
$toast.error(t('transferHistory.aiRedoDisabled'))
return
}
if (hasRunningAiRedo.value) return
const historyIds = [...new Set(selected.value.map(item => item.id))]
if (historyIds.length === 0) return
aiRedoIds.value = [...new Set([...aiRedoIds.value, ...historyIds])]
let progressStarted = false
try {
const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', {
history_ids: historyIds,
})
const progressKey = result.data?.progress_key
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
if (!result.success || !progressKey) {
$toast.error(result.message || t('transferHistory.aiRedoFailed'))
return
}
startAiRedoProgressBatch(acceptedIds, progressKey)
selected.value = selected.value.filter(item => !acceptedIds.includes(item.id))
progressStarted = true
} catch (error) {
console.error(error)
$toast.error(t('transferHistory.aiRedoFailed'))
} finally {
if (!progressStarted) {
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
}
}
}
// 计算下拉菜单
function getDropdownItems(item: TransferHistory) {
return [
@@ -641,6 +686,59 @@ const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) =>
}
}
const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chevron-up' : 'mdi-timer-sand-paused'))
const historyDynamicMenuItems = computed(() => {
if (selected.value.length === 0) return undefined
const items: Array<{ titleKey: string; icon: string; action: () => void; color?: string }> = [
{
titleKey: 'dialog.transferQueue.title',
icon: 'mdi-timer-sand-paused',
action: () => {
transferQueueDialog.value = true
},
},
]
if (!hasRunningAiRedo.value) {
items.push(
{
titleKey: 'transferHistory.actions.batchAiRedo',
icon: 'mdi-robot-outline',
action: () => {
triggerBatchAiRedo()
},
},
{
titleKey: 'transferHistory.actions.batchRedo',
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
titleKey: 'transferHistory.actions.batchDelete',
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
)
}
return items
})
useDynamicButton({
icon: historyDynamicIcon,
onClick: () => {
transferQueueDialog.value = true
},
menuItems: historyDynamicMenuItems,
show: computed(() => appMode.value),
})
// 初始加载数据
onMounted(() => {
loadStorages()
@@ -679,7 +777,6 @@ onUnmounted(() => {
</VCol>
<VCol cols="4" md="6" class="text-end">
<VBtnGroup variant="outlined" divided rounded>
<VBtn icon="mdi-timer-sand-paused" @click="transferQueueDialog = true" />
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
</VBtnGroup>
</VCol>
@@ -899,32 +996,7 @@ onUnmounted(() => {
</div>
</VCard>
<!-- 底部操作按钮 -->
<Teleport to="body" v-if="route.path === '/history'">
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
</Teleport>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center">
@@ -962,6 +1034,47 @@ onUnmounted(() => {
/>
<!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
<!-- app 模式下的 FAB 按钮 -->
<Teleport to="body" v-if="!appMode && route.path === '/history'">
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
<VFab
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-trash-can-outline"
color="warning"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="removeHistoryBatch"
/>
<VFab
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-redo-variant"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="retransferBatch"
/>
<VFab
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-robot-outline"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="triggerBatchAiRedo"
/>
<VFab
icon="mdi-timer-sand-paused"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="transferQueueDialog = true"
/>
</div>
</Teleport>
</template>
<style lang="scss">

View File

@@ -10,9 +10,11 @@ import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { storageAttributes } from '@/api/constants'
const { t } = useI18n()
const { global: globalTheme } = useTheme()
// 所有下载目录
const directories = ref<TransferDirectoryConf[]>([])
@@ -58,6 +60,30 @@ const SystemSettings = ref<any>({
},
})
// 编辑器主题
const editorTheme = computed(() => (globalTheme.name.value === 'light' ? 'github' : 'monokai'))
const renameEditorOptions = {
fontSize: 14,
tabSize: 2,
showLineNumbers: true,
showGutter: true,
}
const movieRenameFormat = computed({
get: () => SystemSettings.value.Basic.MOVIE_RENAME_FORMAT ?? '',
set: (value: string) => {
SystemSettings.value.Basic.MOVIE_RENAME_FORMAT = value || null
},
})
const tvRenameFormat = computed({
get: () => SystemSettings.value.Basic.TV_RENAME_FORMAT ?? '',
set: (value: string) => {
SystemSettings.value.Basic.TV_RENAME_FORMAT = value || null
},
})
// 加载系统设置
async function loadSystemSettings() {
try {
@@ -346,26 +372,48 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="SystemSettings.Basic.MOVIE_RENAME_FORMAT"
:label="t('setting.directory.movieRenameFormat')"
:hint="t('setting.directory.movieRenameFormatHint')"
persistent-hint
clearable
active
prepend-inner-icon="mdi-movie-open"
/>
<div class="rename-format-editor">
<div class="rename-format-editor__label">
<VIcon icon="mdi-movie-open" size="20" class="me-2" />
<span>{{ t('setting.directory.movieRenameFormat') }}</span>
</div>
<VAceEditor
v-model:value="movieRenameFormat"
lang="jinja2"
:theme="editorTheme"
:options="renameEditorOptions"
:print-margin="false"
:min-lines="4"
:max-lines="12"
wrap
class="rename-format-editor__ace rounded"
/>
<div class="rename-format-editor__hint">
{{ t('setting.directory.movieRenameFormatHint') }}
</div>
</div>
</VCol>
<VCol cols="12">
<VTextarea
v-model="SystemSettings.Basic.TV_RENAME_FORMAT"
:label="t('setting.directory.tvRenameFormat')"
:hint="t('setting.directory.tvRenameFormatHint')"
persistent-hint
clearable
active
prepend-inner-icon="mdi-television"
/>
<div class="rename-format-editor">
<div class="rename-format-editor__label">
<VIcon icon="mdi-television" size="20" class="me-2" />
<span>{{ t('setting.directory.tvRenameFormat') }}</span>
</div>
<VAceEditor
v-model:value="tvRenameFormat"
lang="jinja2"
:theme="editorTheme"
:options="renameEditorOptions"
:print-margin="false"
:min-lines="4"
:max-lines="12"
wrap
class="rename-format-editor__ace rounded"
/>
<div class="rename-format-editor__hint">
{{ t('setting.directory.tvRenameFormatHint') }}
</div>
</div>
</VCol>
</VRow>
</VCardText>
@@ -392,3 +440,28 @@ onMounted(() => {
@done="loadMediaCategories"
/>
</template>
<style scoped>
.rename-format-editor__label {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.375rem;
margin-block-end: 0.5rem;
}
.rename-format-editor__ace {
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
min-block-size: 8rem;
}
.rename-format-editor__hint {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.75rem;
line-height: 1.25rem;
margin-block-start: 0.375rem;
}
</style>

View File

@@ -480,8 +480,9 @@ onMounted(() => {
</VCardItem>
<VCardText class="py-0">
<VAceEditor
:key="`${currentTemplate}-jinja2-json`"
v-model:value="editorContent"
lang="json"
lang="jinja2_json"
:theme="editorTheme"
class="w-full h-full min-h-[30rem] rounded"
/>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,567 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
const { t } = useI18n()
const $toast = useToast()
const { wizardData, validationErrors } = useSetupWizard()
const providerRef = computed({
get: () => wizardData.value.agent.provider,
set: value => {
wizardData.value.agent.provider = value || ''
},
})
const apiKeyRef = computed({
get: () => wizardData.value.agent.apiKey,
set: value => {
wizardData.value.agent.apiKey = value || ''
},
})
const baseUrlRef = computed({
get: () => wizardData.value.agent.baseUrl,
set: value => {
wizardData.value.agent.baseUrl = value || ''
},
})
const modelRef = computed({
get: () => wizardData.value.agent.model,
set: value => {
wizardData.value.agent.model = value || ''
},
})
const maxContextTokensRef = computed({
get: () => wizardData.value.agent.maxContextTokens,
set: value => {
wizardData.value.agent.maxContextTokens = value || 0
},
})
const authConnectedRef = computed({
get: () => wizardData.value.agent.authConnected,
set: value => {
wizardData.value.agent.authConnected = Boolean(value)
},
})
const {
providerItems,
baseUrlPresetItems,
models: llmModels,
selectedProvider,
selectedModel,
loadingProviders,
loadingModels,
providerConnected,
showBaseUrlField,
showApiKeyField,
canRefreshModels,
authDialogVisible,
authPolling,
authPopupBlocked,
authSession,
handleProviderSelection,
applyModelMetadata,
loadProviders,
loadModels,
openAuthPage,
startAuth,
pollAuthSession,
disconnectAuth,
closeAuthDialog,
} = useLlmProviderDirectory({
provider: providerRef,
apiKey: apiKeyRef,
baseUrl: baseUrlRef,
model: modelRef,
maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef,
})
const jobIntervalItems = computed(() => [
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
])
const thinkingLevelItems = computed(() => [
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
{ title: t('setting.system.llmThinkingLevelMinimal'), value: 'minimal' },
{ title: t('setting.system.llmThinkingLevelLow'), value: 'low' },
{ title: t('setting.system.llmThinkingLevelMedium'), value: 'medium' },
{ title: t('setting.system.llmThinkingLevelHigh'), value: 'high' },
{ title: t('setting.system.llmThinkingLevelMax'), value: 'max' },
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
])
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
const selectedModelInfo = computed(() => {
if (!selectedModel.value?.context_tokens_k) return ''
return t('setting.system.llmModelResolvedHint', {
context: selectedModel.value.context_tokens_k,
source: selectedModel.value.source || 'models.dev',
})
})
async function refreshModels(forceRefresh = true) {
try {
await loadModels(forceRefresh)
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
console.log('Load LLM models failed:', error)
}
}
async function handleProviderChanged() {
handleProviderSelection(true)
if (canRefreshModels.value) {
await refreshModels(false)
}
}
function handleModelChanged() {
applyModelMetadata()
}
async function startProviderAuth(methodId: string) {
try {
await startAuth(methodId)
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
}
}
async function disconnectProviderAuth() {
try {
await disconnectAuth()
$toast.success(t('setting.system.llmProviderDisconnected'))
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
}
}
onMounted(async () => {
try {
await loadProviders()
if (wizardData.value.agent.enabled && canRefreshModels.value) {
await refreshModels(false)
}
} catch (error) {
console.log('Load LLM providers failed:', error)
}
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.agent.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.agent.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.agent.info') }}</VAlertTitle>
{{ t('setupWizard.agent.infoDesc') }}
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.enabled"
:label="t('setting.system.aiAgentEnable')"
:hint="t('setting.system.aiAgentEnableHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.enabled">
<VCol cols="12" md="3">
<VSwitch
v-model="wizardData.agent.global"
:label="t('setting.system.aiAgentGlobal')"
:hint="t('setting.system.aiAgentGlobalHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="3">
<VSwitch
v-model="wizardData.agent.verbose"
:label="t('setting.system.aiAgentVerbose')"
:hint="t('setting.system.aiAgentVerboseHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="wizardData.agent.provider"
:label="t('setting.system.llmProvider')"
:hint="t('setting.system.llmProviderHint')"
:items="providerItems"
:loading="loadingProviders"
:error="validationErrors.agent.provider"
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-robot-outline"
@update:model-value="handleProviderChanged"
/>
</VCol>
<VCol v-if="showBaseUrlField" cols="12" md="6">
<VCombobox
:model-value="wizardData.agent.baseUrl"
@update:model-value="(value: any) => {
wizardData.agent.baseUrl = typeof value === 'object' && value !== null ? value.value : (value || '');
}"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"
:placeholder="selectedProvider?.default_base_url || 'https://api.deepseek.com'"
:items="baseUrlPresetItems"
item-title="title"
item-value="value"
persistent-hint
prepend-inner-icon="mdi-link-variant"
>
<template #item="{ props, item }">
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
</template>
</VCombobox>
</VCol>
<VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.apiKey"
:label="selectedProvider?.api_key_label || t('setting.system.llmApiKey')"
:hint="selectedProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
:error="validationErrors.agent.apiKey"
:error-messages="
validationErrors.agent.apiKey ? [t('setupWizard.agent.authOrApiKeyRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol v-if="providerAuthMethods.length > 0" cols="12">
<VAlert type="info" variant="tonal">
<div class="d-flex flex-column ga-3">
<div>
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
<div class="text-body-2">
{{ selectedProvider?.description || t('setting.system.llmProviderAuthHint') }}
</div>
<div v-if="providerConnected" class="text-body-2 mt-2">
{{ t('setting.system.llmProviderConnectedAs', { label: providerAuthLabel || selectedProvider?.name }) }}
</div>
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn
v-for="method in providerAuthMethods"
:key="method.id"
color="primary"
variant="tonal"
prepend-icon="mdi-account-arrow-right-outline"
@click="startProviderAuth(method.id)"
>
{{ method.label }}
</VBtn>
<VBtn
v-if="providerConnected"
color="error"
variant="text"
prepend-icon="mdi-link-off"
@click="disconnectProviderAuth"
>
{{ t('setting.system.llmProviderDisconnect') }}
</VBtn>
</div>
</div>
</VAlert>
</VCol>
<VCol cols="12" md="6">
<VCombobox
:model-value="wizardData.agent.model"
@update:model-value="(val: any) => {
wizardData.agent.model = typeof val === 'object' && val !== null ? val.id : val;
handleModelChanged();
}"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:items="llmModels"
item-title="name"
item-value="id"
:loading="loadingModels"
:error="validationErrors.agent.model"
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-brain"
>
<template #append-inner>
<VBtn
variant="text"
icon="mdi-refresh"
size="small"
:disabled="!canRefreshModels"
@click="refreshModels(true)"
/>
</template>
</VCombobox>
<VAlert v-if="selectedModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
{{ selectedModelInfo }}
</VAlert>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="wizardData.agent.maxContextTokens"
:label="t('setting.system.llmMaxContextTokens')"
:hint="t('setting.system.llmMaxContextTokensHint')"
:error="validationErrors.agent.maxContextTokens"
:error-messages="
validationErrors.agent.maxContextTokens ? [t('setupWizard.agent.maxContextTokensRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-counter"
type="number"
min="1"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.thinkingLevel"
:label="t('setting.system.llmThinking')"
:hint="t('setting.system.llmThinkingHint')"
:items="thinkingLevelItems"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportImageInput"
:label="t('setting.system.llmSupportImageInput')"
:hint="t('setting.system.llmSupportImageInputHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportAudioInputOutput"
:label="t('setting.system.llmSupportAudioInputOutput')"
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.supportAudioInputOutput">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceApiKey"
:label="t('setting.system.aiVoiceApiKey')"
:hint="t('setting.system.aiVoiceApiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceBaseUrl"
:label="t('setting.system.aiVoiceBaseUrl')"
:hint="t('setting.system.aiVoiceBaseUrlHint')"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceSttModel"
:label="t('setting.system.aiVoiceSttModel')"
:hint="t('setting.system.aiVoiceSttModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsModel"
:label="t('setting.system.aiVoiceTtsModel')"
:hint="t('setting.system.aiVoiceTtsModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsVoice"
:label="t('setting.system.aiVoiceTtsVoice')"
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
persistent-hint
prepend-inner-icon="mdi-account-voice"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceLanguage"
:label="t('setting.system.aiVoiceLanguage')"
:hint="t('setting.system.aiVoiceLanguageHint')"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.voiceReplyWithText"
:label="t('setting.system.aiVoiceReplyWithText')"
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
persistent-hint
color="primary"
/>
</VCol>
</template>
<VCol cols="12">
<VSelect
v-model="wizardData.agent.jobInterval"
:label="t('setting.system.aiAgentJobInterval')"
:hint="t('setting.system.aiAgentJobIntervalHint')"
:items="jobIntervalItems"
persistent-hint
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.retryTransfer"
:label="t('setting.system.aiAgentRetryTransfer')"
:hint="t('setting.system.aiAgentRetryTransferHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.recommendEnabled"
:label="t('setting.system.aiRecommendEnabled')"
:hint="t('setting.system.aiRecommendEnabledHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol v-if="wizardData.agent.recommendEnabled" cols="12" md="6">
<VTextarea
v-model="wizardData.agent.recommendUserPreference"
:label="t('setting.system.aiRecommendUserPreference')"
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
persistent-hint
prepend-inner-icon="mdi-account-heart-outline"
rows="2"
auto-grow
/>
</VCol>
<VCol v-if="wizardData.agent.recommendEnabled" cols="12" md="6">
<VTextField
v-model.number="wizardData.agent.recommendMaxItems"
:label="t('setting.system.aiRecommendMaxItems')"
:hint="t('setting.system.aiRecommendMaxItemsHint')"
:error="validationErrors.agent.recommendMaxItems"
:error-messages="
validationErrors.agent.recommendMaxItems ? [t('setupWizard.agent.recommendMaxItemsRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-format-list-numbered"
type="number"
min="1"
/>
</VCol>
</template>
</VRow>
</VCardText>
</VCard>
<VDialog v-model="authDialogVisible" max-width="560">
<VCard>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
<VCardText class="d-flex flex-column ga-4">
<VAlert v-if="authSession?.instructions" type="info" variant="tonal">
{{ authSession.instructions }}
</VAlert>
<VAlert v-if="authPopupBlocked" type="warning" variant="tonal">
{{ t('setting.system.llmProviderPopupBlocked') }}
</VAlert>
<div v-if="authSession?.user_code">
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
<div class="text-h5 font-weight-bold">{{ authSession.user_code }}</div>
</div>
<div v-if="authSession?.message" class="text-body-2">
{{ authSession.message }}
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
{{ t('setting.system.llmProviderOpenAuthPage') }}
</VBtn>
<VBtn
variant="tonal"
prepend-icon="mdi-refresh"
:loading="authPolling"
@click="pollAuthSession"
>
{{ t('setting.system.llmProviderCheckAuthStatus') }}
</VBtn>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="text" @click="closeAuthDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -119,6 +119,16 @@ const usernameErrorMessage = computed(() => {
clearable
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.ocrHost"
:label="t('setting.system.ocrHost')"
:hint="t('setting.system.ocrHostHint')"
placeholder="https://movie-pilot.org"
persistent-hint
prepend-inner-icon="mdi-text-recognition"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.proxyHost"

View File

@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.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="wizardData.downloader.config.username"
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.username')"
:error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.password')"
:error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">

View File

@@ -345,6 +345,10 @@ const notificationTypes = [
v-model="wizardData.notification.config.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
:error="validationErrors.notification.QQ_APP_ID"
:error-messages="
validationErrors.notification.QQ_APP_ID ? [t('notification.qqbot.appIdRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-application"
/>
@@ -354,6 +358,12 @@ const notificationTypes = [
v-model="wizardData.notification.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
:error="validationErrors.notification.QQ_APP_SECRET"
:error-messages="
validationErrors.notification.QQ_APP_SECRET
? [t('notification.qqbot.appSecretRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-key"
/>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { wizardData, authSites, validationErrors } = useSetupWizard()
const siteItems = computed(() => {
return Object.keys(authSites.value).map(key => ({
key,
name: authSites.value[key].name,
prependAvatar: authSites.value[key].icon,
}))
})
const formFields = computed(() => {
const site = authSites.value[wizardData.value.siteAuth.site]
return Object.keys(site?.params || {})
.filter(key => site.params[key]?.name && site.params[key]?.type)
.map(key => ({
key,
site: wizardData.value.siteAuth.site,
name: site.params[key].name,
type: site.params[key].type,
placeholder: site.params[key].placeholder,
tooltip: site.params[key].tooltip,
}))
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.siteAuth.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.siteAuth.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.siteAuth.info') }}</VAlertTitle>
{{ t('setupWizard.siteAuth.infoDesc') }}
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.siteAuth.auxiliaryAuthEnable"
:label="t('setting.system.auxAuthEnable')"
:hint="t('setting.system.auxAuthEnableHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="wizardData.siteAuth.site"
:items="siteItems"
item-value="key"
item-title="name"
item-props
:label="t('dialog.userAuth.selectSite')"
:hint="t('setupWizard.siteAuth.selectSiteHint')"
:error="validationErrors.siteAuth.site"
:error-messages="validationErrors.siteAuth.site ? [t('dialog.userAuth.selectSiteRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-web"
clearable
/>
</VCol>
<template v-if="wizardData.siteAuth.site">
<VCol cols="12">
<VAlert type="warning" variant="tonal">
{{ t('setupWizard.siteAuth.submitHint') }}
</VAlert>
</VCol>
<VCol v-for="param in formFields" :key="param.key" cols="12" md="6">
<VTextField
v-model="wizardData.siteAuth.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:type="param.type"
:label="param.name"
:placeholder="param.placeholder"
:hint="param.tooltip"
:error="validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:error-messages="
validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]
? [t('setupWizard.siteAuth.fieldRequired', { name: param.name })]
: []
"
clearable
persistent-hint
/>
</VCol>
</template>
</VRow>
</VCardText>
</VCard>
</template>

View File

@@ -393,17 +393,15 @@ useDynamicButton({
/>
<!-- 新增站点按钮 -->
<Teleport to="body" v-if="route.path === '/site'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-web-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="siteAddDialog = true"
/>
</div>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog

View File

@@ -6,21 +6,13 @@ import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import { useUserStore } from '@/stores'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
// 国际化
const { t } = useI18n()
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 用户 Store
const userStore = useUserStore()
@@ -185,6 +177,10 @@ function historyDone() {
fetchData()
}
function openHistoryDialog() {
historyDialog.value = true
}
// 批量管理相关函数
// 切换批量模式
function toggleBatchMode() {
@@ -381,12 +377,8 @@ onActivated(async () => {
}
})
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-history',
onClick: () => {
historyDialog.value = true
},
defineExpose({
openHistoryDialog,
})
</script>
@@ -477,23 +469,6 @@ useDynamicButton({
:error-title="errorTitle"
:error-description="errorDescription"
/>
<!-- 底部操作按钮 -->
<Teleport to="body" v-if="route.path.startsWith(`/subscribe/${props.type === '电影' ? 'movie' : 'tv'}`)">
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
</Teleport>
<!-- 历史记录弹窗 -->
<SubscribeHistoryDialog
v-if="historyDialog"

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,10 @@ const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
const messageKeys = new Set<string>()
// 是否完成加载
const isLoaded = ref(false)
@@ -29,18 +33,72 @@ const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 生成消息签名
function getMessageKey(message: Message) {
return [
message.action ?? '',
message.userid ?? '',
message.reg_time ?? '',
message.date ?? '',
message.title ?? '',
message.text ?? '',
message.image ?? '',
message.link ?? '',
message.note ?? '',
].join('::')
}
// 排序消息列表,确保最新消息始终位于底部
function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
}
// 记录最新消息时间
function updateLastTime(message: Message) {
const messageTime = getMessageTime(message)
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
lastTime.value = messageTime
}
}
// 合并消息到当前列表
function mergeMessages(items: Message[]) {
let hasNewMessage = false
for (const item of sortMessages(items)) {
const messageKey = getMessageKey(item)
if (messageKeys.has(messageKey)) {
continue
}
messageKeys.add(messageKey)
messages.value.push(item)
updateLastTime(item)
hasNewMessage = true
}
if (hasNewMessage) {
messages.value = sortMessages(messages.value)
}
return hasNewMessage
}
// SSE消息处理函数
function handleSSEMessage(event: MessageEvent) {
const message = event.data
if (message) {
const object = JSON.parse(message)
// 使用reg_time或date字段进行比较
const messageTime = object.reg_time || object.date
if (compareTime(messageTime, lastTime.value) <= 0) return
messages.value.push(object)
nextTick(() => {
emit('scroll') // 新消息到达时触发智能滚动
})
if (mergeMessages([object])) {
nextTick(() => {
emit('scroll') // 新消息到达时触发智能滚动
})
}
}
}
@@ -75,28 +133,10 @@ async function loadMessages({ done }: { done: any }) {
// 已加载过
isLoaded.value = true
if (currData.value.length > 0) {
// 按时间排序,确保最新的消息在最后
currData.value.sort((a, b) => {
const timeA = a.reg_time || a.date || ''
const timeB = b.reg_time || b.date || ''
return compareTime(timeA, timeB)
})
// 取最后一条时间为存量消息最新时间
const lastMessage = currData.value[currData.value.length - 1]
lastTime.value = lastMessage.reg_time || lastMessage.date || ''
// 合并数据并重新排序
const allMessages = [...currData.value, ...messages.value]
allMessages.sort((a, b) => {
const timeA = a.reg_time || a.date || ''
const timeB = b.reg_time || b.date || ''
return compareTime(timeA, timeB)
})
messages.value = allMessages
const hasNewMessage = mergeMessages(currData.value)
// 首次加载时滚动到底部
if (page.value === 1) {
if (page.value === 1 && hasNewMessage) {
nextTick(() => {
emit('scroll')
})
@@ -118,6 +158,26 @@ async function loadMessages({ done }: { done: any }) {
}
}
// 主动刷新最新一页消息作为SSE偶发丢流时的兜底
async function refreshLatestMessages() {
try {
const latestMessages = (await api.get('message/web', {
params: {
page: 1,
size: 20,
},
})) as Message[]
if (mergeMessages(latestMessages)) {
nextTick(() => {
emit('scroll')
})
}
} catch (error) {
console.error('刷新最新消息失败:', error)
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
function compareTime(time1: string, time2: string) {
if (!time1 && !time2) return 0
@@ -160,14 +220,19 @@ function pauseSSE() {
// 恢复SSE连接
function resumeSSE() {
if (manager) {
// 先移除再重建监听确保恢复时拿到一条新的SSE连接。
manager.removeMessageListener('message-view')
manager.addMessageListener('message-view', handleSSEMessage)
}
refreshLatestMessages()
}
// 暴露方法给父组件
defineExpose({
pauseSSE,
resumeSSE,
refreshLatestMessages,
})
onMounted(() => {
@@ -194,7 +259,7 @@ onMounted(() => {
<div>
<div
v-for="(msg, index) in messages"
:key="index"
:key="getMessageKey(msg) || index"
class="chat-group d-flex mt-5 mb-8"
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>

View File

@@ -14,164 +14,28 @@ interface Status {
Doing?: string
}
interface TargetItem {
id: string
icon: string
name: string
}
interface Address {
id: string
image: string
name: string
url: string
proxy: boolean
status: keyof Status
time: string
message: string
btndisable: boolean
include?: string
}
// 测试集
const targets = ref<Address[]>([
{
image: getLogoUrl('tmdb'),
name: 'api.themoviedb.org',
url: 'https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
status: 'Normal',
time: '',
message: '',
btndisable: false,
},
{
image: getLogoUrl('tmdb'),
name: 'api.tmdb.org',
url: 'https://api.tmdb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('tmdb'),
name: 'www.themoviedb.org',
url: 'https://www.themoviedb.org',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: tvdb,
name: 'api.thetvdb.com',
url: 'https://api.thetvdb.com/series/81189',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('fanart'),
name: 'webservice.fanart.tv',
url: 'https://webservice.fanart.tv',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('telegram'),
name: 'api.telegram.org',
url: 'https://api.telegram.org',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('wechat'),
name: 'qyapi.weixin.qq.com',
url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('douban'),
name: 'frodo.douban.com',
url: 'https://frodo.douban.com',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('slack'),
name: 'slack.com',
url: 'https://slack.com',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('python'),
name: 'pypi.org',
url: '{PIP_PROXY}rsa/',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'pypi:repository-version',
},
{
image: getLogoUrl('github'),
name: 'github.com',
url: '{GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/blob/v2/README.md',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'MoviePilot',
},
{
image: getLogoUrl('github'),
name: 'codeload.github.com',
url: 'https://codeload.github.com',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('github'),
name: 'api.github.com',
url: 'https://api.github.com',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('github'),
name: 'raw.githubusercontent.com',
url: '{GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'MoviePilot',
},
])
function resolveTargetImage(icon: string) {
if (icon === 'tvdb') return tvdb
return getLogoUrl(icon)
}
const targets = ref<Address[]>([])
const resolveStatusColor: Status = {
OK: 'success',
@@ -183,13 +47,36 @@ const resolveStatusColor: Status = {
const abortControllers = new Set<AbortController>()
const isUnmounting = ref(false)
async function loadTargets() {
// 测试项由后端下发,前端只负责展示,避免再把可测试目标和校验规则留在客户端。
const result: { [key: string]: any } = await api.get('system/nettest/targets')
if (!result.success || !Array.isArray(result.data)) {
targets.value = []
return
}
targets.value = result.data.map((item: TargetItem) => ({
id: item.id,
image: resolveTargetImage(item.icon),
name: item.name,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
}))
}
// 调用API测试网络连接
async function netTest(index: number) {
const target = targets.value[index]
if (!target) return
// 页面切换时需要主动中止请求,否则自动轮询中的旧请求会回写已卸载页面状态。
const abortController = new AbortController()
abortControllers.add(abortController)
try {
const abortController = new AbortController()
abortControllers.add(abortController)
const { signal } = abortController
const target = targets.value[index]
target.btndisable = true
target.status = 'Doing'
@@ -197,15 +84,11 @@ async function netTest(index: number) {
const result: { [key: string]: any } = await api.get('system/nettest', {
params: {
url: target.url,
proxy: target.proxy,
include: target.include,
target_id: target.id,
},
signal,
})
abortControllers.delete(abortController)
if (result.success) {
target.status = 'OK'
target.message = t('netTest.normal')
@@ -216,13 +99,21 @@ async function netTest(index: number) {
target.time = result.data?.time
target.btndisable = false
} catch (error) {
console.error(error)
if (!isUnmounting.value) {
target.status = 'Fail'
target.message = error instanceof Error ? error.message : t('netTest.notTested')
target.btndisable = false
}
} finally {
abortControllers.delete(abortController)
}
}
// 加载时测试所有连接
onMounted(async () => {
isUnmounting.value = false
await loadTargets()
// 逐个串行测试,避免同时触发过多外部请求导致结果受限流或代理抖动影响。
for (let i = 0; !isUnmounting.value && i < targets.value.length; i++) await netTest(i)
})
onBeforeUnmount(() => {
@@ -236,7 +127,7 @@ onBeforeUnmount(() => {
<template>
<VList lines="two" rounded>
<template v-for="(target, index) of targets" :key="target.name">
<template v-for="(target, index) of targets" :key="target.id">
<VListItem>
<template #prepend>
<VAvatar :image="target.image" />

View File

@@ -99,17 +99,15 @@ useDynamicButton({
<!-- 新增用户按钮 -->
<Teleport to="body" v-if="route.path === '/user'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-account-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddUserDialog"
/>
</div>
</Teleport>
<!-- 用户添加弹窗 -->

View File

@@ -5,8 +5,6 @@ import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
// 国际化
const { t } = useI18n()
@@ -14,12 +12,6 @@ const { t } = useI18n()
// 是否刷新
const isRefreshed = ref(false)
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 新增对话框
const addDialog = ref(false)
@@ -54,14 +46,6 @@ function addDone() {
fetchData()
}
// 使用动态按钮钩子 新增
useDynamicButton({
icon: 'mdi-plus',
onClick: () => {
addDialog.value = true
},
})
onMounted(() => {
loadEventTypes()
fetchData()
@@ -70,6 +54,14 @@ onMounted(() => {
onActivated(() => {
fetchData()
})
function openAddDialog() {
addDialog.value = true
}
defineExpose({
openAddDialog,
})
</script>
<template>
<div>
@@ -83,20 +75,6 @@ onActivated(() => {
:error-title="t('workflow.noWorkflow')"
:error-description="t('workflow.noWorkflowDescription')"
/>
<!-- 新增按钮 -->
<Teleport to="body" v-if="route.path === '/workflow'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
</Teleport>
<!-- 新增对话框 -->
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
</div>

View File

@@ -30,6 +30,7 @@ const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
const currentKey = ref(0)
// 是否加载中
const loading = ref(false)
@@ -53,6 +54,17 @@ async function loadEventTypes() {
}
}
watch(
() => props.keyword,
newKeyword => {
keyword.value = newKeyword || ''
page.value = 1
dataList.value = []
isRefreshed.value = false
currentKey.value++
},
)
// 拼装参数
function getParams() {
let params = {
@@ -141,7 +153,7 @@ onActivated(() => {
<template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData">
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">