修复全量审查问题;接入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
+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"