diff --git a/src/components/objectstorage/ObjectBrowser.vue b/src/components/objectstorage/ObjectBrowser.vue
index ce735ce..a8e61ab 100644
--- a/src/components/objectstorage/ObjectBrowser.vue
+++ b/src/components/objectstorage/ObjectBrowser.vue
@@ -4,9 +4,10 @@ import {
NDataTable,
NDropdown,
NInput,
- NProgress,
NRadio,
NRadioGroup,
+ NTabPane,
+ NTabs,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'
@@ -26,25 +27,30 @@ 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 type { ObjectSummary } from '@/types/api'
+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
- /** 桶版本控制状态,编辑保存确认用;未知按未开启保守处理 */
- versioningOn?: boolean
- /** 桶可见性与 namespace,透传查看器做公网 URL 复制 */
- visibility?: string
- namespace?: string
+ /** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
+ info?: Bucket
}>()
-const emit = defineEmits<{ back: [] }>()
+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('')
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
@@ -113,14 +119,50 @@ function prevPage() {
cursor.value = cursorStack.value.pop() ?? ''
}
-// ---- 上传:PAR 直传,逐文件进度 ----
-interface UploadTask {
+// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
+interface TransferTask {
+ id: number
+ kind: 'up' | 'down'
name: string
percent: number
+ status: 'active' | 'done' | 'error'
+ /** 附注:如「已交由浏览器下载」(无进度) */
+ note?: string
error?: string
abort: () => void
+ retry: () => void
}
-const uploads = ref
([])
+const transfers = ref([])
+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({
+ 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(null)
function pickFiles() {
@@ -134,11 +176,14 @@ async function onFilesPicked(e: Event) {
void result.run({ silent: true })
}
-async function uploadOne(file: File) {
+/** 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
- const objectName = prefix.value + file.name
- const task = reactive({ name: file.name, percent: 0, abort: () => {} })
- uploads.value.push(task)
let parId = ''
try {
const par = await createPar(props.cfgId, props.bucket, {
@@ -151,9 +196,10 @@ async function uploadOne(file: File) {
const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
task.abort = abort
await promise
- uploads.value = uploads.value.filter((t) => t !== task)
- message.success(`${file.name} 上传完成`)
+ task.status = 'done'
+ task.percent = 100
} catch (err) {
+ task.status = 'error'
task.error = err instanceof Error ? err.message : '上传失败'
} finally {
if (parId) void cleanupPar(parId)
@@ -171,9 +217,81 @@ async function cleanupPar(parId: string) {
}
}
-function dismissUpload(task: UploadTask) {
- task.abort()
- uploads.value = uploads.value.filter((t) => t !== task)
+// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
+const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
+
+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)
+}
+
+async function doDownload(task: TransferTask, name: string, size: number) {
+ if (!props.cfgId) 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)
+ task.status = 'done'
+ task.percent = 100
+ // 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
+ if (cleanable) void cleanupPar(par.id)
+ else parPanel.value?.refresh()
+ } catch (err) {
+ if ((err as DOMException)?.name === 'AbortError') return
+ task.status = 'error'
+ task.error = err instanceof Error ? err.message : '下载失败'
+ }
+}
+
+/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
+async function transferDown(task: TransferTask, url: string, size: number): Promise {
+ if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
+ 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)
+ }
+}
+
+function browserOpen(task: TransferTask, url: string): boolean {
+ window.open(url, '_blank')
+ task.note = '已交由浏览器下载'
+ return false
+}
+
+async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise {
+ 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/」对象 ----
@@ -208,22 +326,6 @@ async function doCreateFolder() {
}
// ---- 行操作 ----
-async function download(name: string) {
- if (!props.cfgId) return
- try {
- const par = await createPar(props.cfgId, props.bucket, {
- region: props.region,
- objectName: name,
- accessType: 'ObjectRead',
- expiresHours: 1,
- })
- parPanel.value?.refresh()
- window.open(par.fullUrl, '_blank')
- } catch (e) {
- message.error(e instanceof Error ? e.message : '生成下载链接失败')
- }
-}
-
async function removeObject(name: string) {
if (!props.cfgId) return
try {
@@ -248,7 +350,7 @@ async function restore(name: string) {
}
}
-// ---- 临时链接签发 ----
+// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
const showPar = ref(false)
const parBusy = ref(false)
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
@@ -257,6 +359,7 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
expiresHours: 24,
})
const parUrl = ref('')
+const parIsBucket = computed(() => !parForm.objectName)
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
@@ -267,7 +370,11 @@ const QUICK_HOURS = [
const parPanel = ref | null>(null)
function openParModal(objectName: string) {
- Object.assign(parForm, { objectName, accessType: 'ObjectRead', expiresHours: 24 })
+ Object.assign(parForm, {
+ objectName,
+ accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
+ expiresHours: 24,
+ })
parUrl.value = ''
showPar.value = true
}
@@ -437,7 +544,7 @@ const columns = computed>(() => [
)
: h(
NButton,
- { size: 'tiny', quaternary: true, onClick: () => download(o.name) },
+ { size: 'tiny', quaternary: true, onClick: () => download(o.name, o.size) },
{ default: () => '下载' },
),
h(
@@ -465,51 +572,111 @@ const columns = computed>(() => [
-
-
+
+
+
+
+
{{ bucket }}
+
+
+
+
+
+ 分享桶
+ 桶设置
+ 上传对象
+
+
+
+
+
-
-
存储桶
+
对象数
+
{{ info?.approximateCount ?? '…' }}
+
+
+
已用容量
+
{{ info ? fmtBytes(info.approximateSize) : '…' }}
+
+
+
存储层
+
{{ info?.storageTier ?? '…' }}
+
+
+
创建时间
+
{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ bucket }}
+
+
+ /
+ enterFolder(k)">
+ …
+
+
+
/
- {{ bucket }}
+ {{ c.label }}
-
- /
- enterFolder(k)">
- …
-
-
-
- /
-
- {{ c.label }}
-
-
-
-
- 前缀模式展示,文件夹为虚拟层级 · 点击对象名预览 / 查看详情 · 上传经 PAR 直传不经面板中转
-
+
-
@@ -534,23 +701,6 @@ const columns = computed
>(() => [
-
-
- {{ t.name }}
-
- {{ t.error }}
- {{ t.percent }}%
- 取消
-
-
-
>(() => [
:row-key="(r: Row) => r.key"
/>
- 每页 {{ PAGE_SIZE }} 条 · Archive 层需恢复后才可下载
-
+ 每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载
+
上一页
下一页
@@ -572,7 +722,97 @@ const columns = computed>(() => [
-
+
+
+
+
+
+
+
+
传输列表
+
{{ activeCount }} 进行中
+
+
+
+
+
+ {{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
+
+
+
+ {{ t.name }}
+
+ {{ t.percent }}%
+
+ 完成
+
+
+
+
+
+ {{ t.error }}
+
+ 重试
+
+
+
{{ t.note }}
+
+
+
+
+
+
+
>(() => [
:region="region"
:bucket="bucket"
:object="viewerObject"
- :versioning-on="versioningOn"
- :visibility="visibility"
- :namespace="namespace"
+ :versioning-on="info?.versioningOn"
+ :visibility="info?.visibility"
+ :namespace="info?.namespace"
@download="download"
@share="openParModal"
@changed="() => void result.run({ silent: true })"
@@ -605,7 +845,7 @@ const columns = computed>(() => [
>(() => [
@update:show="showPar = $event"
@submit="doCreatePar"
>
-
- {{ parForm.objectName || '(桶级)' }}
+
+ {{ parForm.objectName || '整桶对象(桶级链接)' }}
- 只读
- 读写
+ 只读
+ 读写
diff --git a/src/components/objectstorage/ObjectViewerModal.vue b/src/components/objectstorage/ObjectViewerModal.vue
index b233a55..7d13a00 100644
--- a/src/components/objectstorage/ObjectViewerModal.vue
+++ b/src/components/objectstorage/ObjectViewerModal.vue
@@ -19,6 +19,7 @@ import TextEditPane from '@/components/objectstorage/viewer/TextEditPane.vue'
import TextPreviewPane from '@/components/objectstorage/viewer/TextPreviewPane.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
+import { useIsMobile } from '@/composables/useIsMobile'
import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
@@ -48,6 +49,14 @@ const emit = defineEmits<{
const message = useToast()
+/** 移动端全屏(复用 main.css 的 .form-modal-mobile 全局全屏规则) */
+const isMobile = useIsMobile()
+const modalStyle = computed(() =>
+ isMobile.value
+ ? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
+ : { width: '1080px', maxWidth: 'calc(100vw - 24px)' },
+)
+
/** 文本预览/编辑体积上限;更大的文本请下载查看 */
const TEXT_MAX = 1024 * 1024
/** 图片与 office/pdf 的中转上限(与后端 20MB 限制一致) */
@@ -346,7 +355,8 @@ const kvRows = computed(() => {
:show="show"
preset="card"
:mask-closable="mode !== 'edit'"
- :style="{ width: '1080px', maxWidth: 'calc(100vw - 24px)' }"
+ :style="modalStyle"
+ :class="isMobile ? 'form-modal-mobile' : ''"
@update:show="emit('update:show', $event)"
>
@@ -362,10 +372,10 @@ const kvRows = computed(() => {
-
-
+
+
>(() => [
:loading="pars.loading.value"
:pagination="false"
:scroll-x="740"
+ :max-height="440"
:row-key="(r: Par) => r.id"
/>
= [
:row-key="(r: ProxyInfo) => r.id"
/>
- 支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储、不回显明文;地区与状态为经代理实测的出口探测结果(ip-api.com),创建后自动检测,探测失败即视为异常,可点「重测」复查;被租户关联的代理不可删除,请先解除关联
+ 支持 SOCKS5 / HTTP / HTTPS;被租户关联的代理须先解除关联才可删除
= [
@saved="void proxies.run({ silent: true })"
/>
-
@@ -334,7 +335,7 @@ const columns: DataTableColumns = [
/>
-
-
- 保存
- 取消
-
-
+
-
= [
{{ f.reason }}
-
- 导入
- 关闭
-
-
+
diff --git a/src/components/proxy/ProxyTenantsModal.vue b/src/components/proxy/ProxyTenantsModal.vue
index f976344..516aac7 100644
--- a/src/components/proxy/ProxyTenantsModal.vue
+++ b/src/components/proxy/ProxyTenantsModal.vue
@@ -7,6 +7,7 @@ import { setProxyTenants } from '@/api/proxies'
import FormField from '@/components/FormField.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { useAsync } from '@/composables/useAsync'
+import { useMobileModal } from '@/composables/useMobileModal'
import type { ProxyInfo } from '@/types/api'
import { useToast } from '@/composables/useToast'
@@ -16,6 +17,7 @@ const props = defineProps<{ show: boolean; proxy: ProxyInfo | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; saved: [] }>()
const message = useToast()
+const { modalStyle, modalClass } = useMobileModal(480)
const configs = useAsync(listConfigs, false)
const selected = ref([])
const saving = ref(false)
@@ -66,7 +68,8 @@ async function save() {
:show="show"
preset="card"
:title="`关联租户 · ${proxy?.name ?? ''}`"
- :style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
+ :style="modalStyle"
+ :class="modalClass"
@update:show="emit('update:show', $event)"
>
diff --git a/src/components/settings/AccountSecurityCard.vue b/src/components/settings/AccountSecurityCard.vue
index 872e10a..9798901 100644
--- a/src/components/settings/AccountSecurityCard.vue
+++ b/src/components/settings/AccountSecurityCard.vue
@@ -23,6 +23,7 @@ import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
+import { useMobileModal } from '@/composables/useMobileModal'
import { useAppStore } from '@/stores/app'
import { darkTokens, tokens } from '@/theme/tokens'
import { useAuthStore } from '@/stores/auth'
@@ -31,6 +32,8 @@ import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useToast()
+/** TOTP 启用引导弹窗的移动端全屏形态 */
+const { modalStyle, modalClass } = useMobileModal(360)
const router = useRouter()
const auth = useAuthStore()
const app = useAppStore()
@@ -426,7 +429,7 @@ async function deleteProvider(p: ProviderType) {
两步验证(TOTP)
- 登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password 等
+ 登录需额外输入验证器动态码
@@ -601,9 +604,8 @@ async function deleteProvider(p: ProviderType) {
尚未配置任何登录方式;点击右上「新增登录方式」配置 GitHub 或 OIDC
- 在 IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
- }}/api/v1/auth/oauth/<github|oidc>/callback;secret 加密存储不回显 ·
- 需先在「网络与地址」保存面板地址
+ 在 IdP / GitHub 应用中填写的授权回调地址:
+ {{ callbackBase}}/api/v1/auth/oauth/<github|oidc>/callback
@@ -747,7 +749,8 @@ async function deleteProvider(p: ProviderType) {
preset="card"
title="启用两步验证"
closable
- :style="{ width: '360px', maxWidth: 'calc(100vw - 24px)' }"
+ :style="modalStyle"
+ :class="modalClass"
>
diff --git a/src/components/settings/AiSettingsTab.vue b/src/components/settings/AiSettingsTab.vue
index 5ab79cf..1edd30f 100644
--- a/src/components/settings/AiSettingsTab.vue
+++ b/src/components/settings/AiSettingsTab.vue
@@ -153,7 +153,7 @@ function onBlacklistChanged() {
- 修改即时生效,无需重启 · 仅作用于 Responses 直通面;Chat / Messages 已有断流自动重做兜底
+ 仅作用于 Responses 直通面;Chat / Messages 已有断流自动重做兜底
@@ -198,7 +197,7 @@ function onBlacklistChanged() {
过滤弃用模型
- 开启后,已宣布弃用(即使未退役)的模型从模型列表与路由中排除;关闭恢复展示
+ 开启后,已宣布弃用(即使未退役)的模型从模型列表与路由中排除
模型黑名单
- 命中的模型从模型列表与路由排除,重新同步渠道后移出才恢复
+ 命中的模型从模型列表与路由排除
diff --git a/src/components/settings/BlacklistAddModal.vue b/src/components/settings/BlacklistAddModal.vue
index 3105392..3e10cf1 100644
--- a/src/components/settings/BlacklistAddModal.vue
+++ b/src/components/settings/BlacklistAddModal.vue
@@ -3,6 +3,7 @@ import { NInput, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { addAiBlacklist, listAiModelCatalog } from '@/api/aigateway'
+import { useMobileModal } from '@/composables/useMobileModal'
import { useToast } from '@/composables/useToast'
import type { AiCatalogModel } from '@/types/api'
@@ -10,6 +11,7 @@ const props = defineProps<{ show: boolean; blacklisted: string[] }>()
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
const toast = useToast()
+const { modalStyle, modalClass } = useMobileModal(640)
const rows = ref