修复全量审查问题;接入vitest;精简IdP图标逻辑
CI / test (push) Successful in 46s

This commit is contained in:
2026-07-22 16:51:45 +08:00
parent efcb84eaa8
commit 119f60516c
39 changed files with 1164 additions and 797 deletions
+22 -25
View File
@@ -129,34 +129,31 @@ export function unbindIdentity(id: number): Promise<SessionRefresh> {
// ---- OAuth provider 配置(设置页) ----
const mockOauth: OAuthSettings = {
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
}
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
})
if (mockOn) return mocked({ ...mockOauth })
return request('/settings/oauth')
}
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: body.oidcIssuer,
oidcClientId: body.oidcClientId,
oidcSecretSet: !!body.oidcClientSecret,
oidcDisplayName: body.oidcDisplayName,
oidcDisabled: body.oidcDisabled,
githubClientId: body.githubClientId,
githubSecretSet: !!body.githubClientSecret,
githubDisplayName: body.githubDisplayName,
githubDisabled: body.githubDisabled,
})
return request('/settings/oauth', { method: 'PUT', body })
if (mockOn) {
const { oidcClientSecret, githubClientSecret, ...rest } = body
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
return mocked({ ...mockOauth })
}
return request('/settings/oauth', { method: 'PATCH', body })
}
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { request, ApiError } from './request'
import { useAuthStore } from '@/stores/auth'
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
function setPageURL(url: string) {
;(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url)
}
/** 构造最小 401 响应;rotate 在响应返回前换发新会话,模拟 OAuth 绑定回跳 */
function stub401(rotateTo?: string) {
vi.stubGlobal('fetch', async () => {
if (rotateTo !== undefined) useAuthStore().setSession(rotateTo, '')
return {
ok: false,
status: 401,
url: '/api/v1/x',
json: async () => ({ error: '未认证' }),
} as unknown as Response
})
}
let assignSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('401 令牌快照', () => {
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
setPageURL('http://localhost/instances?tab=list')
useAuthStore().setSession('tokA', '')
stub401()
await expect(request('/x')).rejects.toMatchObject({ status: 401, message: '未认证' })
expect(useAuthStore().token).toBe('')
expect(assignSpy).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent('/instances?tab=list')}`,
)
})
it('响应到达前会话已换新:旧 token 的迟到 401 不得清掉新会话', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
stub401('tokB')
await expect(request('/x')).rejects.toBeInstanceOf(ApiError)
expect(useAuthStore().token).toBe('tokB')
expect(assignSpy).not.toHaveBeenCalled()
})
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub401()
await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({
message: '未认证',
})
expect(assignSpy).not.toHaveBeenCalled()
})
})
+19 -4
View File
@@ -32,7 +32,7 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response): Promise<never> {
async function parseError(resp: Response, sentToken: string): Promise<never> {
let message = `请求失败(${resp.status}`
let ociCode: string | undefined
try {
@@ -54,7 +54,7 @@ async function parseError(resp: Response): Promise<never> {
} catch {
/* 非 JSON 响应体,保留默认消息 */
}
if (resp.status === 401) useAuthStore().logout()
if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
@@ -62,17 +62,31 @@ async function parseError(resp: Response): Promise<never> {
throw new ApiError(resp.status, message, ociCode)
}
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
function handleUnauthorized(sentToken: string) {
const auth = useAuthStore()
if (sentToken !== auth.token) return
auth.logout()
if (location.pathname !== '/login') {
const redirect = encodeURIComponent(location.pathname + location.search)
location.assign(`/login?redirect=${redirect}`)
}
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) await parseError(resp, sentToken)
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
@@ -91,12 +105,13 @@ export async function rawFetch(
const auth = useAuthStore()
const headers: Record<string, string> = { ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body,
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) await parseError(resp, sentToken)
return resp
}
+4 -4
View File
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
return request('/settings/security')
}
/** 保存安全设置,返回最新;越界或非法后端返回 400,保存后立即生效 */
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
if (mockOn) {
Object.assign(mockSecuritySetting, body)
Object.assign(mockSecuritySetting, patch)
return mocked({ ...mockSecuritySetting }, 400)
}
return request('/settings/security', { method: 'PUT', body })
return request('/settings/security', { method: 'PATCH', body: patch })
}
export interface SystemLogParams {
+2 -5
View File
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
return request(`/tasks/${id}/logs`, { query: { limit } })
}
export function runTask(id: number): Promise<TaskLog> {
if (mockOn) {
const logs = mockTaskLogs[id]
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
}
export function runTask(id: number): Promise<{ triggered: boolean }> {
if (mockOn) return mocked({ triggered: true }, 300)
return request(`/tasks/${id}/run`, { method: 'POST' })
}
+30 -2
View File
@@ -10,7 +10,7 @@ import {
mockUserDetails,
mockUsers,
} from './mock'
import { mockOn, mocked, request } from './request'
import { mockOn, mocked, rawFetch, request } from './request'
import type {
AuditEventsResult,
IamUser,
@@ -18,6 +18,7 @@ import type {
IamUserDetail,
IdentityProvider,
IdentitySetting,
IdpIconUpload,
LimitItem,
LimitService,
NotificationRecipients,
@@ -298,7 +299,7 @@ export interface CreateIdpBody {
name: string
metadata: string
description?: string
/** logodata URI 或外链 URL */
/** logohttp(s) 外链 URL(可先经 IdP 图标上传接口取得) */
iconUrl?: string
nameIdFormat?: string
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
@@ -325,6 +326,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
}
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
export async function uploadIdpIcon(
id: number,
file: File,
domainId?: string,
): Promise<IdpIconUpload> {
if (mockOn)
return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600)
const fd = new FormData()
fd.append('file', file, file.name)
const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, {
method: 'POST',
query: { domainId },
body: fd,
})
return (await resp.json()) as IdpIconUpload
}
/** 删除尚未被 IdP 采用的身份域公共图片 */
export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/idp-icons`, {
method: 'DELETE',
query: { domainId, fileName },
})
}
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
+15 -1
View File
@@ -35,21 +35,34 @@ let ws: WebSocket | null = null
let ro: ResizeObserver | null = null
let sessionId = ''
const encoder = new TextEncoder()
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
// B 实际连着 A,用户输入发往错误实例
let openSeq = 0
async function open() {
const seq = ++openSeq
phase.value = 'creating'
error.value = ''
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
let created = ''
try {
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
sessionId = res.sessionId
created = res.sessionId
} catch (e) {
if (seq !== openSeq) return
phase.value = 'closed'
error.value = e instanceof Error ? e.message : '创建串行会话失败'
return
}
if (seq !== openSeq) {
void deleteConsoleSession(created).catch(() => undefined)
return
}
sessionId = created
phase.value = 'connecting'
await nextTick()
if (seq !== openSeq) return
mountTerm()
connectWs()
}
@@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) {
}
function teardown() {
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
ro?.disconnect()
ro = null
if (ws) {
+11 -2
View File
@@ -41,16 +41,22 @@ const pagination = reactive({
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch {
// 自动刷新失败静默,手动路径由全局兜底
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
@@ -106,12 +112,15 @@ const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null)
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
let contentSeq = 0
async function loadContent(callLogId: number) {
const seq = ++contentSeq
contentLog.value = null
try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
contentLog.value = items[0] ?? null
if (seq === contentSeq) contentLog.value = items[0] ?? null
} catch {
// 正文加载失败不阻塞元数据详情
}
+7 -1
View File
@@ -64,7 +64,12 @@ const pagination = reactive({
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listLogEvents({
@@ -72,12 +77,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize,
cfgId: cfgFilter.value ?? undefined,
})
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
+7 -1
View File
@@ -48,7 +48,12 @@ const pagination = reactive({
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listSystemLogs({
@@ -56,12 +61,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize,
keyword: applied.value || undefined,
})
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
+67 -18
View File
@@ -69,9 +69,23 @@ const result = useAsync(async () => {
})
}, false)
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), {
immediate: true,
})
// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置;
// 层内进目录/翻页只重查列表
watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope())
watch([prefix, cursor], () => void result.run(), { immediate: true })
/** 作用域变化后重置路径、游标、选中项与弹窗再重查:旧列表配新作用域时,
* 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */
function resetScope() {
prefix.value = ''
filterInput.value = ''
cursorStack.value = []
cursor.value = ''
checkedKeys.value = []
showPar.value = false
showViewer.value = false
void result.run()
}
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
const POLL_MS = 30_000
@@ -220,14 +234,25 @@ async function cleanupPar(parId: string) {
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
function willBrowserHandle(size: number): boolean {
return !size || size > DOWN_STREAM_LIMIT
}
function download(name: string, size?: number) {
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
return doDownload(task, name, known)
// 预开窗口必须在点击手势内同步进行:PAR 签发后再 window.open 已脱离手势,
// 会被弹窗策略拦截(重试按钮点击同样是手势,拦截后可恢复)
const preWin = willBrowserHandle(known) ? window.open('', '_blank') : null
return doDownload(task, name, known, preWin)
}
async function doDownload(task: TransferTask, name: string, size: number) {
if (!props.cfgId) return
async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) {
if (!props.cfgId) {
closeIfBlank(preWin)
return
}
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
@@ -235,34 +260,50 @@ async function doDownload(task: TransferTask, name: string, size: number) {
accessType: 'ObjectRead',
expiresHours: 1,
})
const cleanable = await transferDown(task, par.fullUrl!, size)
const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
task.status = 'done'
task.percent = 100
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
if (cleanable) void cleanupPar(par.id)
else parPanel.value?.refresh()
} catch (err) {
// 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页
closeIfBlank(preWin)
if ((err as DOMException)?.name === 'AbortError') return
task.status = 'error'
task.error = err instanceof Error ? err.message : '下载失败'
}
}
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
function closeIfBlank(preWin: Window | null) {
if (preWin && !preWin.closed) preWin.close()
}
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
async function transferDown(
task: TransferTask,
url: string,
size: number,
preWin: Window | null,
): Promise<boolean> {
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
try {
const blob = await fetchWithProgress(task, url, size)
saveBlob(blob, task.name)
return true
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') throw err
return browserOpen(task, url)
// 流式失败回退浏览器接管:此处已脱离手势无预开窗口,被拦会如实报错
return browserOpen(task, url, null)
}
}
function browserOpen(task: TransferTask, url: string): boolean {
window.open(url, '_blank')
/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean {
const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank')
if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试')
if (win === preWin) win.location.replace(url)
task.note = '已交由浏览器下载'
return false
}
@@ -360,12 +401,19 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
})
const parUrl = ref('')
const parIsBucket = computed(() => !parForm.objectName)
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24
const parExpiresValid = computed(() => {
const hours = parForm.expiresHours
return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS
})
/** 有效期快捷项;也可直接输入 1 至 876000 小时 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
{ label: '1 天', value: 24 },
{ label: '7 天', value: 168 },
{ label: '30 天', value: 720 },
{ label: '1 年', value: 8760 },
{ label: '10 年', value: 87600 },
]
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
@@ -380,14 +428,15 @@ function openParModal(objectName: string) {
}
async function doCreatePar() {
if (!props.cfgId || !parForm.expiresHours) return
const expiresHours = parForm.expiresHours
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
parBusy.value = true
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: parForm.objectName,
accessType: parForm.accessType,
expiresHours: parForm.expiresHours,
expiresHours,
})
parUrl.value = par.fullUrl ?? ''
parPanel.value?.refresh()
@@ -848,7 +897,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
:title="parIsBucket ? '分享桶' : '分享对象'"
:width="460"
:submitting="parBusy"
:submit-disabled="!!parUrl || !parForm.expiresHours"
:submit-disabled="!!parUrl || !parExpiresValid"
submit-text="签发"
@update:show="showPar = $event"
@submit="doCreatePar"
@@ -862,12 +911,12 @@ const columns = computed<DataTableColumns<Row>>(() => [
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
</NRadioGroup>
</FormField>
<FormField label="有效期" hint="OCI 上限 30 (720 小时)">
<FormField label="有效期" hint="最长 100 876000 小时">
<div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber
v-model:value="parForm.expiresHours"
:min="1"
:max="720"
:max="PAR_MAX_EXPIRES_HOURS"
:precision="0"
size="small"
class="!w-24"
@@ -115,8 +115,10 @@ watch(
return
}
reset()
const seq = ++contentSeq
void detail.run().then(() => {
if (!blockedReason.value) void loadContent()
// detail 是共享 useAsync:换对象后旧 then 链仍会执行,按代次拦下
if (seq === contentSeq && !blockedReason.value) void loadContent()
})
},
)
@@ -145,19 +147,25 @@ function revokeImage() {
imageUrl.value = ''
}
// 内容加载代次:换对象/关闭即自增,旧对象的迟到响应按代次丢弃——
// 否则 A 的正文与 ETag 会顶着 B 的标题展示,编辑保存还会拿错基线
let contentSeq = 0
async function loadContent() {
if (!props.cfgId || loading.value) return
const seq = ++contentSeq
loading.value = true
loadError.value = ''
try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
if (seq !== contentSeq) return
contentEtag.value = c.etag
contentType.value = c.contentType
await applyContent(c.blob)
} catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败'
if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally {
loading.value = false
if (seq === contentSeq) loading.value = false
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ const pars = useAsync<ParPage>(async () => {
const items = computed(() => pars.data.value?.items ?? [])
watch(
[() => props.cfgId, () => props.bucket],
// region 必须在依赖里:切区域后旧列表配新区域,撤销会删错区域的同名 PAR
[() => props.cfgId, () => props.region, () => props.bucket],
() => {
cursorStack.value = []
cursor.value = ''
@@ -322,23 +322,8 @@ const secretConfigured = computed(() => {
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
})
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest {
const cfg = oauthCfg.data.value
return {
oidcIssuer: cfg?.oidcIssuer ?? '',
oidcClientId: cfg?.oidcClientId ?? '',
oidcDisplayName: cfg?.oidcDisplayName ?? '',
oidcDisabled: cfg?.oidcDisabled ?? false,
githubClientId: cfg?.githubClientId ?? '',
githubDisplayName: cfg?.githubDisplayName ?? '',
githubDisabled: cfg?.githubDisabled ?? false,
...patch,
}
}
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
function providerFormPatch(): Partial<UpdateOAuthRequest> {
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
function providerFormPatch(): UpdateOAuthRequest {
const name = pForm.displayName.trim()
const id = pForm.clientId.trim()
if (pForm.provider === 'oidc')
@@ -358,7 +343,7 @@ function providerFormPatch(): Partial<UpdateOAuthRequest> {
async function saveProvider() {
savingOauth.value = true
try {
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
await updateOAuthSettings(providerFormPatch())
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
showProviderModal.value = false
void oauthCfg.run({ silent: true })
@@ -374,7 +359,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
savingOauth.value = true
try {
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
await updateOAuthSettings(oauthPayloadWith(patch))
await updateOAuthSettings(patch)
message.success(`${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
void oauthCfg.run({ silent: true })
} catch (e) {
@@ -393,7 +378,7 @@ async function deleteProvider(p: ProviderType) {
p === 'oidc'
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
await updateOAuthSettings(oauthPayloadWith(patch))
await updateOAuthSettings(patch)
message.success(`登录方式「${name}」已删除`)
void oauthCfg.run({ silent: true })
} catch (e) {
@@ -570,7 +555,7 @@ async function deleteProvider(p: ProviderType) {
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
>
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
{{ row.disabled ? '已禁用' : '启用中' }}
+3 -1
View File
@@ -70,7 +70,9 @@ watch(
if (t.type === 'snatch') {
const inst = (p.instance ?? {}) as Record<string, unknown>
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
form.count = typeof p.count === 'number' ? p.count : 1
// 表单编辑的是目标台数:优先 totalCount,旧任务缺省回退剩余台数
const target = typeof p.totalCount === 'number' ? p.totalCount : p.count
form.count = typeof target === 'number' ? target : 1
form.displayName = String(inst.displayName ?? '')
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
+7 -3
View File
@@ -182,7 +182,11 @@ function rowProps(row: AuditEvent) {
}
}
// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情
let rawSeq = 0
async function loadRaw(row: AuditEvent) {
const seq = ++rawSeq
detailRaw.value = null
rawGone.value = false
if (!row.eventId || !row.eventTime) {
@@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
eventId: row.eventId,
eventTime: row.eventTime,
})
detailRaw.value = raw
if (seq === rawSeq) detailRaw.value = raw
} catch {
rawGone.value = true
if (seq === rawSeq) rawGone.value = true
} finally {
rawLoading.value = false
if (seq === rawSeq) rawLoading.value = false
}
}
+168 -32
View File
@@ -1,12 +1,19 @@
<script setup lang="ts">
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
import { NButton, NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
import {
createIdp,
deleteIdpIcon,
getIdentitySetting,
uploadIdpIcon,
type CreateIdpBody,
} from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
import type { IdpIconUpload } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
@@ -15,6 +22,7 @@ const message = useToast()
const submitting = ref(false)
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
const primaryEmailRequired = ref(false)
const identitySettingReady = ref(false)
const nameIdFormatOptions = [
{ label: '无', value: 'saml-none' },
@@ -51,22 +59,66 @@ const blank = {
}
const form = reactive({ ...blank })
// ---- 图标上传:传到身份域公共图片存储(/storage/v1/Images),回填公网 URL ----
const iconFileEl = ref<HTMLInputElement | null>(null)
const iconUploading = ref(false)
// 最近上传且尚未被创建采用的图标;替换、放弃表单或提交未引用时尽力删除。
// 删除失败只会在自己租户里留下一张无入口的小图,静默接受,不打扰用户。
let pendingIcon: (IdpIconUpload & { cfgId: number; domainId?: string }) | null = null
let identitySettingSeq = 0
// 图片加载失败时回退占位,避免预览框里出现浏览器裂图
const iconError = ref(false)
watch(
() => props.show,
(v) => {
if (!v) return
Object.assign(form, blank)
void loadIdentitySetting()
},
() => form.iconUrl,
() => (iconError.value = false),
)
// 上传请求序号:重开弹窗或重新选文件即自增,旧请求的响应按序号丢弃
let iconUploadSeq = 0
function invalidateIconUpload() {
iconUploadSeq++
iconUploading.value = false
}
function cleanupPendingIcon() {
const icon = pendingIcon
pendingIcon = null
if (icon) void deleteIdpIcon(icon.cfgId, icon.fileName, icon.domainId).catch(() => {})
}
function abandonForm() {
identitySettingSeq++
identitySettingReady.value = false
invalidateIconUpload()
cleanupPendingIcon()
}
function resetForm() {
abandonForm()
primaryEmailRequired.value = false
Object.assign(form, blank)
void loadIdentitySetting()
}
watch(
() => [props.show, props.cfgId, props.domainId] as const,
([show]) => (show ? resetForm() : abandonForm()),
{ immediate: true },
)
// show/cfgId/domainId 任一变化都会经 abandonForm 递增序号,故过期只需比对序号
async function loadIdentitySetting() {
const seq = ++identitySettingSeq
identitySettingReady.value = false
try {
const s = await getIdentitySetting(props.cfgId)
const s = await getIdentitySetting(props.cfgId, props.domainId)
if (seq !== identitySettingSeq) return
primaryEmailRequired.value = s.primaryEmailRequired
if (s.primaryEmailRequired) form.jitMapEmail = true
} catch {
primaryEmailRequired.value = false
if (seq === identitySettingSeq) primaryEmailRequired.value = false
} finally {
if (seq === identitySettingSeq) identitySettingReady.value = true
}
}
@@ -76,11 +128,60 @@ const iconUrlValid = computed(() => {
return !v || /^https?:\/\//.test(v)
})
async function onIconPick(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || submitting.value) return
if (file.size > 1 << 20) {
message.error('图标不能超过 1MB')
return
}
const { cfgId, domainId } = props
const seq = ++iconUploadSeq
iconUploading.value = true
try {
const result = await uploadIdpIcon(cfgId, file, domainId)
if (seq !== iconUploadSeq) {
void deleteIdpIcon(cfgId, result.fileName, domainId).catch(() => {})
return
}
cleanupPendingIcon()
pendingIcon = { ...result, cfgId, domainId }
form.iconUrl = result.url
message.success('图标已上传到身份域公共存储')
} catch (err) {
if (seq === iconUploadSeq) message.error(err instanceof Error ? err.message : '上传失败')
} finally {
if (seq === iconUploadSeq) iconUploading.value = false
}
}
function onIconUrlInput(value: string) {
if (submitting.value || value === form.iconUrl) return
invalidateIconUpload()
form.iconUrl = value
}
function closeForm() {
abandonForm()
emit('update:show', false)
}
function onModalShowChange(show: boolean) {
if (!show && submitting.value) return
if (show) emit('update:show', true)
else closeForm()
}
const canSubmit = computed(
() =>
!!form.name.trim() &&
form.metadata.includes('EntityDescriptor') &&
iconUrlValid.value &&
!iconUploading.value &&
identitySettingReady.value &&
!submitting.value &&
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
)
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
}
async function submit() {
if (submitting.value || !canSubmit.value) return
const body = buildBody()
submitting.value = true
try {
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
emit('update:show', false)
const created = await createIdp(props.cfgId, body, props.domainId)
// 创建请求引用了刚上传的图标即视为已采用,交给 IdP 生命周期,不再清理
if (pendingIcon && pendingIcon.url === body.iconUrl) pendingIcon = null
const warning = created.setupWarning
if (warning)
message.warning(`IdP「${created.name}」的创建结果需确认`, `${warning.message}\n关联 ID${warning.requestId}`)
else message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
closeForm()
emit('created')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
@@ -124,12 +232,17 @@ async function submit() {
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建禁用态"
@update:show="emit('update:show', $event)"
@update:show="onModalShowChange"
@submit="submit"
>
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" />
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" />
</FormField>
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
</div>
<FormField
label="IdP SAML metadata XML 文件"
required
@@ -153,26 +266,49 @@ async function submit() {
@load="(text) => (form.metadata = text)"
/>
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<FormField
label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
>
<div class="flex items-center gap-2">
<FormField
label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
>
<div class="flex items-center gap-2">
<div
class="flex h-[34px] w-[34px] flex-none items-center justify-center overflow-hidden rounded border border-line-soft bg-wash"
>
<img
v-if="form.iconUrl && iconUrlValid"
v-if="form.iconUrl && iconUrlValid && !iconError"
:src="form.iconUrl"
alt=""
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
class="h-full w-full object-contain"
@error="iconError = true"
/>
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
<span v-else class="text-[10px] text-ink-3 select-none">Logo</span>
</div>
</FormField>
</div>
<NInput
:value="form.iconUrl"
:disabled="submitting"
placeholder="https://…/logo.png,或点右侧上传"
class="min-w-0 flex-1"
@update:value="onIconUrlInput"
/>
<NButton
class="flex-none"
:loading="iconUploading"
:disabled="submitting"
@click="iconFileEl?.click()"
>
上传
</NButton>
<input
ref="iconFileEl"
type="file"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
:disabled="submitting"
class="hidden"
@change="onIconPick"
/>
</div>
</FormField>
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
映射用户身份
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { useAsync } from './useAsync'
/** 手动控制解析时机的 fetcher,复现请求乱序返回 */
function deferredFetcher<T>() {
const pending: Array<{ resolve: (v: T) => void; reject: (e: unknown) => void }> = []
const fetcher = () =>
new Promise<T>((resolve, reject) => {
pending.push({ resolve, reject })
})
return { fetcher, pending }
}
describe('useAsync 并发与乱序', () => {
it('后发请求先返回时,先发请求的迟到结果被丢弃', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
const p2 = st.run()
pending[1].resolve('新')
await p2
pending[0].resolve('旧')
await p1
expect(st.data.value).toBe('新')
expect(st.loading.value).toBe(false)
})
it('迟到的失败不覆盖最新成功结果', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
const p2 = st.run()
pending[1].resolve('新')
await p2
pending[0].reject(new Error('旧请求失败'))
await p1
expect(st.data.value).toBe('新')
expect(st.error.value).toBe('')
})
it('silent 刷新不置 loading,普通刷新置 loading', async () => {
const { fetcher, pending } = deferredFetcher<number>()
const st = useAsync(fetcher, false)
const p1 = st.run({ silent: true })
expect(st.loading.value).toBe(false)
pending[0].resolve(1)
await p1
const p2 = st.run()
expect(st.loading.value).toBe(true)
pending[1].resolve(2)
await p2
expect(st.loading.value).toBe(false)
expect(st.data.value).toBe(2)
})
it('失败时记录 error 并清 loading', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
pending[0].reject(new Error('boom'))
await p1
expect(st.error.value).toBe('boom')
expect(st.loading.value).toBe(false)
expect(st.data.value).toBeNull()
})
})
+2 -2
View File
@@ -100,8 +100,8 @@ const router = createRouter({
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed) return { name: 'overview' }
if (!to.meta.public && !auth.isAuthed()) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed()) return { name: 'overview' }
return true
})
+49
View File
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from './auth'
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
describe('isAuthed 实时过期判定', () => {
const cases = [
{ name: '无 token 未登录', token: '', expiresAt: '', want: false },
{ name: '有 token 无过期时间视为已登录', token: 't', expiresAt: '', want: true },
{
name: '未过期',
token: 't',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
want: true,
},
{
name: '已过期(computed 缓存会误判的场景)',
token: 't',
expiresAt: new Date(Date.now() - 1_000).toISOString(),
want: false,
},
]
for (const c of cases) {
it(c.name, () => {
const auth = useAuthStore()
if (c.token) auth.setSession(c.token, c.expiresAt)
expect(auth.isAuthed()).toBe(c.want)
})
}
})
it('logout 清空会话与本地存储', () => {
const auth = useAuthStore()
auth.setSession('t', new Date(Date.now() + 60_000).toISOString())
auth.logout()
expect(auth.token).toBe('')
expect(auth.isAuthed()).toBe(false)
expect(localStorage.getItem('oci-portal.token')).toBeNull()
})
it('setSession 落地本地存储,新 store 实例可恢复', () => {
useAuthStore().setSession('t2', '')
setActivePinia(createPinia())
expect(useAuthStore().token).toBe('t2')
})
+5 -3
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ref } from 'vue'
const TOKEN_KEY = 'oci-portal.token'
const EXPIRES_KEY = 'oci-portal.expiresAt'
@@ -8,11 +8,13 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
const isAuthed = computed(() => {
// 普通函数而非 computed:computed 只随 token/expiresAt 失效,时间流逝不会
// 触发重算,令牌自然过期后路由守卫会长期误判仍已登录
function isAuthed(): boolean {
if (!token.value) return false
if (!expiresAt.value) return true
return new Date(expiresAt.value).getTime() > Date.now()
})
}
function setSession(newToken: string, newExpiresAt: string) {
token.value = newToken
+31 -8
View File
@@ -66,17 +66,17 @@ export interface OAuthSettings {
githubDisabled: boolean
}
/** 保存 provider 配置;secret 缺省沿用、空串清除 */
/** provider 配置字段补丁:缺省字段沿用现值;secret 空串清除、缺省沿用 */
export interface UpdateOAuthRequest {
oidcIssuer: string
oidcClientId: string
oidcIssuer?: string
oidcClientId?: string
oidcClientSecret?: string
oidcDisplayName: string
oidcDisabled: boolean
githubClientId: string
oidcDisplayName?: string
oidcDisabled?: boolean
githubClientId?: string
githubClientSecret?: string
githubDisplayName: string
githubDisabled: boolean
githubDisplayName?: string
githubDisabled?: boolean
}
/** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */
@@ -699,6 +699,13 @@ export interface IdentitySetting {
}
// ---- Federation ----
export interface IdpSetupWarning {
code: 'JIT_SETUP_INCOMPLETE'
message: string
resourceCreated: true
requestId: string
}
export interface IdentityProvider {
id: string
name: string
@@ -707,6 +714,14 @@ export interface IdentityProvider {
partnerProviderId: string
jitEnabled: boolean
timeCreated: string
/** 资源曾创建且回滚状态不确定;创建方必须按资源可能存在处理 */
setupWarning?: IdpSetupWarning
}
/** IdP 图标上传结果;fileName 为身份域存储内标识,留作后续清理 */
export interface IdpIconUpload {
url: string
fileName: string
}
export interface SignOnRule {
@@ -756,6 +771,14 @@ export interface OverviewCost {
currency: string
total: number
days: OverviewCostDay[]
/** 按币种拆分的序列(合计降序);顶层 currency/total/days 恒为首个主币种,跨币种不相加 */
series: OverviewCostSeries[]
}
export interface OverviewCostSeries {
currency: string
total: number
days: OverviewCostDay[]
}
export interface OverviewTasks {
+11
View File
@@ -50,6 +50,14 @@ const currencySymbol = computed(() => {
const currency = ov.value?.cost.currency
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
})
/** 主币种之外的其余币种合计;跨币种金额不可相加,只并列展示 */
const otherCurrencyTotals = computed(() =>
(ov.value?.cost.series ?? [])
.slice(1)
.map((s) => `${s.currency} ${s.total.toFixed(2)}`)
.join('、'),
)
</script>
<template>
@@ -126,6 +134,9 @@ const currencySymbol = computed(() => {
<div class="text-[12.5px] text-ink-3">
{{ costDaily.labels[0] }} {{ costDaily.labels.at(-1) }} 合计
</div>
<div v-if="otherCurrencyTotals" class="text-[12.5px] text-ink-3">
另有 {{ otherCurrencyTotals }}跨币种不相加
</div>
</div>
<div class="px-3 pb-2">
<CostChart :labels="costDaily.labels" :values="costDaily.values" />
+24 -12
View File
@@ -42,12 +42,15 @@ const auth = useAuthStore()
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
const tab = ref('notify')
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab;
// 绑定使令牌版本递增,fragment 携带的新 token 须先落地,否则后续请求 401
// OAuth 绑定回跳:绑定使令牌版本递增,fragment 携带的新 token 必须在本组件
// 任何请求发起前(setup 顶部)落地——setup 阶段的 useAsync 会立即用旧 token
// 发请求,迟到的 401 会误清刚写入的新会话
consumeBindToken()
// 成功/失败提示后清理 query,并停在安全 tab
onMounted(() => {
const { oauth, oauthError } = route.query
if (oauth !== 'bound' && !oauthError) return
consumeBindToken()
tab.value = 'security'
if (oauth === 'bound') message.success('外部身份绑定成功')
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
@@ -244,7 +247,7 @@ function pickHeader(v: string) {
customHeaderMode.value = false
if (securityForm.value.realIpHeader === v) return
securityForm.value.realIpHeader = v
void saveSecurity()
void saveSecurity({ realIpHeader: v })
}
function pickCustomHeader() {
@@ -265,18 +268,27 @@ watch(
},
)
/** 控件变更即全量 PUT(与 AI 设置一致);失败重拉服务端值回滚 UI */
async function saveSecurity() {
/** 控件变更即发字段补丁,服务端只落库出现字段(并发编辑互不回滚);
* 失败重拉服务端值回滚 UI。在途时把补丁并入尾随一轮,完成后合并补发。 */
let pendingSecurityPatch: Partial<SecuritySetting> | null = null
async function saveSecurity(patch: Partial<SecuritySetting>) {
if (savingSecurity.value) {
pendingSecurityPatch = { ...pendingSecurityPatch, ...patch }
return
}
savingSecurity.value = true
try {
await updateSecuritySetting({ ...securityForm.value })
await updateSecuritySetting(patch)
message.success('已保存,立即生效')
void security.run({ silent: true })
if (!pendingSecurityPatch) void security.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
void security.run({ silent: true })
if (!pendingSecurityPatch) void security.run({ silent: true })
} finally {
savingSecurity.value = false
const next = pendingSecurityPatch
pendingSecurityPatch = null
if (next) void saveSecurity(next)
}
}
@@ -287,7 +299,7 @@ function commitSecurityNum(
) {
if (v == null || v === securityForm.value[key]) return
securityForm.value[key] = v
void saveSecurity()
void saveSecurity({ [key]: v })
}
async function saveTaskSetting() {
@@ -676,7 +688,7 @@ async function sendTest() {
v-model:value="securityForm.realIpHeader"
class="mono mt-2 !w-70"
placeholder="自定义头名,如 X-Client-IP"
@change="() => void saveSecurity()"
@change="() => void saveSecurity({ realIpHeader: securityForm.realIpHeader })"
/>
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
头名仅限字母数字与连字符
@@ -689,7 +701,7 @@ async function sendTest() {
<NInput
v-model:value="securityForm.appUrl"
placeholder="https://demo.example.com"
@change="() => void saveSecurity()"
@change="() => void saveSecurity({ appUrl: securityForm.appUrl })"
/>
</FormField>
</div>
+13 -3
View File
@@ -20,9 +20,19 @@ const tasks = useAsync(listTasks)
const showCreate = ref(false)
const editingTask = ref<Task | null>(null)
// 每 5 秒静默刷新不闪 loading
const refreshTimer = setInterval(() => void tasks.run({ silent: true }), 5000)
onUnmounted(() => clearInterval(refreshTimer))
// 每 5 秒静默刷新(不闪 loading);上一轮完成后再排下一轮,
// 接口变慢时不堆叠请求,也避免响应总被下一轮标记过期而饿死
let refreshTimer = 0
let refreshStopped = false
async function refreshLoop() {
await tasks.run({ silent: true })
if (!refreshStopped) refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
}
refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
onUnmounted(() => {
refreshStopped = true
clearTimeout(refreshTimer)
})
const cfgAliasById = computed(
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),