Smail/frontend/src/views/components/SystemNotification/SystemNotification.js
smail 9933a0c35f 优化系统发信管理弹窗
将账号配置改为管理弹窗,增加管理员查看授权码入口,并在账号列表下方安全展示授权码。
2026-09-27 16:20:45 +08:00

268 lines
10 KiB
JavaScript

import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessageBox, ElNotification } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication'
export function usePage(props = {}) {
const globalStatus = useGlobalStatusStore()
const manager = computed(() => globalStatus.hasCapability('notification.manage'))
const loading = ref(false)
const submitting = ref(false)
const error = ref('')
const status = ref(null)
const accounts = ref([])
const page = ref(1)
const total = ref(0)
const selectedID = ref(Number(props.userId || globalStatus.userInfos?.id || 0))
const form = reactive({ enabled: false, minute_limit: 10, day_limit: 100 })
const secret = ref('')
const secretAccount = ref('')
const secretVisible = ref(false)
const seconds = ref(0)
const verifyVisible = ref(false)
const verifyPassword = ref('')
const manageVisible = ref(false)
let verifyTarget = null
let secretSequence = 0
let secretTimer = null
let requestSequence = 0
let accountsSequence = 0
let disposed = false
const canManage = computed(() => manager.value && Boolean(status.value?.can_manage))
const canReveal = computed(() => Boolean(status.value?.can_reveal))
const stateText = computed(() => {
if (!status.value?.enabled) return '未开启'
if (!status.value.account_available) return '账号不可用'
return status.value.configured ? '已开启,可发信' : '已开启,待生成凭据'
})
const dirty = computed(() => status.value && (
form.enabled !== status.value.enabled || form.minute_limit !== status.value.minute_limit || form.day_limit !== status.value.day_limit
))
const dataOf = result => {
if (result?.errorNo !== 0) throw new Error(result?.errorMsg || '操作失败')
return result.data
}
const showError = cause => {
if (isRecentAuthenticationCancelled(cause) || cause === 'cancel' || cause === 'close') return
ElNotification({ title: '操作失败', message: cause?.message || '请稍后重试', type: 'error' })
}
const clearSecret = () => {
secretSequence += 1
if (secretTimer) clearInterval(secretTimer)
secretTimer = null
secret.value = ''
secretAccount.value = ''
seconds.value = 0
secretVisible.value = false
}
const clearVerification = () => {
verifyPassword.value = ''
verifyTarget = null
verifyVisible.value = false
}
const showSecret = (code, account) => {
clearSecret()
if (!code || document.hidden) return
secret.value = code
secretAccount.value = account
secretVisible.value = true
const expiresAt = Date.now() + 60000
seconds.value = 60
secretTimer = setInterval(() => {
seconds.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
if (seconds.value === 0) clearSecret()
}, 250)
}
const openVerify = () => {
if (!canReveal.value || loading.value || submitting.value || dirty.value) return
clearSecret()
verifyPassword.value = ''
verifyTarget = status.value
verifyVisible.value = true
}
const openManage = async row => {
if (loading.value || submitting.value || !(await beforeLeave())) return
clearSecret()
status.value = null
selectedID.value = row.user_id
await loadStatus()
if (!disposed) manageVisible.value = true
}
const closeManage = async () => {
if (!(await beforeLeave())) return false
manageVisible.value = false
clearSecret()
return true
}
const revealAccount = async row => {
if (loading.value || submitting.value || !row.configured || !(await beforeLeave())) return
clearSecret()
status.value = null
selectedID.value = row.user_id
await loadStatus()
if (disposed || !status.value || status.value.user_id !== row.user_id) return
if (canReveal.value) openVerify()
}
const verifyAndReveal = async () => {
if (!verifyTarget || !verifyPassword.value || submitting.value || !canReveal.value) return
const target = verifyTarget
const sequence = ++secretSequence
submitting.value = true
try {
const data = dataOf(await http.post('/api/v1/system/notification/credential/reveal', {
user_id: target.user_id, version: target.version, password: verifyPassword.value
}))
if (disposed || sequence !== secretSequence || target.user_id !== status.value?.user_id) return
clearVerification()
showSecret(data.code, target.account)
} catch (cause) {
if (disposed || sequence !== secretSequence) return
verifyPassword.value = ''
showError(cause)
await Promise.all([loadStatus(), loadAccounts()])
if (disposed || sequence !== secretSequence) return
if (!canReveal.value || target.user_id !== status.value?.user_id || target.version !== status.value?.version) {
clearSecret()
clearVerification()
}
} finally {
verifyPassword.value = ''
submitting.value = false
}
}
const hideOnBackground = () => {
if (!document.hidden) return
clearSecret()
clearVerification()
}
const loadStatus = async () => {
const sequence = ++requestSequence
loading.value = true
error.value = ''
try {
const data = dataOf(await http.get('/api/v1/system/notification/status', { params: { user_id: selectedID.value || undefined } }))
if (disposed || sequence !== requestSequence) return
status.value = data
Object.assign(form, { enabled: data.enabled, minute_limit: data.minute_limit, day_limit: data.day_limit })
} catch (cause) {
if (disposed || sequence !== requestSequence) return
status.value = null
error.value = cause?.message || '读取通知发信状态失败'
} finally {
if (!disposed && sequence === requestSequence) loading.value = false
}
}
const loadAccounts = async () => {
if (!manager.value || props.userId) return
const sequence = ++accountsSequence
try {
const data = dataOf(await http.get('/api/v1/system/notification/accounts', { params: { page: page.value } }))
if (disposed || sequence !== accountsSequence) return
accounts.value = data.list || []
total.value = data.total || 0
} catch (cause) {
if (!disposed && sequence === accountsSequence) {
accounts.value = []
showError(cause)
}
}
}
const beforeLeave = async () => {
if (submitting.value) return false
if (!dirty.value) return true
try {
await ElMessageBox.confirm('通知授权尚未保存,是否放弃本次修改?', '离开确认', {
confirmButtonText: '放弃修改', cancelButtonText: '继续编辑', type: 'warning'
})
return true
} catch { return false }
}
const selectAccount = async row => {
if (loading.value || !(await beforeLeave())) return
clearSecret()
status.value = null
selectedID.value = row.user_id
await loadStatus()
}
const refresh = async () => {
if (!(await beforeLeave())) return
clearSecret()
await Promise.all([loadStatus(), loadAccounts()])
}
const save = async () => {
if (!canManage.value || !status.value || submitting.value) return
const target = status.value
const input = { user_id: target.user_id, version: target.version, ...form }
if (input.day_limit < input.minute_limit) {
showError(new Error('每日限额不能小于每分钟限额'))
return
}
try {
await ElMessageBox.confirm(
input.enabled ? `保存账号「${target.account}」的通知发信授权与限额?` : `关闭账号「${target.account}」的通知发信后,旧凭据将永久撤销;普通收发信不受影响。`,
'确认通知授权', { confirmButtonText: '确认保存', cancelButtonText: '取消', type: 'warning' }
)
submitting.value = true
dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/authorization', input)))
clearSecret()
ElNotification({ title: '已保存', message: '账号通知授权已更新', type: 'success' })
await Promise.all([loadStatus(), loadAccounts()])
} catch (cause) {
showError(cause)
} finally { submitting.value = false }
}
const rotate = async () => {
if (!canManage.value || !status.value?.enabled || submitting.value || dirty.value) return
const target = status.value
try {
await ElMessageBox.confirm(`为「${target.account}」生成新凭据?旧通知凭据和旧连接将失效,普通邮箱凭据不变。`, '确认重置凭据', {
confirmButtonText: '生成新凭据', cancelButtonText: '取消', type: 'warning'
})
submitting.value = true
const sequence = ++secretSequence
const data = dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/credential', {
user_id: target.user_id, version: target.version
})))
if (disposed) return
if (sequence === secretSequence) showSecret(data.code, target.account)
await Promise.all([loadStatus(), loadAccounts()])
} catch (cause) { showError(cause) }
finally { submitting.value = false }
}
const copySecret = async () => {
if (!secret.value) return
try {
await navigator.clipboard.writeText(secret.value)
ElNotification({ title: '已复制', message: '请安全交付给账号使用者,不要放入日志或代码仓库', type: 'success' })
} catch { showError(new Error('复制失败,请手动复制授权码')) }
}
onMounted(() => {
loadStatus()
loadAccounts()
document.addEventListener('visibilitychange', hideOnBackground)
})
onUnmounted(() => {
disposed = true
requestSequence += 1
accountsSequence += 1
clearSecret()
clearVerification()
manageVisible.value = false
document.removeEventListener('visibilitychange', hideOnBackground)
})
return {
manager, loading, submitting, error, status, accounts, page, total, form,
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
canReveal, verifyVisible, verifyPassword, openVerify, verifyAndReveal, clearVerification,
loadAccounts, selectAccount: openManage, openManage, closeManage, revealAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave,
manageVisible
}
}