<script setup lang="ts">
import {
  NButton,
  NDataTable,
  NDropdown,
  NInput,
  NRadio,
  NRadioGroup,
  NTabPane,
  NTabs,
  type DataTableColumns,
} from 'naive-ui'
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'

import {
  createPar,
  deleteObject,
  deletePar,
  listObjects,
  restoreObject,
  uploadViaPar,
} from '@/api/objectstorage'
import AppInputNumber from '@/components/AppInputNumber.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FileIcon from '@/components/objectstorage/FileIcon.vue'
import ObjectViewerModal from '@/components/objectstorage/ObjectViewerModal.vue'
import ParPanel from '@/components/objectstorage/ParPanel.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useIsMobile } from '@/composables/useIsMobile'
import type { Bucket, ObjectSummary } from '@/types/api'
import { useToast } from '@/composables/useToast'

const props = defineProps<{
  cfgId: number | null
  region?: string
  bucket: string
  /** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
  info?: Bucket
}>()
const emit = defineEmits<{ back: []; settings: [] }>()

const message = useToast()

/** hero 与 Tab:对象浏览 / 分享链接(PAR) */
const tab = ref<'objects' | 'par'>('objects')
/** 移动端 hero meta 默认收起,与租户/实例/VCN 详情一致 */
const isMobile = useIsMobile()
const heroExpanded = ref(false)

const PAGE_SIZE = 100
const prefix = ref('')
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
const filterInput = ref('')
/** 上页游标栈:OCI 只回传 next 游标,上一页靠历史栈回放 */
const cursorStack = ref<string[]>([])
const cursor = ref('')

const result = useAsync(async () => {
  if (!props.cfgId) return { objects: [], prefixes: [], nextStartWith: '' }
  return listObjects(props.cfgId, props.bucket, {
    region: props.region,
    prefix: prefix.value,
    startWith: cursor.value || undefined,
    limit: PAGE_SIZE,
  })
}, false)

// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 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
let pollTimer = 0
watch(
  () => (result.data.value?.objects ?? []).some((o) => o.archivalState === 'Restoring'),
  (has) => {
    window.clearInterval(pollTimer)
    if (has) pollTimer = window.setInterval(() => void result.run({ silent: true }), POLL_MS)
  },
)
onBeforeUnmount(() => window.clearInterval(pollTimer))

function enterFolder(p: string) {
  cursorStack.value = []
  cursor.value = ''
  prefix.value = p
  filterInput.value = ''
}

/** 面包屑段:桶根 + prefix 逐级 */
const crumbs = computed(() => {
  const parts = prefix.value.split('/').filter(Boolean)
  return parts.map((seg, i) => ({ label: `${seg}/`, prefix: parts.slice(0, i + 1).join('/') + '/' }))
})

/** 层级过深时仅展示末 2 段,其余收进「…」下拉,避免面包屑占满整行 */
const visibleCrumbs = computed(() =>
  crumbs.value.length > 3 ? crumbs.value.slice(-2) : crumbs.value,
)
const foldedOptions = computed(() =>
  crumbs.value.length > 3
    ? crumbs.value.slice(0, -2).map((c) => ({ label: c.prefix, key: c.prefix }))
    : [],
)

function nextPage() {
  const next = result.data.value?.nextStartWith
  if (!next) return
  cursorStack.value.push(cursor.value)
  cursor.value = next
}

function prevPage() {
  cursor.value = cursorStack.value.pop() ?? ''
}

// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
interface TransferTask {
  id: number
  kind: 'up' | 'down'
  name: string
  percent: number
  status: 'active' | 'done' | 'error'
  /** 附注:如「已交由浏览器下载」(无进度) */
  note?: string
  error?: string
  abort: () => void
  retry: () => void
}
const transfers = ref<TransferTask[]>([])
const transfersCollapsed = ref(false)
let taskSeq = 0

const activeCount = computed(() => transfers.value.filter((t) => t.status === 'active').length)
const doneCount = computed(() => transfers.value.filter((t) => t.status === 'done').length)

function newTask(kind: 'up' | 'down', name: string, retry: () => void): TransferTask {
  const task = reactive<TransferTask>({
    id: ++taskSeq, kind, name, percent: 0, status: 'active', abort: () => {}, retry,
  })
  transfers.value.push(task)
  transfersCollapsed.value = false
  return task
}

function removeTask(task: TransferTask) {
  task.abort()
  transfers.value = transfers.value.filter((t) => t !== task)
}

function retryTask(task: TransferTask) {
  transfers.value = transfers.value.filter((t) => t !== task)
  task.retry()
}

function clearDone() {
  transfers.value = transfers.value.filter((t) => t.status !== 'done')
}

// ---- 上传:PAR 直传,逐文件进度 ----
const fileInput = ref<HTMLInputElement | null>(null)

function pickFiles() {
  fileInput.value?.click()
}

async function onFilesPicked(e: Event) {
  const files = [...((e.target as HTMLInputElement).files ?? [])]
  ;(e.target as HTMLInputElement).value = ''
  await Promise.all(files.map((f) => uploadOne(f)))
  void result.run({ silent: true })
}

/** objectName 进闭包:重试时不受当前浏览层级变化影响 */
function uploadOne(file: File, objectName = prefix.value + file.name) {
  const task = newTask('up', file.name, () => uploadOne(file, objectName))
  return doUpload(task, objectName, file)
}

async function doUpload(task: TransferTask, objectName: string, file: File) {
  if (!props.cfgId) return
  let parId = ''
  try {
    const par = await createPar(props.cfgId, props.bucket, {
      region: props.region,
      objectName,
      accessType: 'ObjectWrite',
      expiresHours: 1,
    })
    parId = par.id
    const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
    task.abort = abort
    await promise
    task.status = 'done'
    task.percent = 100
  } catch (err) {
    task.status = 'error'
    task.error = err instanceof Error ? err.message : '上传失败'
  } finally {
    if (parId) void cleanupPar(parId)
  }
}

/** 上传用的一次性写链接用后即删,不留在分享链接列表;删除失败静默(1 小时自然过期) */
async function cleanupPar(parId: string) {
  try {
    await deletePar(props.cfgId!, props.bucket, parId, props.region)
  } catch {
    /* 忽略 */
  } finally {
    parPanel.value?.refresh()
  }
}

// ---- 下载:小文件 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))
  // 预开窗口必须在点击手势内同步进行: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, preWin: Window | null) {
  if (!props.cfgId) {
    closeIfBlank(preWin)
    return
  }
  try {
    const par = await createPar(props.cfgId, props.bucket, {
      region: props.region,
      objectName: name,
      accessType: 'ObjectRead',
      expiresHours: 1,
    })
    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,
  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, null)
  }
}

