Files
jczxw-pc/app/pages/developer/resources/storage.vue
2026-04-23 16:30:57 +08:00

569 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dev-page">
<div class="page-toolbar">
<div class="toolbar-left">
<a-select v-model:value="selectedAppId" placeholder="全部应用" allow-clear style="width: 180px" @change="loadList">
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">{{ app.productName }}</a-select-option>
</a-select>
<PermissionGuard :app-id="selectedAppId" permission="canEditResource" :show-tip="true">
<a-button type="primary" @click="showAdd = true">
<template #icon><PlusOutlined /></template>
添加存储桶
</a-button>
</PermissionGuard>
</div>
<div class="toolbar-right">
<a-input-search
v-model:value="searchText"
placeholder="搜索存储桶名称"
style="width: 240px"
@search="loadList"
/>
</div>
</div>
<div class="notice-bar">
<InfoCircleOutlined class="notice-icon" />
<span>在此管理云对象存储OSS/COS存储桶将文件存储资源关联到对应应用</span>
</div>
<a-table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="pagination"
row-key="resourceId"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
<div v-if="record.status === 'failed' && record.remark" style="font-size: 12px; color: #ff4d4f;">
{{ record.remark }}
</div>
</template>
<template v-if="column.key === 'acl'">
<a-tag :color="record.acl === 'public-read' ? 'green' : 'default'">
{{ record.acl === 'public-read' ? '公开读' : '私有' }}
</a-tag>
</template>
<template v-if="column.key === 'usedBytes'">
{{ formatSize(record.usedBytes) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleRefresh(record)">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource" type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-divider v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource" type="vertical" />
<a-popconfirm v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource" title="确定要移除此存储桶?" @confirm="handleDelete(record.resourceId)">
<a-button type="link" size="small" danger>移除</a-button>
</a-popconfirm>
<span v-if="!record.isOwner && !getAppPermission(record.appId)?.canEditResource" style="font-size: 12px; color: #faad14;">
<LockOutlined /> 只读
</span>
</a-space>
</template>
</template>
</a-table>
<a-modal
v-model:open="showAdd"
:title="editRecord ? '编辑存储桶' : '添加存储桶'"
ok-text="保存"
cancel-text="取消"
:confirm-loading="saveLoading"
@ok="handleSave"
@cancel="resetForm"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
<a-form-item label="存储桶名称" name="name">
<a-input v-model:value="form.name" placeholder="如assets-bucket" :disabled="!!editRecord" />
</a-form-item>
<a-form-item label="服务商" name="provider">
<a-select v-model:value="form.provider" placeholder="请选择" :disabled="!!editRecord" @change="handleProviderChange">
<a-select-option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="云账号凭证" name="credentialId">
<a-select v-model:value="form.credentialId" placeholder="请先选择服务商" :disabled="!!editRecord" allow-clear>
<a-select-option v-for="cred in credentialOptions" :key="cred.id" :value="cred.id">
{{ cred.name }} ({{ cred.accessKeyId?.slice(0, 8) }}***)
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所在地区" name="region">
<a-select
v-model:value="form.region"
placeholder="请选择地区"
show-search
:filter-option="(input, option) => option.label.toLowerCase().includes(input.toLowerCase())"
allow-clear
:disabled="!!editRecord"
>
<a-select-option v-for="opt in getRegionOptions(form.provider)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="访问权限" name="acl">
<a-radio-group v-model:value="form.acl">
<a-radio value="public-read">公开读</a-radio>
<a-radio value="private">私有</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="关联应用" name="appId">
<a-select v-model:value="form.appId" placeholder="可选关联到应用" allow-clear>
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
</a-form-item>
</a-form>
</a-modal>
<!-- 删除确认弹窗 -->
<a-modal
v-model:open="showVerifyDelete"
title="确认删除"
ok-text="确认删除"
cancel-text="取消"
:confirm-loading="deleteLoading"
@ok="confirmDelete"
>
<div style="text-align: center; padding: 16px 0;">
<p style="margin-bottom: 16px; color: #ff4d4f; font-size: 14px;">
<ExclamationCircleOutlined /> 此操作不可恢复,请谨慎操作!
</p>
<p style="margin-bottom: 8px;">请输入手机验证码确认删除</p>
<p style="margin-bottom: 16px; font-size: 12px; color: #666;">
验证码将发送至测试手机号13737128880
</p>
<div style="display: flex; gap: 8px; justify-content: center;">
<a-input
v-model:value="verifyCode"
placeholder="请输入验证码"
style="width: 120px;"
@pressEnter="confirmDelete"
/>
<a-button @click="sendVerifyCode" :disabled="verifyCountdown > 0">
{{ verifyCountdown > 0 ? `${verifyCountdown}s` : '发送验证码' }}
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { PlusOutlined, InfoCircleOutlined, LockOutlined, ReloadOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { pageAppResource, addAppResource, updateAppResource, removeAppResource, refreshStorage } from '@/api/app/appResource'
import { getMyAccessibleApps, getAppProduct } from '@/api/app/appProduct'
import { sendSmsCaptcha } from '@/api/passport/login'
import { useAppPermission } from '@/composables/useAppPermission'
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
import { pageCloudCredential } from '@/api/app/cloudCredential'
import type { AppResource } from '@/api/app/appResource/model'
import type { AppCloudCredential } from '@/api/app/cloudCredential/model'
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
definePageMeta({ layout: 'developer' })
useHead({ title: '云存储管理 - 开发者中心' })
const loading = ref(false)
const saveLoading = ref(false)
const showAdd = ref(false)
const searchText = ref('')
const editRecord = ref<any>(null)
const formRef = ref()
const selectedAppId = ref<number | undefined>(undefined)
const { getAppPermission } = useAppPermission()
const form = reactive({
name: '',
provider: undefined as string | undefined,
credentialId: undefined as number | undefined,
region: '',
acl: 'private',
appId: undefined as number | undefined,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入存储桶名称' }],
provider: [{ required: true, message: '请选择服务商' }],
credentialId: [{ required: true, message: '请选择云账号凭证' }],
region: [{ required: true, message: '请输入所在地区' }],
}
const columns = [
{ title: '存储桶名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '服务商', dataIndex: 'provider', key: 'provider', width: 100, customRender: ({ record }) => getProviderLabel(record.provider) },
{ title: '地区', dataIndex: 'region', key: 'region', width: 140, customRender: ({ record }) => getRegionLabel(record.provider, record.region) },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
{ title: '访问权限', dataIndex: 'acl', key: 'acl', width: 80 },
{ title: '已用空间', dataIndex: 'usedBytes', key: 'usedBytes', width: 90 },
{ title: '对象数', dataIndex: 'usedCount', key: 'objectCount', width: 70 },
// { title: '关联应用', dataIndex: 'appName', key: 'appName', width: 120 },
{ title: '操作', key: 'action', width: 100 },
]
// 获取服务商显示标签
function getProviderLabel(provider: string): string {
const map: Record<string, string> = {
aliyun: '阿里云',
tencent: '腾讯云',
huawei: '华为云',
qiniu: '七牛云',
}
return map[provider] || provider
}
// 获取地区显示标签
function getRegionLabel(provider: string, region: string): string {
if (!region) return '-'
const opts = getRegionOptions(provider)
const found = opts.find(o => o.value === region)
return found?.label || region
}
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
const list = ref<AppResource[]>([])
const appOptions = ref<any[]>([])
const credentialOptions = ref<AppCloudCredential[]>([])
// 服务商选项
const providerOptions = [
{ label: '☁️ 阿里云 OSS', value: 'aliyun' },
{ label: '🔵 腾讯云 COS', value: 'tencent' },
{ label: '🟠 华为云 OBS', value: 'huawei' },
{ label: '🟡 七牛云 Kodo', value: 'qiniu' },
]
// 阿里云 OSS 地区选项
const aliyunRegions = [
{ label: '华东 1杭州', value: 'oss-cn-hangzhou' },
{ label: '华东 2上海', value: 'oss-cn-shanghai' },
{ label: '华南 1深圳', value: 'oss-cn-shenzhen' },
{ label: '华南 2广州', value: 'oss-cn-guangzhou' },
{ label: '华北 2北京', value: 'oss-cn-beijing' },
{ label: '华北 3张家口', value: 'oss-cn-zhangjiakou' },
{ label: '华北 5呼和浩特', value: 'oss-cn-huhehaote' },
{ label: '西南 1成都', value: 'oss-cn-chengdu' },
{ label: '香港', value: 'oss-cn-hongkong' },
{ label: '新加坡', value: 'oss-ap-southeast-1' },
{ label: '日本(东京)', value: 'oss-ap-northeast-1' },
{ label: '韩国(首尔)', value: 'oss-ap-northeast-2' },
{ label: '美国(弗吉尼亚)', value: 'oss-us-east-1' },
{ label: '美国(硅谷)', value: 'oss-us-west-1' },
{ label: '德国(法兰克福)', value: 'oss-eu-central-1' },
{ label: '英国(伦敦)', value: 'oss-eu-west-1' },
]
// 腾讯云 COS 地区选项
const tencentRegions = [
{ label: '华北地区(北京)', value: 'ap-beijing' },
{ label: '华南地区(广州)', value: 'ap-guangzhou' },
{ label: '华南地区(深圳)', value: 'ap-shenzhen' },
{ label: '华东地区(上海)', value: 'ap-shanghai' },
{ label: '西南地区(成都)', value: 'ap-chengdu' },
{ label: '西南地区(重庆)', value: 'ap-chongqing' },
{ label: '港澳台地区(香港)', value: 'ap-hongkong' },
{ label: '亚太东南(新加坡)', value: 'ap-singapore' },
{ label: '亚太东南(曼谷)', value: 'ap-bangkok' },
{ label: '亚太南部(孟买)', value: 'ap-mumbai' },
{ label: '亚太东北(东京)', value: 'ap-tokyo' },
{ label: '美国东部(弗吉尼亚)', value: 'na-ashburn' },
{ label: '美国西部(硅谷)', value: 'na-siliconvalley' },
{ label: '欧洲地区(法兰克福)', value: 'eu-frankfurt' },
{ label: '欧洲地区(伦敦)', value: 'eu-london' },
]
// 华为云 OBS 地区选项
const huaweiRegions = [
{ label: '华北-北京一', value: 'cn-north-1' },
{ label: '华北-北京四', value: 'cn-north-4' },
{ label: '华东-上海一', value: 'cn-east-2' },
{ label: '华南-广州', value: 'cn-south-1' },
{ label: '西南-贵阳一', value: 'cn-southwest-2' },
{ label: '香港', value: 'cn-hongkong' },
{ label: '亚太-新加坡', value: 'ap-southeast-1' },
{ label: '亚太-曼谷', value: 'ap-southeast-2' },
{ label: '非洲-约翰内斯堡', value: 'af-south-1' },
{ label: '欧洲-巴黎', value: 'eu-west-1' },
{ label: '欧洲-法兰克福', value: 'eu-central-1' },
{ label: '拉美-圣保罗', value: 'sa-brazil-1' },
]
// 根据服务商获取地区选项
function getRegionOptions(provider: string) {
if (provider === 'aliyun') return aliyunRegions
if (provider === 'tencent') return tencentRegions
if (provider === 'huawei') return huaweiRegions
return []
}
// 加载云账号凭证列表
async function loadCredentials(provider?: string) {
try {
const result = await pageCloudCredential({ provider, page: 1, limit: 100 })
credentialOptions.value = result.list || []
} catch {
credentialOptions.value = []
}
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
function getStatusColor(status: string): string {
if (status === 'running') return 'success'
if (status === 'pending') return 'processing'
if (status === 'failed') return 'error'
return 'default'
}
function getStatusText(status: string): string {
if (status === 'running') return '运行中'
if (status === 'pending') return '创建中'
if (status === 'failed') return '创建失败'
return status || '未知'
}
// 选择服务商后加载对应凭证
function handleProviderChange(provider: string) {
form.credentialId = undefined
if (provider) {
loadCredentials(provider)
} else {
credentialOptions.value = []
}
}
// 加载应用下拉列表(仅当前用户可访问的应用)
async function loadAppOptions() {
try {
appOptions.value = await getMyAccessibleApps()
}
catch {
appOptions.value = []
}
}
async function loadList() {
loading.value = true
try {
const result = await pageAppResource({
resourceType: 'storage',
keywords: searchText.value || undefined,
appId: selectedAppId.value,
page: pagination.current,
limit: pagination.pageSize,
})
list.value = enrichResourcesWithPermission(result?.list ?? [])
pagination.total = result?.count ?? 0
}
catch (e: any) {
message.error(e.message || '加载失败')
}
finally {
loading.value = false
}
}
function handleTableChange(pag: any) {
pagination.current = pag.current
loadList()
}
function handleEdit(record: AppResource) {
if (!record.isOwner) {
message.warning('只有资源创建者才能编辑')
return
}
editRecord.value = record
// 先加载对应服务商的凭证
if (record.provider) {
loadCredentials(record.provider)
}
Object.assign(form, {
name: record.name,
provider: record.provider,
credentialId: record.credentialId ? Number(record.credentialId) : undefined,
region: record.region,
acl: record.acl || 'private',
appId: record.appId ? Number(record.appId) : undefined,
remark: record.remark,
resourceId: record.resourceId,
})
showAdd.value = true
}
// 删除存储桶(需要验证码)
const showVerifyDelete = ref(false)
const verifyResourceId = ref<number>(0)
const verifyCode = ref('')
const verifyTargetPhone = ref('')
const verifyTargetUser = ref('')
async function handleDelete(resourceId: number) {
const item = list.value.find(r => r.resourceId === resourceId)
if (item && !item.isOwner) {
message.warning('只有资源创建者才能删除')
return
}
// 确认删除操作
showVerifyDelete.value = true
verifyResourceId.value = resourceId
verifyCode.value = ''
verifyTargetPhone.value = ''
verifyTargetUser.value = ''
// 获取应用创建者信息
if (item?.appId) {
try {
const app = await getAppProduct(item.appId)
if (app?.developerPhone) {
verifyTargetPhone.value = app.developerPhone
verifyTargetUser.value = app.developer || '应用创建者'
}
}
catch {
// 静默失败,后续会检查手机号
}
}
}
const deleteLoading = ref(false)
const verifyCountdown = ref(0)
async function sendVerifyCode() {
if (verifyCountdown.value > 0) return
try {
// 测试阶段:验证码发送给固定手机号
const phone = '13737128880'
await sendSmsCaptcha({ phone })
message.success('验证码已发送测试手机号13737128880')
verifyCountdown.value = 60
const timer = setInterval(() => {
verifyCountdown.value--
if (verifyCountdown.value <= 0) clearInterval(timer)
}, 1000)
}
catch (e: any) {
message.error(e.message || '发送失败')
}
}
async function confirmDelete() {
if (!verifyCode.value) {
message.warning('请输入短信验证码')
return
}
deleteLoading.value = true
try {
await removeAppResource(verifyResourceId.value, verifyCode.value)
message.success('已移除')
showVerifyDelete.value = false
loadList()
}
catch (e: any) {
message.error(e.message || '删除失败')
}
finally {
deleteLoading.value = false
}
}
async function handleRefresh(record: AppResource) {
try {
const result = await refreshStorage(record.resourceId!)
// 更新本地数据
const index = list.value.findIndex(r => r.resourceId === record.resourceId)
if (index !== -1) {
list.value[index].usedBytes = result.usedBytes
list.value[index].usedCount = result.objectCount
}
message.success('刷新成功')
}
catch (e: any) {
message.error(e.message || '刷新失败')
}
}
async function handleSave() {
await formRef.value?.validate()
saveLoading.value = true
try {
const payload: AppResource = {
resourceType: 'storage',
name: form.name,
provider: form.provider,
credentialId: form.credentialId ? Number(form.credentialId) : undefined,
region: form.region,
acl: form.acl,
appId: form.appId ? Number(form.appId) : undefined,
remark: form.remark,
}
if (editRecord.value) {
payload.resourceId = editRecord.value.resourceId
await updateAppResource(payload)
message.success('保存成功')
}
else {
await addAppResource(payload)
message.success('添加成功,存储桶正在创建中...')
}
showAdd.value = false
resetForm()
loadList()
}
catch (e: any) {
message.error(e.message || '操作失败')
}
finally {
saveLoading.value = false
}
}
function resetForm() {
editRecord.value = null
formRef.value?.resetFields()
Object.assign(form, { name: '', provider: undefined, credentialId: undefined, region: '', acl: 'private', appId: undefined, remark: '' })
}
onMounted(() => {
loadAppOptions()
loadList()
})
</script>
<style scoped>
.dev-page { padding: 24px; max-width: 1100px; }
.page-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.toolbar-left { display: flex; align-items: center; gap: 12px; }
.toolbar-right { display: flex; gap: 8px; }
.notice-bar {
display: flex; align-items: center; gap: 8px;
background: #e6f4ff; border: 1px solid #91caff;
border-radius: 6px; padding: 8px 14px; margin-bottom: 16px;
font-size: 13px; color: #1677ff;
}
.notice-icon { font-size: 15px; }
</style>