mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-08 01:03:27 +08:00
Compare commits
40 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 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.10.2",
|
||||
"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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
@@ -1557,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',
|
||||
@@ -2801,6 +2869,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: 'Assistant Organize',
|
||||
aiRedoPending: 'Assistant Organizing...',
|
||||
batchAiRedo: 'Assistant Batch Organize',
|
||||
redo: 'Reorganize',
|
||||
delete: 'Delete',
|
||||
batchRedo: 'Batch Reorganize',
|
||||
@@ -2864,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',
|
||||
@@ -3224,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}',
|
||||
},
|
||||
@@ -3300,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: '定时唤醒',
|
||||
@@ -2765,6 +2822,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '删除',
|
||||
batchRedo: '批量重新整理',
|
||||
@@ -2828,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: '强制继续',
|
||||
@@ -3260,6 +3320,7 @@ export default {
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
|
||||
@@ -994,6 +994,7 @@ export default {
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
refreshSearch: '重新搜尋',
|
||||
},
|
||||
browse: {
|
||||
actor: '演員',
|
||||
@@ -1230,6 +1231,17 @@ export default {
|
||||
content: '內容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日誌內容',
|
||||
allLevels: '全部級別',
|
||||
followTail: '跟隨最新日誌',
|
||||
wrapLines: '自動換行',
|
||||
pauseStream: '暫停日誌流',
|
||||
resumeStream: '恢復日誌流',
|
||||
waitingForLogs: '等待日誌輸出...',
|
||||
paused: '已暫停',
|
||||
connected: '實時更新中',
|
||||
lineCount: '顯示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1319,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 限制',
|
||||
@@ -1334,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: '定時喚醒',
|
||||
@@ -2767,6 +2824,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '刪除',
|
||||
batchRedo: '批量重新整理',
|
||||
@@ -2830,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: '強制繼續',
|
||||
@@ -3262,6 +3322,7 @@ export default {
|
||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
providerRequired: 'LLM 提供商不能為空',
|
||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||
modelRequired: 'LLM 模型名稱不能為空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,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[]>([])
|
||||
@@ -374,6 +374,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
|
||||
|
||||
// 批量删除记录
|
||||
async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
||||
if (hasRunningAiRedo.value) return
|
||||
// 关闭弹窗
|
||||
deleteConfirmDialog.value = false
|
||||
// 总条数
|
||||
@@ -409,6 +410,7 @@ async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
|
||||
|
||||
// 批量删除历史记录
|
||||
async function removeHistoryBatch() {
|
||||
if (hasRunningAiRedo.value) return
|
||||
if (selected.value.length === 0) return
|
||||
|
||||
// 清空当前操作记录
|
||||
@@ -421,6 +423,7 @@ async function removeHistoryBatch() {
|
||||
}
|
||||
// 批量重新整理
|
||||
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 [
|
||||
@@ -645,7 +690,7 @@ const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chev
|
||||
const historyDynamicMenuItems = computed(() => {
|
||||
if (selected.value.length === 0) return undefined
|
||||
|
||||
return [
|
||||
const items: Array<{ titleKey: string; icon: string; action: () => void; color?: string }> = [
|
||||
{
|
||||
titleKey: 'dialog.transferQueue.title',
|
||||
icon: 'mdi-timer-sand-paused',
|
||||
@@ -653,22 +698,36 @@ const historyDynamicMenuItems = computed(() => {
|
||||
transferQueueDialog.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchRedo',
|
||||
icon: 'mdi-redo-variant',
|
||||
action: () => {
|
||||
retransferBatch()
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchDelete',
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
action: () => {
|
||||
removeHistoryBatch()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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({
|
||||
@@ -980,7 +1039,7 @@ onUnmounted(() => {
|
||||
<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"
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
@@ -989,7 +1048,7 @@ onUnmounted(() => {
|
||||
@click="removeHistoryBatch"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-redo-variant"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
@@ -997,6 +1056,15 @@ onUnmounted(() => {
|
||||
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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
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'"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user