@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
// 正文加载失败不阻塞元数据详情
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? '已禁用' : '启用中' }}
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
映射用户身份
|
||||
|
||||
Reference in New Issue
Block a user