mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-07 16:53:20 +08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 | ||
|
|
23d7f0dcc1 | ||
|
|
288aeed178 | ||
|
|
9a9a618136 | ||
|
|
723eb319e1 | ||
|
|
96684a8d13 | ||
|
|
fc9fe5e21e | ||
|
|
24b763d808 | ||
|
|
f761cdff00 | ||
|
|
b785769138 | ||
|
|
6d1febd70a | ||
|
|
bdbaf503ca | ||
|
|
f9e74cf436 | ||
|
|
e043669a10 | ||
|
|
78d8fdba9d | ||
|
|
5c0f0386a6 | ||
|
|
30b39283b6 | ||
|
|
de84c39d2f | ||
|
|
65152e7e37 | ||
|
|
ba343ce5fa | ||
|
|
60495668a6 | ||
|
|
f2ac624dbb | ||
|
|
6238849d3f | ||
|
|
82cb903c1f | ||
|
|
5e5eb95b55 | ||
|
|
74e6f8b03e | ||
|
|
a2bf0d2b16 | ||
|
|
7532d39978 | ||
|
|
5cc9bf7418 | ||
|
|
20bdb940cd | ||
|
|
e9b214cff8 | ||
|
|
54f5fb2877 | ||
|
|
e86cb9e1cc | ||
|
|
3f258b9016 | ||
|
|
b54e144d0e | ||
|
|
7b20a7b775 | ||
|
|
df66b3e917 | ||
|
|
a919622d08 | ||
|
|
2a9ce950b7 | ||
|
|
48c12b765d | ||
|
|
1120055eed | ||
|
|
c66b6649e2 | ||
|
|
8479099926 | ||
|
|
cab65be1c9 | ||
|
|
6689e976c2 | ||
|
|
712dfa3fe1 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.10.1",
|
||||
"version": "2.10.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -101,19 +101,21 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -195,7 +195,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +204,8 @@ watch(
|
||||
:label="t('directory.alias')"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,15 +45,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VAutocomplete
|
||||
|
||||
@@ -205,22 +205,24 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 += '/'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
// 返回控制函数和状态
|
||||
|
||||
380
src/composables/useLlmProviderDirectory.ts
Normal file
380
src/composables/useLlmProviderDirectory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,21 @@ export interface WizardData {
|
||||
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
|
||||
@@ -97,6 +107,7 @@ export interface ValidationErrorState {
|
||||
downloader: {
|
||||
name: boolean
|
||||
host: boolean
|
||||
apikey: boolean
|
||||
username: boolean
|
||||
password: boolean
|
||||
}
|
||||
@@ -121,6 +132,33 @@ export interface ValidationErrorState {
|
||||
}
|
||||
}
|
||||
|
||||
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 = 8
|
||||
@@ -195,11 +233,21 @@ const wizardData = ref<WizardData>({
|
||||
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,
|
||||
@@ -230,6 +278,7 @@ const validationErrors = ref<ValidationErrorState>({
|
||||
downloader: {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
},
|
||||
@@ -419,6 +468,7 @@ export function useSetupWizard() {
|
||||
validationErrors.value.downloader = {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
}
|
||||
@@ -501,9 +551,18 @@ 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()) {
|
||||
@@ -672,8 +731,8 @@ export function useSetupWizard() {
|
||||
validationErrors.value.agent.provider = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.apiKey?.trim()) {
|
||||
errors.push(t('setupWizard.agent.apiKeyRequired'))
|
||||
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
|
||||
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
|
||||
validationErrors.value.agent.apiKey = true
|
||||
}
|
||||
|
||||
@@ -1332,10 +1391,19 @@ export function useSetupWizard() {
|
||||
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:
|
||||
@@ -1428,11 +1496,21 @@ export function useSetupWizard() {
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
@@ -3213,7 +3295,8 @@ export default {
|
||||
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.',
|
||||
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}',
|
||||
},
|
||||
@@ -3289,6 +3372,7 @@ export default {
|
||||
'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',
|
||||
|
||||
@@ -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: '强制继续',
|
||||
@@ -3250,6 +3320,7 @@ export default {
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
|
||||
@@ -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: '強制繼續',
|
||||
@@ -3251,6 +3322,7 @@ export default {
|
||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
providerRequired: 'LLM 提供商不能為空',
|
||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||
modelRequired: 'LLM 模型名稱不能為空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -1,20 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { useSetupWizard } from '@/composables/useSetupWizard'
|
||||
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const { wizardData, validationErrors } = useSetupWizard()
|
||||
|
||||
const llmModels = ref<string[]>([])
|
||||
const loadingModels = ref(false)
|
||||
const providerRef = computed({
|
||||
get: () => wizardData.value.agent.provider,
|
||||
set: value => {
|
||||
wizardData.value.agent.provider = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const providerItems = [
|
||||
{ title: 'OpenAI', value: 'openai' },
|
||||
{ title: 'Google', value: 'google' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
]
|
||||
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 },
|
||||
@@ -27,37 +96,72 @@ const jobIntervalItems = computed(() => [
|
||||
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
|
||||
])
|
||||
|
||||
async function loadLlmModels() {
|
||||
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
|
||||
return
|
||||
}
|
||||
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' },
|
||||
])
|
||||
|
||||
loadingModels.value = true
|
||||
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 {
|
||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
||||
params: {
|
||||
provider: wizardData.value.agent.provider,
|
||||
api_key: wizardData.value.agent.apiKey,
|
||||
base_url: wizardData.value.agent.baseUrl,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
llmModels.value = result.data || []
|
||||
if (!wizardData.value.agent.model && llmModels.value.length > 0) {
|
||||
wizardData.value.agent.model = llmModels.value[0]
|
||||
}
|
||||
}
|
||||
await loadModels(forceRefresh)
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(error))
|
||||
console.log('Load LLM models failed:', error)
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
|
||||
loadLlmModels()
|
||||
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>
|
||||
@@ -89,7 +193,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
|
||||
<template v-if="wizardData.agent.enabled">
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="3">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.global"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
@@ -99,7 +203,7 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="3">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.verbose"
|
||||
:label="t('setting.system.aiAgentVerbose')"
|
||||
@@ -109,60 +213,109 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportImageInput"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
<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 cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.baseUrl"
|
||||
<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="https://api.deepseek.com"
|
||||
: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 cols="12" md="6">
|
||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.apiKey"
|
||||
:label="t('setting.system.llmApiKey')"
|
||||
:hint="t('setting.system.llmApiKeyHint')"
|
||||
: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.apiKeyRequired')] : []"
|
||||
: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
|
||||
v-model="wizardData.agent.model"
|
||||
: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')] : []"
|
||||
@@ -174,11 +327,15 @@ onMounted(() => {
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
|
||||
@click="loadLlmModels"
|
||||
: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">
|
||||
@@ -198,6 +355,110 @@ onMounted(() => {
|
||||
</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')"
|
||||
@@ -259,4 +520,48 @@ onMounted(() => {
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'"
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 用户添加弹窗 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user