初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

View File

@@ -0,0 +1,568 @@
<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>