/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
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
}

async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise<Blob> {
  const ctrl = new AbortController()
  task.abort = () => ctrl.abort()
  const resp = await fetch(url, { signal: ctrl.signal })
  if (!resp.ok || !resp.body) throw new Error(`下载失败(HTTP ${resp.status})`)
  const total = Number(resp.headers.get('Content-Length')) || size
  const reader = resp.body.getReader()
  const chunks: BlobPart[] = []
  let loaded = 0
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    chunks.push(value)
    loaded += value.byteLength
    task.percent = Math.min(99, Math.round((loaded / total) * 100))
  }
  return new Blob(chunks)
}

function saveBlob(blob: Blob, filename: string) {
  const a = document.createElement('a')
  a.href = URL.createObjectURL(blob)
  a.download = filename
  a.click()
  window.setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
}

// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
const showNewFolder = ref(false)
const folderName = ref('')
const creatingFolder = ref(false)

async function doCreateFolder() {
  if (!props.cfgId || !folderName.value.trim()) return
  creatingFolder.value = true
  let parId = ''
  try {
    const name = prefix.value + folderName.value.trim().replace(/\/+$/, '') + '/'
    const par = await createPar(props.cfgId, props.bucket, {
      region: props.region,
      objectName: name,
      accessType: 'ObjectWrite',
      expiresHours: 1,
    })
    parId = par.id
    await uploadViaPar(par.fullUrl!, new File([], name), () => {}).promise
    message.success('文件夹已创建')
    showNewFolder.value = false
    folderName.value = ''
    void result.run()
  } catch (e) {
    message.error(e instanceof Error ? e.message : '创建失败')
  } finally {
    if (parId) void cleanupPar(parId)
    creatingFolder.value = false
  }
}

// ---- 行操作 ----
async function removeObject(name: string) {
  if (!props.cfgId) return
  try {
    await deleteObject(props.cfgId, props.bucket, name, props.region)
    message.success('对象已删除')
    checkedKeys.value = checkedKeys.value.filter((k) => k !== name)
    void result.run()
  } catch (e) {
    message.error(e instanceof Error ? e.message : '删除失败')
  }
}

