进一步精简配置保存流程
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-07 17:50:06 +08:00
parent 659d98b589
commit a7ad2bc32e
8 changed files with 26 additions and 21 deletions

View File

@ -25,6 +25,7 @@ func TestReauthenticateCurrentUser(t *testing.T) {
oldSession := session.Instance oldSession := session.Instance
oldUserAttempts := reauthUserAttempts oldUserAttempts := reauthUserAttempts
oldIPAttempts := reauthIPAttempts oldIPAttempts := reauthIPAttempts
testRoot := t.TempDir()
t.Cleanup(func() { t.Cleanup(func() {
if db.Instance != nil && db.Instance != oldDB { if db.Instance != nil && db.Instance != oldDB {
_ = db.Instance.Close() _ = db.Instance.Close()
@ -36,7 +37,7 @@ func TestReauthenticateCurrentUser(t *testing.T) {
reauthIPAttempts = oldIPAttempts reauthIPAttempts = oldIPAttempts
}) })
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "reauth.db")} config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "reauth.db")}
if err := db.Init(""); err != nil { if err := db.Init(""); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -64,6 +64,8 @@ function isAuthenticationRefreshRequest(config) {
} }
function isAuthenticationFailureError(error) { function isAuthenticationFailureError(error) {
const errorCode = error?.response?.data?.data?.code
if (errorCode === 'RECENT_AUTH_REQUIRED') return false
return error?.response?.data?.errorNo === 403 return error?.response?.data?.errorNo === 403
|| (error?.response?.status === 403 && isAuthenticationRefreshRequest(error.config)) || (error?.response?.status === 403 && isAuthenticationRefreshRequest(error.config))
} }
@ -71,6 +73,7 @@ function isAuthenticationFailureError(error) {
http.interceptors.response.use(async (response) => { http.interceptors.response.use(async (response) => {
//响应成功 //响应成功
if (response.data?.errorNo === 403 if (response.data?.errorNo === 403
&& response.data?.data?.code !== 'RECENT_AUTH_REQUIRED'
&& !isSetupRequest(response.config) && !isSetupRequest(response.config)
&& !isAuthenticationRefreshRequest(response.config)) { && !isAuthenticationRefreshRequest(response.config)) {
await handleLoginRedirect(); await handleLoginRedirect();

View File

@ -18,12 +18,12 @@ export async function requestWithRecentAuthentication(request) {
let password = '' let password = ''
try { try {
const result = await ElMessageBox.prompt( const result = await ElMessageBox.prompt(
'为保护敏感配置,请确认当前登录密码。确认后将自动继续应用。', '为保护敏感配置,请确认当前登录密码。确认后将自动完成保存。',
'确认当前密码', '确认当前密码',
{ {
inputType: 'password', inputType: 'password',
inputPlaceholder: '请输入当前密码', inputPlaceholder: '请输入当前密码',
confirmButtonText: '确认并继续', confirmButtonText: '确认并保存',
cancelButtonText: '取消', cancelButtonText: '取消',
closeOnClickModal: false, closeOnClickModal: false,
inputValidator: value => String(value || '').length > 0 || '请输入当前密码' inputValidator: value => String(value || '').length > 0 || '请输入当前密码'

View File

@ -78,7 +78,7 @@
<div v-if="canWrite" class="editor-footer"> <div v-if="canWrite" class="editor-footer">
<el-alert <el-alert
v-if="pendingCandidate" v-if="pendingCandidate"
title="有一项配置尚未生效,可以继续应用或放弃更改。" title="上次保存尚未完成,可以继续保存或放弃本次更改。"
type="warning" type="warning"
:closable="false" :closable="false"
show-icon show-icon
@ -98,7 +98,7 @@
:disabled="sectionLoading || !!working || !!sectionError || !hasChanges" :disabled="sectionLoading || !!working || !!sectionError || !hasChanges"
@click="saveAndApply" @click="saveAndApply"
> >
保存并应用 保存配置
</el-button> </el-button>
<el-button <el-button
v-else v-else
@ -107,7 +107,7 @@
:disabled="sectionLoading || !!working || !!sectionError" :disabled="sectionLoading || !!working || !!sectionError"
@click="continuePending" @click="continuePending"
> >
继续应用 继续保存
</el-button> </el-button>
<el-button <el-button
v-if="pendingCandidate" v-if="pendingCandidate"

View File

@ -82,7 +82,7 @@ export function usePage(pageKey = 'base') {
const scope = pending.payload && loadedText.value const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的插件运行配置未变化。') ? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的插件运行配置未变化。')
: '候选内容尚未完整读取,请刷新后核对。' : '候选内容尚未完整读取,请刷新后核对。'
return `${scope}三个插件共用一个配置分区,请在对应三级菜单核对;应用时整个候选一起生效,其他插件的原有配置会保留。` return `${scope}三个插件共用一组运行配置,请在对应三级菜单核对;完成保存时会统一生效,其他插件的原有配置会保留。`
} }
if (pending?.section !== 'mail' || apiSection() !== 'mail') return '' if (pending?.section !== 'mail' || apiSection() !== 'mail') return ''
const active = readFormConfig(loadedText.value) const active = readFormConfig(loadedText.value)
@ -101,7 +101,7 @@ export function usePage(pageKey = 'base') {
const scope = pending.payload && loadedText.value const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的邮件服务与安全策略未变化。') ? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的邮件服务与安全策略未变化。')
: '候选内容尚未完整读取,请刷新后核对。' : '候选内容尚未完整读取,请刷新后核对。'
return `${scope}邮件服务页和安全策略页共用一个候选配置;应用时全部变更一起生效,监听端口变更仍需重启后端。` return `${scope}邮件服务页和安全策略页共用一组运行配置;完成保存时全部变更一起生效,监听端口变更仍需重启后端。`
}) })
let savedInput = null let savedInput = null
const validationResult = ref(null) const validationResult = ref(null)
@ -577,7 +577,7 @@ export function usePage(pageKey = 'base') {
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] } validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus() await loadStatus()
await loadPending() await loadPending()
if (!quiet) ElMessage.success('配置已保存,等待应用') if (!quiet) ElMessage.success('配置已暂存,正在完成保存')
return true return true
} catch (error) { } catch (error) {
if (isRecentAuthenticationCancelled(error)) return false if (isRecentAuthenticationCancelled(error)) return false
@ -625,7 +625,7 @@ export function usePage(pageKey = 'base') {
return return
} }
const saved = await saveConfig({ quiet: true }) const saved = await saveConfig({ quiet: true })
if (saved) await applyConfig({ successMessage: '配置已保存并应用' }) if (saved) await applyConfig({ successMessage: '配置已保存' })
} }
const continuePending = async () => { const continuePending = async () => {
@ -716,7 +716,7 @@ export function usePage(pageKey = 'base') {
if (!hasChanges.value && !migrationToken.value && !rollbackPreview.value) return true if (!hasChanges.value && !migrationToken.value && !rollbackPreview.value) return true
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'当前页有编辑内容、待应用候选配置或操作预览。离开会清除本页状态,但不会撤销已保存到服务器的候选配置。确认离开吗?', '当前页有编辑内容、未完成的保存或操作预览。离开会清除本页状态,但不会撤销已暂存到服务器的更改。确认离开吗?',
'确认离开当前页', '确认离开当前页',
{ type: 'warning', confirmButtonText: '离开页面', cancelButtonText: '继续编辑' } { type: 'warning', confirmButtonText: '离开页面', cancelButtonText: '继续编辑' }
) )

View File

@ -131,7 +131,7 @@ export function useSpamBlockSettings() {
savedInput = JSON.stringify(settings) savedInput = JSON.stringify(settings)
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] } validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await load() await load()
if (!quiet) ElMessage.success('垃圾邮件插件配置已保存,等待应用') if (!quiet) ElMessage.success('垃圾邮件插件配置已暂存,正在完成保存')
return true return true
} catch (error) { } catch (error) {
if (isRecentAuthenticationCancelled(error)) return false if (isRecentAuthenticationCancelled(error)) return false
@ -201,7 +201,7 @@ export function useSpamBlockSettings() {
return return
} }
const saved = await save({ quiet: true }) const saved = await save({ quiet: true })
if (saved) await apply({ successMessage: '垃圾邮件插件配置已保存并应用' }) if (saved) await apply({ successMessage: '垃圾邮件插件配置已保存' })
} }
const continuePending = async () => { const continuePending = async () => {

View File

@ -25,7 +25,7 @@
show-icon show-icon
/> />
<el-alert v-if="pendingCandidate" title="有一项配置尚未生效,可以继续应用或放弃更改。" type="warning" :closable="false" show-icon /> <el-alert v-if="pendingCandidate" title="上次保存尚未完成,可以继续保存或放弃本次更改。" type="warning" :closable="false" show-icon />
<el-form label-position="top" class="spam-form"> <el-form label-position="top" class="spam-form">
<el-form-item label="模型 API 接口地址"> <el-form-item label="模型 API 接口地址">
<el-input <el-input
@ -68,7 +68,7 @@
:disabled="!canWrite() || !!working || loading || !hasChanges" :disabled="!canWrite() || !!working || loading || !hasChanges"
@click="saveAndApply" @click="saveAndApply"
> >
保存并应用 保存配置
</el-button> </el-button>
<el-button <el-button
v-else v-else
@ -77,7 +77,7 @@
:disabled="!canWrite() || !!working || loading" :disabled="!canWrite() || !!working || loading"
@click="continuePending" @click="continuePending"
> >
继续应用 继续保存
</el-button> </el-button>
<el-button v-if="canWrite() && pendingCandidate" type="danger" plain :disabled="!!working || pendingCandidate.committed" @click="managePending('cancel')">放弃更改</el-button> <el-button v-if="canWrite() && pendingCandidate" type="danger" plain :disabled="!!working || pendingCandidate.committed" @click="managePending('cancel')">放弃更改</el-button>
</div> </div>

View File

@ -9,6 +9,7 @@ const formSource = readFileSync(new URL('../../frontend/src/views/components/Con
const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')() const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')()
const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8') const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '') .replace(/^import .*$/gm, '')
.replaceAll('export async function ', 'async function ')
.replaceAll('export function ', 'function ') .replaceAll('export function ', 'function ')
const compileRecentAuthentication = compileFunction( const compileRecentAuthentication = compileFunction(
recentAuthenticationSource + '\nreturn { isRecentAuthenticationCancelled, requestWithRecentAuthentication }', recentAuthenticationSource + '\nreturn { isRecentAuthenticationCancelled, requestWithRecentAuthentication }',
@ -429,7 +430,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.doesNotMatch(template, /&#x[0-9a-f]+;/i) assert.doesNotMatch(template, /&#x[0-9a-f]+;/i)
}) })
test('配置编辑页将验证、候选保存和应用精简为单一操作', () => { test('配置编辑页将验证、候选保存和应用精简为单一保存操作', () => {
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8') const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const sharedCss = readFileSync(new URL('ConfigSettings.css', root), 'utf8') const sharedCss = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
@ -437,9 +438,9 @@ test('配置编辑页将验证、候选保存和应用精简为单一操作', ()
assert.doesNotMatch(template, /change-workflow|step-number|配置变更流程/) assert.doesNotMatch(template, /change-workflow|step-number|配置变更流程/)
assert.doesNotMatch(sharedCss + editorCss, /change-workflow|step-number/) assert.doesNotMatch(sharedCss + editorCss, /change-workflow|step-number/)
assert.match(template, /@click="saveAndApply"/) assert.match(template, /@click="saveAndApply"/)
assert.match(template, /保存并应用/) assert.match(template, /保存配置/)
assert.match(template, /@click="continuePending"/) assert.match(template, /@click="continuePending"/)
assert.match(template, /继续应用/) assert.match(template, /继续保存/)
assert.match(template, /v-if="canWrite" class="editor-footer"/) assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /@click="managePending\('cancel'\)"/) assert.match(template, /@click="managePending\('cancel'\)"/)
assert.match(template, /放弃更改/) assert.match(template, /放弃更改/)
@ -857,7 +858,7 @@ test('邮件页与安全策略页共享候选并明确提示改动范围,忽
assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/) assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/)
page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } } page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } }
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 监听、邮件与账号策略。/) assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 监听、邮件与账号策略。/)
assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一个候选配置/) assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一组运行配置/)
page.pendingCandidate.value = { section: 'smtp' } page.pendingCandidate.value = { section: 'smtp' }
assert.equal(page.pendingScopeHint.value, '') assert.equal(page.pendingScopeHint.value, '')
page.pendingCandidate.value = { section: 'mail' } page.pendingCandidate.value = { section: 'mail' }
@ -924,7 +925,7 @@ for (const key of ['spam_block', 'telegram_push', 'wechat_push']) {
const restored = fixture(() => {}, other, { http }).page const restored = fixture(() => {}, other, { http }).page
await restored.loadAll() await restored.loadAll()
assert.equal(restored.savedGeneration.value, 2) assert.equal(restored.savedGeneration.value, 2)
assert.match(restored.pendingScopeHint.value, /三个插件共用一个配置分区/) assert.match(restored.pendingScopeHint.value, /三个插件共用一组运行配置/)
assert.ok(restored.pendingScopeHint.value.includes(page.currentSection.value.label)) assert.ok(restored.pendingScopeHint.value.includes(page.currentSection.value.label))
await restored.applyConfig() await restored.applyConfig()
assert.ok(requests.includes('/api/v1/config/plugins/apply')) assert.ok(requests.includes('/api/v1/config/plugins/apply'))