async function restore(name: string) {
  if (!props.cfgId) return
  try {
    await restoreObject(props.cfgId, props.bucket, name, props.region)
    message.success('取回已提交,约 1 小时后可下载 (24 小时有效)')
    // 立刻刷出 Restoring 态,触发后台轮询
    void result.run({ silent: true })
  } catch (e) {
    message.error(e instanceof Error ? e.message : '取回失败')
  }
}

// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
const showPar = ref(false)
const parBusy = ref(false)
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
  objectName: '',
  accessType: 'ObjectRead',
  expiresHours: 24,
})
const parUrl = ref('')
const parIsBucket = computed(() => !parForm.objectName)
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)

function openParModal(objectName: string) {
  Object.assign(parForm, {
    objectName,
    accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
    expiresHours: 24,
  })
  parUrl.value = ''
  showPar.value = true
}

async function doCreatePar() {
  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,
    })
    parUrl.value = par.fullUrl ?? ''
    parPanel.value?.refresh()
  } catch (e) {
    message.error(e instanceof Error ? e.message : '签发失败')
  } finally {
    parBusy.value = false
  }
}

async function copyParUrl() {
  await navigator.clipboard.writeText(parUrl.value)
  message.success('链接已复制')
}

// ---- 批量选择 ----
const checkedKeys = ref<string[]>([])

async function batchDelete() {
  for (const name of [...checkedKeys.value]) await removeObject(name)
}

async function batchDownload() {
  for (const name of [...checkedKeys.value]) {
    await download(name)
    await new Promise((r) => setTimeout(r, 300))
  }
}

// ---- 对象查看器:点击对象名打开,预览/编辑合一 ----
const viewerObject = ref('')
const showViewer = ref(false)

function openViewer(name: string) {
  viewerObject.value = name
  showViewer.value = true
}

interface Row {
  key: string
  isFolder: boolean
  obj?: ObjectSummary
}

const rows = computed<Row[]>(() => {
  const data = result.data.value
  if (!data) return []
  const folders = (data.prefixes ?? []).map((p) => ({ key: p, isFolder: true }))
  // 文件夹占位对象(名字等于当前前缀自身)不展示
  const files = (data.objects ?? [])
    .filter((o) => o.name !== prefix.value)
    .map((o) => ({ key: o.name, isFolder: false, obj: o }))
  const all = [...folders, ...files]
  const q = filterInput.value.trim().toLowerCase()
  if (!q) return all
  return all.filter((r) => r.key.slice(prefix.value.length).toLowerCase().includes(q))
})

const columns = computed<DataTableColumns<Row>>(() => [
  { type: 'selection', disabled: (row) => row.isFolder },
  {
    title: '名称',
    key: 'name',
    minWidth: 260,
    // 超长对象名截断,悬停 tooltip 看全名
    ellipsis: { tooltip: true },
    render: (row) =>
      row.isFolder
        ? h(
            'span',
            {
              class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] font-medium hover:text-accent',
              onClick: () => enterFolder(row.key),
            },
            [h(FileIcon, { folder: true }), row.key.slice(prefix.value.length)],
          )
        : h(
            'span',
            {
              class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] hover:text-accent',
              onClick: () => openViewer(row.key),
            },
            [h(FileIcon, { name: row.key }), row.key.slice(prefix.value.length)],
          ),
  },
  {
    title: '大小',
    key: 'size',
    width: 110,
    render: (row) => h('span', { class: 'mono text-[12.5px]' }, row.isFolder ? '—' : fmtBytes(row.obj!.size)),
  },
  {
    title: '存储层',
    key: 'tier',
    width: 110,
    render: (row) => {
      if (row.isFolder) return h('span', { class: 'text-ink-3' }, '—')
      const o = row.obj!
      if (o.storageTier === 'Archive')
        return h(
          'span',
          { class: 'rounded-full bg-warn/14 px-2 py-0.5 text-[11.5px] font-medium text-warn' },
          o.archivalState === 'Restored'
            ? '已取回'
            : o.archivalState === 'Restoring'
              ? '取回中'
              : 'Archive',
        )
      return h('span', { class: 'text-[13px]' }, o.storageTier || 'Standard')
    },
  },
  {
    title: '修改时间',
    key: 'time',
    width: 150,
    render: (row) =>
      h(
        'span',
        { class: 'text-[13px] text-ink-2' },
        row.isFolder ? '—' : fmtTime(row.obj!.timeModified),
      ),
  },
  {
    title: '操作',
    key: 'actions',
    width: 120,
    render: (row) => {
      if (row.isFolder) return null
      const o = row.obj!
      const restoring = o.storageTier === 'Archive' && o.archivalState === 'Restoring'
      const archived =
        o.storageTier === 'Archive' && o.archivalState !== 'Restored' && !restoring
      return h('div', { class: 'flex items-center gap-1' }, [
        restoring
          ? h(
              NButton,
              { size: 'tiny', quaternary: true, disabled: true },
              { default: () => '取回中' },
            )
          : archived
          ? h(
              ConfirmPop,
              {
                title: '取回 Archive 对象？',
                content: '约 1 小时后可下载 (24 小时有效)。',
                kind: 'plain',
                confirmText: '取回',
                onConfirm: () => restore(o.name),
              },
              {
                trigger: () =>
                  h(NButton, { size: 'tiny', quaternary: true }, { default: () => '恢复' }),
              },
            )
          : h(
              NButton,
              { size: 'tiny', quaternary: true, onClick: () => download(o.name, o.size) },
              { default: () => '下载' },
            ),
        h(
          ConfirmPop,
          {
            title: '删除该对象？',
            content: '未开版本控制时不可恢复。',
            confirmText: '删除',
            onConfirm: () => removeObject(o.name),
          },
          {
            trigger: () =>
              h(
                NButton,
                { size: 'tiny', quaternary: true, type: 'error' },
                { default: () => '删除' },
              ),
          },
        ),
      ])
    },
  },
])
</script>

<template>
  <div class="flex flex-col gap-4">
    <!-- hero:与租户/VCN 详情同构——返回链 + 标题行(徽标/操作)+ meta 栅格(移动可折叠) -->
    <div class="panel px-5 py-4">
      <button
        type="button"
        class="mb-2 inline-flex cursor-pointer items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
        @click="emit('back')"
      >
        ← 返回存储桶列表
      </button>
      <div class="flex flex-wrap items-center gap-3">
        <h1 class="page-title min-w-0 truncate">{{ bucket }}</h1>
        <StatusBadge
          v-if="info"
          :kind="info.visibility === 'ObjectRead' ? 'prov' : 'check'"
          :label="info.visibility === 'ObjectRead' ? '公共读' : '私有'"
          :dot="false"
        />
        <StatusBadge v-if="info?.versioningOn" kind="run" label="版本控制" :dot="false" />
        <div class="flex-1" />
        <!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
        <div class="flex items-center gap-2 max-md:w-full">
          <NButton size="small" @click="openParModal('')">分享桶</NButton>
          <NButton size="small" @click="emit('settings')">桶设置</NButton>
          <NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
        </div>
        <input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
      </div>
      <button
        v-if="isMobile"
        class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
        @click="heroExpanded = !heroExpanded"
      >
        {{ heroExpanded ? '收起详情' : '展开详情' }}
        <svg
          class="h-3 w-3 transition-transform"
          :class="heroExpanded ? 'rotate-180' : ''"
          viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
        >
          <path d="m6 9 6 6 6-6" />
        </svg>
      </button>
      <div
        v-if="!isMobile || heroExpanded"
        class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
      >
        <div class="min-w-0">
          <div class="text-xs text-ink-3">对象数</div>
          <div class="mono mt-0.5 text-[13px]">{{ info?.approximateCount ?? '…' }}</div>
        </div>
        <div class="min-w-0">
          <div class="text-xs text-ink-3">已用容量</div>
          <div class="mono mt-0.5 text-[13px]">{{ info ? fmtBytes(info.approximateSize) : '…' }}</div>
        </div>
        <div class="min-w-0">
          <div class="text-xs text-ink-3">存储层</div>
          <div class="mt-0.5 text-[13px]">{{ info?.storageTier ?? '…' }}</div>
        </div>
        <div class="min-w-0">
          <div class="text-xs text-ink-3">创建时间</div>
          <div class="mt-0.5 text-[13px]">{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}</div>
        </div>
      </div>
    </div>

    <NTabs v-model:value="tab" type="line">
      <NTabPane name="objects" tab="对象" />
      <NTabPane name="par" tab="分享链接" />
    </NTabs>

    <div v-show="tab === 'objects'" class="panel">
      <div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3 max-md:px-3.5">
        <div class="flex min-w-0 flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
          <span
            class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
            :title="bucket"
            @click="enterFolder('')"
          >
            {{ bucket }}
          </span>
          <template v-if="foldedOptions.length">
            <span>/</span>
            <NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
              <span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
            </NDropdown>
          </template>
          <template v-for="c in visibleCrumbs" :key="c.prefix">
            <span>/</span>
            <span
              class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
              :title="c.label"
              @click="enterFolder(c.prefix)"
            >
              {{ c.label }}
            </span>
          </template>
        </div>
        <div class="flex items-center gap-2 max-md:w-full">
          <NInput
            v-model:value="filterInput"
            size="small"
            placeholder="搜索本层对象…"
            clearable
            class="!w-52 max-md:!w-auto max-md:flex-1"
          />
          <NButton size="small" @click="showNewFolder = true">新建文件夹</NButton>
        </div>
      </div>

      <div
        v-if="checkedKeys.length"
        class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
      >
        <span class="font-medium">已选 {{ checkedKeys.length }} 项</span>
        <NButton size="tiny" @click="batchDownload">逐个下载</NButton>
        <ConfirmPop
          :title="`删除选中的 ${checkedKeys.length} 个对象？`"
          content="未开版本控制时不可恢复。"
          confirm-text="删除"
          :on-confirm="batchDelete"
        >
          <template #trigger>
            <NButton size="tiny" type="error" quaternary>删除</NButton>
          </template>
        </ConfirmPop>
        <span class="ml-auto cursor-pointer text-ink-3 hover:text-ink" @click="checkedKeys = []">
          清除选择
        </span>
      </div>

      <NDataTable
        v-model:checked-row-keys="checkedKeys"
        :columns="columns"
        :data="rows"
        :loading="result.loading.value"
        :scroll-x="880"
        :row-key="(r: Row) => r.key"
      />
      <div
        class="flex items-center justify-between gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
      >
        <span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
        <span class="flex flex-none items-center gap-1.5">
          <NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
          <NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
            下一页
          </NButton>
        </span>
      </div>
    </div>

    <ParPanel v-show="tab === 'par'" ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />

    <!-- 传输列表浮层:上传主色 / 下载 info 色,失败红字可重试,可收起 -->
    <Teleport to="body">
      <div
        v-if="transfers.length"
        class="fixed right-5 bottom-5 z-40 w-80 max-w-[calc(100vw-24px)] max-md:right-3 max-md:bottom-[calc(64px_+_env(safe-area-inset-bottom))]"
      >
        <div class="overflow-hidden rounded-lg border border-line bg-card shadow-[0_10px_32px_-8px_rgba(0,0,0,0.22)]">
          <div
            class="flex items-center gap-2 bg-wash px-3.5 py-2 text-[12.5px]"
            :class="transfersCollapsed ? '' : 'border-b border-line-soft'"
          >
            <span class="font-medium">传输列表</span>
            <span v-if="activeCount" class="text-ink-3">{{ activeCount }} 进行中</span>
            <div class="ml-auto flex items-center gap-1">
              <button
                v-if="doneCount"
                type="button"
                class="cursor-pointer rounded px-1.5 py-0.5 text-xs text-ink-3 hover:bg-wash-deep hover:text-ink"
                @click="clearDone"
              >
                清除已完成
              </button>
              <button
                type="button"
                class="cursor-pointer rounded p-1 text-ink-3 hover:bg-wash-deep hover:text-ink"
                :title="transfersCollapsed ? '展开' : '收起'"
                @click="transfersCollapsed = !transfersCollapsed"
              >
                <svg
                  class="h-3.5 w-3.5 transition-transform"
                  :class="transfersCollapsed ? 'rotate-180' : ''"
                  viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
                  stroke-linecap="round" stroke-linejoin="round"
                >
                  <path d="m6 9 6 6 6-6" />
                </svg>
              </button>
            </div>
          </div>
          <div v-show="!transfersCollapsed" class="max-h-60 overflow-y-auto">
            <div
              v-for="t in transfers"
              :key="t.id"
              class="flex items-center gap-2.5 border-b border-line-soft px-3.5 py-2 text-[12.5px] last:border-b-0"
            >
              <span
                class="flex-none font-semibold"
                :class="t.status === 'error' ? 'text-err' : t.kind === 'up' ? 'text-accent' : 'text-info'"
              >
                {{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
              </span>
              <div class="min-w-0 flex-1">
                <div class="flex items-center justify-between gap-2">
                  <span class="mono truncate">{{ t.name }}</span>
                  <span v-if="t.status === 'active'" class="mono flex-none text-xs text-ink-3">
                    {{ t.percent }}%
                  </span>
                  <span v-else-if="t.status === 'done'" class="flex-none text-xs text-ok">完成</span>
                </div>
                <div v-if="t.status === 'active'" class="mt-1 h-1 overflow-hidden rounded-full bg-line-soft">
                  <span
                    class="block h-full rounded-full"
                    :class="t.kind === 'up' ? 'bg-accent' : 'bg-info'"
                    :style="{ width: `${t.percent}%` }"
                  />
                </div>
                <div v-else-if="t.status === 'error'" class="mt-0.5 flex items-center gap-2 text-xs">
                  <span class="min-w-0 truncate text-err" :title="t.error">{{ t.error }}</span>
                  <span class="flex-none cursor-pointer font-medium text-accent" @click="retryTask(t)">
                    重试
                  </span>
                </div>
                <div v-else-if="t.note" class="mt-0.5 text-xs text-ink-3">{{ t.note }}</div>
              </div>
              <button
                type="button"
                class="flex-none cursor-pointer text-ink-3 hover:text-ink"
                :title="t.status === 'active' ? '取消' : '移除'"
                @click="removeTask(t)"
              >
                <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
                  <path d="M18 6 6 18M6 6l12 12" />
                </svg>
              </button>
            </div>
          </div>
        </div>
      </div>
    </Teleport>

    <ObjectViewerModal
      v-model:show="showViewer"
      :cfg-id="cfgId"
      :region="region"
      :bucket="bucket"
      :object="viewerObject"
      :versioning-on="info?.versioningOn"
      :visibility="info?.visibility"
      :namespace="info?.namespace"
      @download="download"
      @share="openParModal"
      @changed="() => void result.run({ silent: true })"
    />

    <FormModal
      :show="showNewFolder"
      title="新建文件夹"
      :width="380"
      :submitting="creatingFolder"
      :submit-disabled="!folderName.trim()"
      submit-text="创建"
      @update:show="showNewFolder = $event"
      @submit="doCreateFolder"
    >
      <FormField label="文件夹名" hint="以 0 字节对象实现虚拟层级">
        <NInput v-model:value="folderName" placeholder="backup" class="mono" />
      </FormField>
    </FormModal>

    <FormModal
      :show="showPar"
      :title="parIsBucket ? '分享桶' : '分享对象'"
      :width="460"
      :submitting="parBusy"
      :submit-disabled="!!parUrl || !parExpiresValid"
      submit-text="签发"
      @update:show="showPar = $event"
      @submit="doCreatePar"
    >
      <FormField :label="parIsBucket ? '范围' : '对象'">
        <span class="mono text-[12.5px]">{{ parForm.objectName || '整桶对象(桶级链接)' }}</span>
      </FormField>
      <FormField label="权限">
        <NRadioGroup v-model:value="parForm.accessType">
          <NRadio :value="parIsBucket ? 'AnyObjectRead' : 'ObjectRead'">只读</NRadio>
          <NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
        </NRadioGroup>
      </FormField>
      <FormField label="有效期" hint="最长 100 年（876000 小时）">
        <div class="flex flex-wrap items-center gap-1.5">
          <AppInputNumber
            v-model:value="parForm.expiresHours"
            :min="1"
            :max="PAR_MAX_EXPIRES_HOURS"
            :precision="0"
            size="small"
            class="!w-24"
          />
          <span class="text-[12.5px] text-ink-2">小时</span>
          <NButton
            v-for="q in QUICK_HOURS"
            :key="q.value"
            size="tiny"
            quaternary
            :type="parForm.expiresHours === q.value ? 'primary' : 'default'"
            @click="parForm.expiresHours = q.value"
          >
            {{ q.label }}
          </NButton>
        </div>
      </FormField>
      <div
        v-if="parUrl"
        class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
      >
        <span class="mono min-w-0 flex-1 truncate text-xs">{{ parUrl }}</span>
        <NButton size="tiny" @click="copyParUrl">复制</NButton>
      </div>
      <div v-if="parUrl" class="mt-1 text-xs text-warn">
        链接仅本次展示,关闭后无法再次获取,请立即保存
      </div>
    </FormModal>
  </div>
</template>
