初始化2
This commit is contained in:
142
app/pages/developer/resources/README_RESOURCE_COLLAB.md
Normal file
142
app/pages/developer/resources/README_RESOURCE_COLLAB.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 资源中心协作权限设计
|
||||
|
||||
## 一、数据库变更
|
||||
|
||||
```sql
|
||||
-- 扩展 app_resource 表,添加协作权限相关字段
|
||||
ALTER TABLE app_resource
|
||||
ADD COLUMN owner_user_id BIGINT NULL COMMENT '资源创建者 userId(创建时自动设置)' AFTER user_id,
|
||||
ADD COLUMN access_level TINYINT DEFAULT 1 COMMENT '默认协作者访问级别: 1=基础查看 2=连接查看 3=完全权限' AFTER owner_user_id;
|
||||
|
||||
-- 数据修复:将现有数据的 owner_user_id 设置为 user_id
|
||||
UPDATE app_resource SET owner_user_id = user_id WHERE owner_user_id IS NULL;
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_resource_owner ON app_resource(owner_user_id);
|
||||
```
|
||||
|
||||
## 二、权限级别定义
|
||||
|
||||
| 级别 | 名称 | 可见信息 | 适用角色 |
|
||||
|-----|------|---------|---------|
|
||||
| 0 | 无权限 | 无 | 非团队成员 |
|
||||
| 1 | 基础查看 | 名称、IP、端口、状态 | 所有团队成员 |
|
||||
| 2 | 连接查看 | + 用户名、Host、连接方式 | 技术负责人 |
|
||||
| 3 | 完全权限 | + 密码、私钥、编辑删除 | 资源创建者 (Owner) |
|
||||
|
||||
## 三、后端实现要点
|
||||
|
||||
### 3.1 创建资源时自动设置 owner_user_id
|
||||
|
||||
```java
|
||||
// AppResourceServiceImpl.java
|
||||
@Override
|
||||
public void save(AppResource resource) {
|
||||
// 创建时自动设置所有者
|
||||
Long currentUserId = StpUtil.getLoginIdAsLong();
|
||||
if (resource.getOwnerUserId() == null) {
|
||||
resource.setOwnerUserId(currentUserId);
|
||||
}
|
||||
super.save(resource);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 查询时计算 accessLevel 并过滤敏感字段
|
||||
|
||||
```java
|
||||
// AppResourceController.java - 查询列表/分页时处理权限
|
||||
private void enrichWithPermission(List<AppResource> resources) {
|
||||
Long currentUserId = StpUtil.getLoginIdAsLong();
|
||||
for (AppResource resource : resources) {
|
||||
// 计算访问级别
|
||||
ResourceAccessLevel level = checkAccessLevel(currentUserId, resource);
|
||||
resource.setAccessLevel(level.getValue());
|
||||
resource.setOwner(currentUserId.equals(resource.getOwnerUserId()));
|
||||
|
||||
// 过滤敏感字段
|
||||
if (level.getValue() < 3) {
|
||||
resource.setSshPassword(hasContent(resource.getSshPassword()) ? "******" : null);
|
||||
resource.setAdminPassword(hasContent(resource.getAdminPassword()) ? "******" : null);
|
||||
resource.setDbPassword(hasContent(resource.getDbPassword()) ? "******" : null);
|
||||
resource.setPrivateKey(null); // 私钥完全不返回给非Owner
|
||||
}
|
||||
if (level.getValue() < 2) {
|
||||
resource.setSshUsername(null);
|
||||
resource.setDbUsername(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ResourceAccessLevel checkAccessLevel(Long userId, AppResource resource) {
|
||||
// Owner 拥有完全权限
|
||||
if (userId.equals(resource.getOwnerUserId())) {
|
||||
return ResourceAccessLevel.FULL;
|
||||
}
|
||||
|
||||
// 检查应用团队权限(通过 app_invite 表)
|
||||
AppInvite invite = appInviteService.getByAppAndUser(resource.getAppId(), userId);
|
||||
if (invite == null) {
|
||||
return ResourceAccessLevel.NONE;
|
||||
}
|
||||
|
||||
// 根据角色返回权限级别
|
||||
String role = invite.getRole();
|
||||
if ("technical_lead".equals(role) || "admin".equals(role)) {
|
||||
return ResourceAccessLevel.VIEW_CONNECTION;
|
||||
}
|
||||
return ResourceAccessLevel.VIEW_BASIC;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 accessLevel 枚举
|
||||
|
||||
```java
|
||||
public enum ResourceAccessLevel {
|
||||
NONE(0), VIEW_BASIC(1), VIEW_CONNECTION(2), FULL(3);
|
||||
|
||||
private final int value;
|
||||
|
||||
ResourceAccessLevel(int value) { this.value = value; }
|
||||
|
||||
public int getValue() { return value; }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 修改/删除时权限检查
|
||||
|
||||
```java
|
||||
@PutMapping
|
||||
public ApiResult<Void> update(@RequestBody AppResource resource) {
|
||||
AppResource existing = service.getById(resource.getResourceId());
|
||||
Long currentUserId = StpUtil.getLoginIdAsLong();
|
||||
|
||||
if (!currentUserId.equals(existing.getOwnerUserId())) {
|
||||
return ApiResult.fail("只有资源创建者才能修改");
|
||||
}
|
||||
|
||||
service.updateById(resource);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
```
|
||||
|
||||
## 四、前端已完成的改动
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|-----|---------|
|
||||
| `app/composables/useResourceAccess.ts` | 权限工具库:级别判断、enrichment、isOwner |
|
||||
| `app/api/app/appResource/model/index.ts` | 模型新增 `ownerUserId` / `accessLevel` / `isOwner` 字段 |
|
||||
| `app/pages/developer/resources/index.vue` | 总览页:协作说明banner、权限列标识 |
|
||||
| `app/pages/developer/resources/servers.vue` | 服务器:操作按权限显示,SSH/Panel 需连接权限 |
|
||||
| `app/pages/developer/resources/databases.vue` | 数据库:用户名列权限脱敏,操作按isOwner控制 |
|
||||
| `app/pages/developer/resources/ssl.vue` | SSL:私钥完全不显示给协作者,编辑/删除仅Owner |
|
||||
| `app/pages/developer/resources/storage.vue` | 云存储:操作按isOwner控制 |
|
||||
| `app/pages/developer/resources/domains.vue` | 域名:操作按isOwner控制,协作者标识 |
|
||||
|
||||
## 五、降级策略(后端尚未改造时)
|
||||
|
||||
前端的 `enrichResourcesWithPermission()` 提供了前端降级逻辑:
|
||||
- 若后端未返回 `accessLevel`,前端根据 `ownerUserId` vs localStorage `UserId` 判断
|
||||
- `ownerUserId === userId` → accessLevel=3(完全权限)
|
||||
- 否则 → accessLevel=1(基础查看)
|
||||
|
||||
> **注意**:前端降级逻辑仅用于开发阶段。生产环境必须由后端计算 accessLevel 并过滤敏感字段,前端无法保证数据安全。
|
||||
98
app/pages/developer/resources/SSL_TESTS.md
Normal file
98
app/pages/developer/resources/SSL_TESTS.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# SSL 证书管理功能测试文档
|
||||
|
||||
## 功能概述
|
||||
本次更新为 SSL 证书管理添加了完整的证书信息管理能力,包括私钥、公钥、证书文件、证书链等关键字段。
|
||||
|
||||
## 新增字段
|
||||
1. **privateKey** - 私钥(AES加密存储)
|
||||
2. **publicKey** - 公钥(可自动从证书提取)
|
||||
3. **certificate** - 完整的证书文件内容
|
||||
4. **certChain** - 证书链/中间证书
|
||||
5. **algorithm** - 加密算法(RSA/ECC)
|
||||
6. **keyBits** - 密钥长度
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 测试 1:添加证书
|
||||
1. 点击"添加证书"按钮
|
||||
2. 填写证书名称和绑定域名
|
||||
3. 选择证书类型(DV/OV/EV)
|
||||
4. 输入颁发机构
|
||||
5. 选择加密算法和密钥长度
|
||||
6. 粘贴证书内容(PEM格式)
|
||||
7. 粘贴私钥内容(安全考虑会在后续加密存储)
|
||||
8. 可选的证书链和备注
|
||||
9. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 证书添加成功
|
||||
- 在列表中显示新添加的证书
|
||||
- 安全存储私钥(AES加密)
|
||||
|
||||
### 测试 2:编辑证书
|
||||
1. 在证书列表中找到要编辑的证书
|
||||
2. 点击"编辑"按钮
|
||||
3. 修改证书名称或其他字段
|
||||
4. **注意**:编辑时私钥字段为空(安全考虑)
|
||||
5. 点击保存
|
||||
|
||||
**预期结果**:
|
||||
- 证书信息更新成功
|
||||
- 私钥保持不变(如果未重新输入)
|
||||
- 页面显示更新成功消息
|
||||
|
||||
### 测试 3:证书信息预览
|
||||
1. 在添加/编辑表单的证书文件字段输入证书内容
|
||||
2. 观察下方是否出现"证书信息预览"区域
|
||||
3. 检查预览信息是否正确解析
|
||||
|
||||
**预期结果**:
|
||||
- 预览区域显示基本证书信息
|
||||
- 信息包括域名、颁发机构、有效期限、算法等
|
||||
|
||||
### 测试 4:验证规则检查
|
||||
1. 尝试添加证书时不填写证书内容
|
||||
2. 尝试添加证书时不选择证书类型
|
||||
3. 输入不符合格式的证书内容
|
||||
|
||||
**预期结果**:
|
||||
- 应该有必填字段验证提示
|
||||
- 证书格式应该有基本验证
|
||||
|
||||
### 测试 5:安全特性测试
|
||||
1. 添加证书时输入私钥
|
||||
2. 编辑证书时检查私钥是否显示为空
|
||||
3. 查看数据库存储的私钥格式
|
||||
|
||||
**预期结果**:
|
||||
- 私钥在编辑时不显示(安全考虑)
|
||||
- 私钥应该以加密格式存储
|
||||
- 只有新增时需要提供私钥
|
||||
|
||||
## 测试数据示例
|
||||
|
||||
### 示例证书内容(PEM格式):
|
||||
```
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAJ8QxLvBw...
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
### 示例私钥内容(PEM格式):
|
||||
```
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFA...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
## 测试后验证
|
||||
1. 数据库表结构是否正确更新
|
||||
2. 前端表单能否正确提交所有字段
|
||||
3. 证书列表能否正确显示新增的算法和密钥长度字段
|
||||
4. 安全机制是否正常工作
|
||||
|
||||
## 注意事项
|
||||
1. 私钥安全性是关键,确保使用 AES 加密存储
|
||||
2. 证书内容较大,注意输入长度限制
|
||||
3. 编辑功能要正确处理私钥的安全更新逻辑
|
||||
4. 证书解析功能目前是基本实现,后续可以集成真正的证书解析库
|
||||
508
app/pages/developer/resources/databases.vue
Normal file
508
app/pages/developer/resources/databases.vue
Normal file
@@ -0,0 +1,508 @@
|
||||
<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>在此统一管理应用使用的数据库实例。选择服务器后,添加数据库将自动在远程服务器上创建。</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 === 'dbType'">
|
||||
<a-tag :color="typeColor[record.dbType]">{{ record.dbType }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'port'">
|
||||
{{ record.port || '-' }}
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="statusBadge[record.status] || 'default'"
|
||||
:text="statusLabel[record.status] || record.status"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="record.status === 'failed' && (record.isOwner || getAppPermission(record.appId)?.canEditResource)"
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="retryingId === record.resourceId"
|
||||
@click="handleRetry(record)"
|
||||
>
|
||||
重试
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.isOwner || getAppPermission(record.appId)?.canEditResource"
|
||||
title="确定要重置密码吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleResetPassword(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :loading="resettingId === record.resourceId">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<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)">
|
||||
<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="添加数据库"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="saveLoading"
|
||||
width="560px"
|
||||
@ok="handleSave"
|
||||
@cancel="resetForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
|
||||
<!-- 所属服务器 -->
|
||||
<a-form-item label="所属服务器" name="serverResourceId">
|
||||
<a-select
|
||||
v-model:value="form.serverResourceId"
|
||||
placeholder="选择服务器(用于远程建库)"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterServerOption"
|
||||
@change="handleServerChange"
|
||||
>
|
||||
<a-select-option v-for="s in serverOptions" :key="s.resourceId" :value="s.resourceId">
|
||||
{{ s.name }}({{ s.ip }} / MySQL:{{ s.mysqlPort || 3306 }} / PG:{{ s.pgPort || 5432 }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div v-if="form.serverResourceId" class="form-tip">
|
||||
<ApiOutlined /> 将在所选服务器上自动创建数据库
|
||||
</div>
|
||||
<div v-else class="form-tip form-tip-warn">
|
||||
不选择服务器则仅记录信息,不会远程创建数据库
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="数据库名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="如:db_shop" />
|
||||
</a-form-item>
|
||||
<a-form-item label="数据库类型" name="dbType">
|
||||
<a-select v-model:value="form.dbType" placeholder="请选择类型">
|
||||
<a-select-option value="MySQL">MySQL</a-select-option>
|
||||
<a-select-option value="PostgreSQL">PostgreSQL</a-select-option>
|
||||
<a-select-option value="Redis">Redis</a-select-option>
|
||||
<a-select-option value="MongoDB">MongoDB</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="连接地址" name="host">
|
||||
<a-input v-model:value="form.host" placeholder="Host / IP 地址(选择服务器后自动填充)" :disabled="!!form.serverResourceId" />
|
||||
</a-form-item>
|
||||
<a-form-item label="端口" name="port">
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="如:3306" :disabled="!!form.serverResourceId" />
|
||||
</a-form-item>
|
||||
<a-form-item label="用户名" name="dbUsername">
|
||||
<a-input v-model:value="form.dbUsername" placeholder="数据库用户名(选择服务器后将自动创建)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="dbPassword">
|
||||
<a-input-password v-model:value="form.dbPassword" placeholder="数据库密码">
|
||||
<template #addonAfter>
|
||||
<a-button type="link" size="small" style="padding: 0; height: auto;" @click="refreshPassword">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input-password>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, InfoCircleOutlined, SyncOutlined, ApiOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { pageAppResource, addAppResource, removeAppResource, retryCreateDatabase, resetDatabasePassword } from '@/api/app/appResource'
|
||||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||||
import { useAppPermission } from '@/composables/useAppPermission'
|
||||
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
|
||||
import type { AppResource } from '@/api/app/appResource/model'
|
||||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '数据库管理 - 开发者中心' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saveLoading = ref(false)
|
||||
const resettingId = ref<number | null>(null)
|
||||
const showAdd = ref(false)
|
||||
const searchText = ref('')
|
||||
const retryingId = ref<number | null>(null)
|
||||
const formRef = ref()
|
||||
const selectedAppId = ref<number | undefined>(undefined)
|
||||
const { getAppPermission } = useAppPermission()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
dbType: undefined as string | undefined,
|
||||
serverResourceId: undefined as number | undefined,
|
||||
host: '',
|
||||
port: undefined as number | undefined,
|
||||
dbUsername: '',
|
||||
dbPassword: '',
|
||||
appId: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 生成随机密码(12位,包含大小写字母、数字、特殊字符)
|
||||
function generateRandomPassword(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'
|
||||
let password = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
// 监听数据库名称变化,自动填充用户名和密码
|
||||
watch(() => form.name, (newName) => {
|
||||
if (newName) {
|
||||
form.dbUsername = newName
|
||||
if (!form.dbPassword) {
|
||||
form.dbPassword = generateRandomPassword()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据库类型变化,如果已选服务器则自动刷新端口
|
||||
watch(() => form.dbType, () => {
|
||||
if (form.serverResourceId) {
|
||||
handleServerChange(form.serverResourceId)
|
||||
}
|
||||
})
|
||||
|
||||
// 校验连接地址
|
||||
function validateHost(_rule: any, value: string) {
|
||||
if (!value) return Promise.reject('请输入连接地址')
|
||||
if (/[a-zA-Z\-]/.test(value)) return Promise.resolve()
|
||||
const ipv4Reg = /^(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)\.(25[0-5]|2[0-4]\d|1\d{1,2}|[1-9]?\d)$/
|
||||
if (ipv4Reg.test(value)) return Promise.resolve()
|
||||
if (/^[\d.]+$/.test(value) && value.includes('.'))
|
||||
return Promise.reject('IPv4 地址格式不正确(如:192.168.1.1)')
|
||||
if (value.includes(':')) {
|
||||
const ipv6Reg = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?$/
|
||||
if (ipv6Reg.test(value)) return Promise.resolve()
|
||||
return Promise.reject('IPv6 地址格式不正确')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 校验端口号
|
||||
function validatePort(_rule: any, value: number) {
|
||||
if (value === undefined || value === null || value === '') return Promise.resolve()
|
||||
const num = Number(value)
|
||||
if (isNaN(num) || !Number.isInteger(num) || num < 1 || num > 65535) {
|
||||
return Promise.reject('端口号须为 1 ~ 65535 之间的整数')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 校验数据库名称
|
||||
function validateDbName(_rule: any, value: string) {
|
||||
if (!value) return Promise.reject('请输入数据库名称')
|
||||
const dbNameReg = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
if (!dbNameReg.test(value)) {
|
||||
return Promise.reject('命名格式:db_shop(小写字母开头,下划线分隔,仅含小写字母、数字、下划线)')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入数据库名称' },
|
||||
{ validator: validateDbName, trigger: 'blur' },
|
||||
],
|
||||
dbType: [{ required: true, message: '请选择数据库类型' }],
|
||||
host: [
|
||||
{ required: true, message: '请输入连接地址' },
|
||||
{ validator: validateHost, trigger: 'blur' },
|
||||
],
|
||||
port: [{ validator: validatePort, trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '运行中',
|
||||
pending: '创建中',
|
||||
failed: '创建失败',
|
||||
stopped: '已停止',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
running: 'success',
|
||||
pending: 'processing',
|
||||
failed: 'error',
|
||||
stopped: 'default',
|
||||
expired: 'warning',
|
||||
}
|
||||
|
||||
const typeColor: Record<string, string> = {
|
||||
MySQL: 'blue',
|
||||
PostgreSQL: 'purple',
|
||||
Redis: 'red',
|
||||
MongoDB: 'green',
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '数据库名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'dbType', key: 'dbType' },
|
||||
{ title: '连接地址', dataIndex: 'host', key: 'host' },
|
||||
{ title: '端口', dataIndex: 'port', key: 'port' },
|
||||
{ title: '用户名', dataIndex: 'dbUsername', key: 'dbUsername', customRender: ({ record }: any) => {
|
||||
if ((record.accessLevel ?? 1) >= 2) return record.dbUsername || '-'
|
||||
return '***'
|
||||
} },
|
||||
// { title: '关联应用', dataIndex: 'productName', key: 'productName' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 180 },
|
||||
]
|
||||
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const list = ref<AppResource[]>([])
|
||||
const appOptions = ref<any[]>([])
|
||||
const serverOptions = ref<any[]>([])
|
||||
|
||||
// 服务器搜索过滤
|
||||
function filterServerOption(input: string, option: any) {
|
||||
const label = option.children?.[0]?.children?.toString() || ''
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
// 选择服务器后自动填充 host/port
|
||||
function handleServerChange(serverResourceId: number | undefined) {
|
||||
if (serverResourceId) {
|
||||
const server = serverOptions.value.find((s: any) => s.resourceId === serverResourceId)
|
||||
if (server) {
|
||||
form.host = server.ip
|
||||
// 根据数据库类型填充对应端口
|
||||
if (form.dbType === 'PostgreSQL') {
|
||||
form.port = server.pgPort || 5432
|
||||
} else {
|
||||
form.port = server.mysqlPort || 3306
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载应用下拉列表(仅当前用户可访问的应用)
|
||||
async function loadAppOptions() {
|
||||
try {
|
||||
appOptions.value = await getMyAccessibleApps()
|
||||
}
|
||||
catch {
|
||||
appOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载服务器下拉列表(只加载有管理员凭据的服务器)
|
||||
async function loadServerOptions() {
|
||||
try {
|
||||
const res = await pageAppResource({ resourceType: 'server', page: 1, limit: 200 })
|
||||
const servers = res?.list ?? []
|
||||
// 只展示配置了管理员用户名的服务器
|
||||
serverOptions.value = servers.filter((s: any) => s.adminUsername)
|
||||
}
|
||||
catch {
|
||||
serverOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pageAppResource({
|
||||
resourceType: 'database',
|
||||
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()
|
||||
}
|
||||
|
||||
async function handleDelete(record: AppResource) {
|
||||
try {
|
||||
if (record.serverResourceId && (record.dbType === 'MySQL' || record.dbType === 'PostgreSQL')) {
|
||||
message.loading({ content: '正在删除远程数据库...', key: 'deleteDb' })
|
||||
}
|
||||
await removeAppResource(record.resourceId!)
|
||||
message.success({ content: '已移除', key: 'deleteDb' })
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error({ content: e.message || '删除失败', key: 'deleteDb' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(record: AppResource) {
|
||||
if (!record.resourceId) return
|
||||
retryingId.value = record.resourceId
|
||||
try {
|
||||
await retryCreateDatabase(record.resourceId)
|
||||
message.success('已开始重新创建,请稍后刷新查看状态')
|
||||
// 3秒后自动刷新
|
||||
setTimeout(loadList, 3000)
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '重试失败')
|
||||
}
|
||||
finally {
|
||||
retryingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
async function handleResetPassword(record: AppResource) {
|
||||
if (!record.resourceId) return
|
||||
resettingId.value = record.resourceId
|
||||
try {
|
||||
const result = await resetDatabasePassword(record.resourceId)
|
||||
message.success(`密码已重置为:${result.password}(请妥善保管)`)
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '重置失败')
|
||||
}
|
||||
finally {
|
||||
resettingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await formRef.value?.validate()
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const payload: AppResource = {
|
||||
resourceType: 'database',
|
||||
name: form.name,
|
||||
dbType: form.dbType,
|
||||
serverResourceId: form.serverResourceId ? Number(form.serverResourceId) : undefined,
|
||||
host: form.host || undefined,
|
||||
port: form.port,
|
||||
dbUsername: form.dbUsername || undefined,
|
||||
dbPassword: form.dbPassword || undefined,
|
||||
appId: form.appId ? Number(form.appId) : undefined,
|
||||
remark: form.remark,
|
||||
}
|
||||
await addAppResource(payload)
|
||||
if (form.serverResourceId && (form.dbType === 'MySQL' || form.dbType === 'PostgreSQL')) {
|
||||
message.success('添加成功,正在远程创建数据库...')
|
||||
// 3秒后刷新状态
|
||||
setTimeout(loadList, 3000)
|
||||
}
|
||||
else {
|
||||
message.success('添加成功')
|
||||
}
|
||||
showAdd.value = false
|
||||
resetForm()
|
||||
loadList()
|
||||
}
|
||||
catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields()
|
||||
Object.assign(form, { name: '', dbType: undefined, serverResourceId: undefined, host: '', port: undefined, dbUsername: '', dbPassword: '', appId: undefined, remark: '' })
|
||||
}
|
||||
|
||||
// 刷新密码
|
||||
function refreshPassword() {
|
||||
form.dbPassword = generateRandomPassword()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAppOptions()
|
||||
loadServerOptions()
|
||||
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; }
|
||||
.form-tip {
|
||||
font-size: 12px; color: #52c41a; margin-top: 4px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.form-tip-warn { color: #faad14; }
|
||||
</style>
|
||||
388
app/pages/developer/resources/domains.vue
Normal file
388
app/pages/developer/resources/domains.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- 工具栏 -->
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3 class="page-title">域名管理</h3>
|
||||
<a-tag color="blue">{{ list.length }} 个</a-tag>
|
||||
<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>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-select v-model:value="filterIcp" placeholder="备案状态" style="width: 120px" allow-clear @change="loadList">
|
||||
<a-select-option :value="true">已备案</a-select-option>
|
||||
<a-select-option :value="false">未备案</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterSsl" placeholder="SSL 状态" style="width: 120px" allow-clear @change="loadList">
|
||||
<a-select-option :value="true">已绑定</a-select-option>
|
||||
<a-select-option :value="false">未绑定</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search v-model:value="searchText" placeholder="搜索域名 / 备案号" style="width: 200px" allow-clear @search="loadList" />
|
||||
<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>
|
||||
|
||||
<!-- 通知栏 -->
|
||||
<div class="notice-bar">
|
||||
<InfoCircleOutlined class="notice-icon" />
|
||||
<span>在此管理应用所使用的域名,支持关联 SSL 证书和绑定具体应用。</span>
|
||||
</div>
|
||||
|
||||
<!-- 域名卡片列表 -->
|
||||
<div v-if="loading" class="card-loading"><a-spin size="large" /></div>
|
||||
<div v-else-if="list.length === 0" class="card-empty">
|
||||
<a-empty description="暂无域名,点击右上角添加">
|
||||
<a-button type="primary" @click="showAdd = true"><template #icon><PlusOutlined /></template>添加域名</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
<div v-else class="domain-grid">
|
||||
<div v-for="item in list" :key="item.resourceId" class="domain-card" :class="{ 'card-expiring': isNearExpiry(item.expireAt) }">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<div class="domain-name" @click="copyText(item.domain!)">
|
||||
<GlobalOutlined />
|
||||
<span class="domain-text">{{ item.domain }}</span>
|
||||
<CopyOutlined class="copy-icon" />
|
||||
</div>
|
||||
<div class="domain-tags">
|
||||
<a-tag :color="item.icp ? 'green' : 'orange'" size="small">{{ item.icp ? '已备案' : '未备案' }}</a-tag>
|
||||
<a-tag :color="item.sslBound ? 'blue' : 'default'" size="small">{{ item.sslBound ? 'SSL 已绑定' : 'SSL 未绑定' }}</a-tag>
|
||||
<a-tag v-if="isNearExpiry(item.expireAt)" color="red" size="small">即将到期</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small"><template #icon><EllipsisOutlined /></template></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }: any) => handleMenuAction(key, item)">
|
||||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="edit"><template #icon><EditOutlined /></template>编辑</a-menu-item>
|
||||
<a-menu-item v-if="!item.sslBound" key="bindSsl"><template #icon><SafetyOutlined /></template>绑定 SSL</a-menu-item>
|
||||
<a-menu-item v-if="item.sslBound" key="unbindSsl"><template #icon><DisconnectOutlined /></template>解绑 SSL</a-menu-item>
|
||||
<a-menu-divider v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" />
|
||||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="delete" danger><template #icon><DeleteOutlined /></template>移除</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 卡片信息区 -->
|
||||
<div class="card-info">
|
||||
<div class="info-row"><span class="info-label">注册商</span><span class="info-value">{{ item.registrar || '-' }}</span></div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">备案号</span>
|
||||
<span class="info-value icp-value" @click="item.icpNo && copyText(item.icpNo)">
|
||||
{{ item.icpNo || '未备案' }}<CopyOutlined v-if="item.icpNo" class="copy-icon-small" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">SSL 证书</span>
|
||||
<span class="info-value">
|
||||
<template v-if="item.sslBound && item.sslCertName"><SafetyOutlined style="color: #52c41a; margin-right: 4px;" />{{ item.sslCertName }}</template>
|
||||
<span v-else class="text-muted">未绑定</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row"><span class="info-label">关联应用</span><span class="info-value">{{ item.appName || '未关联' }}</span></div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">到期时间</span>
|
||||
<span class="info-value" :class="{ 'text-warning': isNearExpiry(item.expireAt), 'text-danger': isExpired(item.expireAt) }">{{ item.expireAt || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作区 -->
|
||||
<div class="card-actions">
|
||||
<a-button v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" size="small" @click="handleEdit(item)"><template #icon><EditOutlined /></template>编辑</a-button>
|
||||
<a-button v-if="!item.sslBound" size="small" @click="handleBindSsl(item)"><template #icon><SafetyOutlined /></template>绑定 SSL</a-button>
|
||||
<a-button v-if="item.sslBound" size="small" @click="handleViewSsl(item)"><template #icon><EyeOutlined /></template>查看证书</a-button>
|
||||
<span v-if="!item.isOwner && !getAppPermission(item.appId)?.canEditResource" style="font-size: 11px; color: #faad14; display:flex; align-items:center; gap:4px;">
|
||||
<LockOutlined /> 协作者
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="pagination-wrapper">
|
||||
<a-pagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="['12', '24', '48']" show-size-changer show-total @change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<a-modal v-model:open="showAdd" :title="editRecord ? '编辑域名' : '添加域名'" ok-text="保存" cancel-text="取消" :confirm-loading="saveLoading" width="560px" @ok="handleSave" @cancel="resetForm">
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
|
||||
<div class="form-section-header"><GlobalOutlined /><span>基本信息</span></div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="域名" name="domain">
|
||||
<a-input v-model:value="form.domain" placeholder="如:example.com" :disabled="!!editRecord" @blur="handleDomainBlur" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="到期时间" name="expireAt">
|
||||
<a-date-picker v-model:value="form.expireAt" value-format="YYYY-MM-DD" style="width: 100%" placeholder="可选" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="注册商" name="registrar">
|
||||
<a-input v-model:value="form.registrar" placeholder="如:腾讯云、阿里云" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<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-col>
|
||||
</a-row>
|
||||
|
||||
<div class="form-section-header"><SafetyCertificateOutlined /><span>ICP 备案</span></div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="备案状态" name="icp">
|
||||
<a-radio-group v-model:value="form.icp">
|
||||
<a-radio :value="true">已备案</a-radio>
|
||||
<a-radio :value="false">未备案</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-form-item label="ICP 备案号" name="icpNo">
|
||||
<a-input v-model:value="form.icpNo" placeholder="如:粤ICP备XXXXXXXX号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="form-section-header"><SafetyOutlined /><span>SSL 证书</span><span class="form-section-hint">可选,绑定已有证书</span></div>
|
||||
<a-form-item label="选择证书" name="sslResourceId">
|
||||
<a-select v-model:value="form.sslResourceId" placeholder="选择要绑定的 SSL 证书" allow-clear @change="handleSslChange">
|
||||
<a-select-option v-for="cert in sslOptions" :key="cert.resourceId" :value="cert.resourceId">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>{{ cert.name }}</span>
|
||||
<a-tag size="small" :color="certTypeColor[cert.certType!]">{{ cert.certType }}</a-tag>
|
||||
<span style="color: #999; font-size: 12px;">{{ cert.domain }}</span>
|
||||
</div>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注" name="remark" style="margin-bottom: 0">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 绑定 SSL 弹窗 -->
|
||||
<a-modal v-model:open="showBindSsl" title="绑定 SSL 证书" ok-text="绑定" cancel-text="取消" @ok="confirmBindSsl" @cancel="cancelBindSsl">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="域名"><a-input :value="currentDomain?.domain" disabled /></a-form-item>
|
||||
<a-form-item label="选择 SSL 证书">
|
||||
<a-select v-model:value="bindSslForm.sslResourceId" placeholder="选择要绑定的 SSL 证书">
|
||||
<a-select-option v-for="cert in sslOptions" :key="cert.resourceId" :value="cert.resourceId">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>{{ cert.name }}</span>
|
||||
<a-tag size="small" :color="certTypeColor[cert.certType!]">{{ cert.certType }}</a-tag>
|
||||
<span style="color: #999; font-size: 12px;">{{ cert.domain }}</span>
|
||||
</div>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看 SSL 证书弹窗 -->
|
||||
<a-modal v-model:open="showViewSsl" title="SSL 证书信息" :footer="null" @cancel="showViewSsl = false">
|
||||
<div v-if="currentSslCert" class="ssl-info">
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">证书名称</span><span class="ssl-info-value">{{ currentSslCert.name }}</span></div>
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">绑定域名</span><span class="ssl-info-value">{{ currentSslCert.domain }}</span></div>
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">证书类型</span><span class="ssl-info-value"><a-tag :color="certTypeColor[currentSslCert.certType!]">{{ currentSslCert.certType }}</a-tag></span></div>
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">颁发机构</span><span class="ssl-info-value">{{ currentSslCert.issuer || '-' }}</span></div>
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">到期时间</span><span class="ssl-info-value" :class="{ 'text-warning': isNearExpiry(currentSslCert.expireAt) }">{{ currentSslCert.expireAt }}</span></div>
|
||||
<div class="ssl-info-item"><span class="ssl-info-label">加密算法</span><span class="ssl-info-value">{{ currentSslCert.algorithm || '-' }} {{ currentSslCert.keyBits ? `(${currentSslCert.keyBits}位)` : '' }}</span></div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EllipsisOutlined, GlobalOutlined, CopyOutlined, InfoCircleOutlined, SafetyOutlined, SafetyCertificateOutlined, DisconnectOutlined, EyeOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { pageAppResource, addAppResource, updateAppResource, removeAppResource } from '@/api/app/appResource'
|
||||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||||
import { useAppPermission } from '@/composables/useAppPermission'
|
||||
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
|
||||
import type { AppResource, AppResourceParam } from '@/api/app/appResource/model'
|
||||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '域名管理 - 开发者中心' })
|
||||
|
||||
const DOMAIN_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
const loading = ref(false)
|
||||
const saveLoading = ref(false)
|
||||
const showAdd = ref(false)
|
||||
const showBindSsl = ref(false)
|
||||
const showViewSsl = ref(false)
|
||||
const searchText = ref('')
|
||||
const filterIcp = ref<boolean | undefined>(undefined)
|
||||
const filterSsl = ref<boolean | undefined>(undefined)
|
||||
const editRecord = ref<AppResource | null>(null)
|
||||
const currentDomain = ref<AppResource | null>(null)
|
||||
const currentSslCert = ref<AppResource | null>(null)
|
||||
const formRef = ref()
|
||||
const selectedAppId = ref<number | undefined>(undefined)
|
||||
const { getAppPermission } = useAppPermission()
|
||||
|
||||
const form = reactive({ domain: '', registrar: '', icp: false, icpNo: '', sslResourceId: undefined as number | undefined, appId: undefined as number | undefined, expireAt: null as any, remark: '' })
|
||||
const bindSslForm = reactive({ sslResourceId: undefined as number | undefined })
|
||||
|
||||
const rules = {
|
||||
domain: [
|
||||
{ required: true, message: '请输入域名' },
|
||||
{ validator: async (_rule: any, value: string) => { if (!value) return Promise.resolve(); const domain = value.trim().toLowerCase(); const testDomain = domain.startsWith('*.') ? domain.slice(2) : domain; if (DOMAIN_REGEX.test(testDomain)) return Promise.resolve(); return Promise.reject(new Error('请输入合法的域名')) }, trigger: ['blur', 'change'] },
|
||||
],
|
||||
}
|
||||
|
||||
const certTypeColor: Record<string, string> = { DV: 'blue', OV: 'purple', EV: 'gold' }
|
||||
const pagination = reactive({ current: 1, pageSize: 12, total: 0 })
|
||||
const list = ref<AppResource[]>([])
|
||||
const appOptions = ref<any[]>([])
|
||||
const sslOptions = ref<AppResource[]>([])
|
||||
|
||||
function isNearExpiry(dateStr?: string): boolean { if (!dateStr) return false; return dayjs(dateStr).diff(dayjs(), 'day') <= 30 && dayjs(dateStr).diff(dayjs(), 'day') >= 0 }
|
||||
function isExpired(dateStr?: string): boolean { if (!dateStr) return false; return dayjs(dateStr).diff(dayjs(), 'day') < 0 }
|
||||
|
||||
async function copyText(text: string) { try { await navigator.clipboard.writeText(text); message.success('已复制: ' + text) } catch { message.error('复制失败') } }
|
||||
function handleDomainBlur() { if (form.domain && !form.icpNo) { } }
|
||||
function handleSslChange(value: number) { if (value) form.icp = true }
|
||||
|
||||
function handleMenuAction(key: string, item: AppResource) {
|
||||
if (key === 'edit') handleEdit(item)
|
||||
else if (key === 'bindSsl') handleBindSsl(item)
|
||||
else if (key === 'unbindSsl') handleUnbindSsl(item)
|
||||
else if (key === 'delete') {
|
||||
if (!item.isOwner) { message.warning('只有域名创建者才能删除'); return }
|
||||
Modal.confirm({ title: '确定要移除此域名?', content: `将移除「${item.domain}」`, okText: '确定移除', okType: 'danger', cancelText: '取消', onOk: () => handleDelete(item.resourceId!) })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppOptions() { try { appOptions.value = await getMyAccessibleApps() } catch { appOptions.value = [] } }
|
||||
async function loadSslOptions() { try { const result = await pageAppResource({ resourceType: 'ssl', page: 1, limit: 200 }); sslOptions.value = result?.list ?? [] } catch { sslOptions.value = [] } }
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: AppResourceParam = { resourceType: 'domain', keywords: searchText.value || undefined, appId: selectedAppId.value, page: pagination.current, limit: pagination.pageSize }
|
||||
if (filterIcp.value !== undefined) (params as any).icp = filterIcp.value
|
||||
if (filterSsl.value !== undefined) (params as any).sslBound = filterSsl.value
|
||||
const result = await pageAppResource(params)
|
||||
list.value = enrichResourcesWithPermission(result?.list ?? [])
|
||||
pagination.total = result?.count ?? 0
|
||||
} catch (e: any) { message.error(e.message || '加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function handlePageChange(page: number, pageSize: number) { pagination.current = page; pagination.pageSize = pageSize; loadList() }
|
||||
|
||||
function handleEdit(record: AppResource) {
|
||||
if (!record.isOwner) {
|
||||
message.warning('只有域名创建者才能编辑')
|
||||
return
|
||||
}
|
||||
editRecord.value = record
|
||||
Object.assign(form, { domain: record.domain, registrar: record.registrar, icp: record.icp ?? false, icpNo: record.icpNo, sslResourceId: record.sslResourceId, appId: record.appId ? Number(record.appId) : undefined, expireAt: record.expireAt || null, remark: record.remark, resourceId: record.resourceId })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(resourceId: number) { try { await removeAppResource(resourceId); message.success('已移除'); loadList() } catch (e: any) { message.error(e.message || '删除失败') } }
|
||||
|
||||
async function handleSave() {
|
||||
await formRef.value?.validate()
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const payload: AppResource = { resourceType: 'domain', name: form.domain, domain: form.domain.trim().toLowerCase(), registrar: form.registrar, icp: form.icp, icpNo: form.icpNo, sslResourceId: form.sslResourceId, sslBound: !!form.sslResourceId, appId: form.appId ? Number(form.appId) : undefined, expireAt: form.expireAt ? dayjs(form.expireAt).format('YYYY-MM-DD') : 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 handleBindSsl(item: AppResource) { currentDomain.value = item; bindSslForm.sslResourceId = undefined; showBindSsl.value = true }
|
||||
|
||||
async function confirmBindSsl() {
|
||||
if (!bindSslForm.sslResourceId || !currentDomain.value) { message.warning('请选择 SSL 证书'); return }
|
||||
try {
|
||||
const payload: AppResource = { resourceId: currentDomain.value.resourceId, resourceType: 'domain', domain: currentDomain.value.domain, sslResourceId: bindSslForm.sslResourceId, sslBound: true }
|
||||
await updateAppResource(payload)
|
||||
message.success('SSL 证书绑定成功')
|
||||
showBindSsl.value = false
|
||||
loadList()
|
||||
} catch (e: any) { message.error(e.message || '绑定失败') }
|
||||
}
|
||||
|
||||
function cancelBindSsl() { showBindSsl.value = false; bindSslForm.sslResourceId = undefined; currentDomain.value = null }
|
||||
|
||||
async function handleUnbindSsl(item: AppResource) {
|
||||
Modal.confirm({ title: '确定要解绑 SSL 证书?', content: `域名「${item.domain}」将解除与证书「${item.sslCertName || '未知'}」的绑定`, okText: '确定解绑', okType: 'warning', cancelText: '取消', onOk: async () => { try { const payload: AppResource = { resourceId: item.resourceId, resourceType: 'domain', domain: item.domain, sslResourceId: undefined, sslBound: false }; await updateAppResource(payload); message.success('SSL 证书解绑成功'); loadList() } catch (e: any) { message.error(e.message || '解绑失败') } } })
|
||||
}
|
||||
|
||||
async function handleViewSsl(item: AppResource) {
|
||||
if (!item.sslResourceId) { message.warning('未绑定 SSL 证书'); return }
|
||||
currentDomain.value = item
|
||||
const cert = sslOptions.value.find(c => c.resourceId === item.sslResourceId)
|
||||
if (cert) { currentSslCert.value = cert; showViewSsl.value = true }
|
||||
else { message.warning('证书信息加载失败') }
|
||||
}
|
||||
|
||||
function resetForm() { editRecord.value = null; formRef.value?.resetFields(); Object.assign(form, { domain: '', registrar: '', icp: false, icpNo: '', sslResourceId: undefined, appId: undefined, expireAt: null, remark: '' }) }
|
||||
|
||||
onMounted(() => { loadAppOptions(); loadSslOptions(); loadList() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
.page-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
||||
.toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.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; }
|
||||
.card-loading, .card-empty { display: flex; align-items: center; justify-content: center; min-height: 300px; }
|
||||
.domain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
||||
.domain-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 10px; padding: 16px 20px; transition: all 0.2s; display: flex; flex-direction: column; gap: 12px; }
|
||||
.domain-card:hover { border-color: #91caff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); }
|
||||
.card-expiring { border-color: #ffa39e; background: #fff2f0; }
|
||||
.card-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||
.card-header-left { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
|
||||
.domain-name { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #1a1a1a; cursor: pointer; padding: 2px 6px; border-radius: 4px; transition: background 0.15s; }
|
||||
.domain-name:hover { background: #e6f4ff; color: #1677ff; }
|
||||
.domain-text { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; }
|
||||
.copy-icon { font-size: 12px; opacity: 0; transition: opacity 0.2s; color: #1677ff; }
|
||||
.domain-name:hover .copy-icon { opacity: 1; }
|
||||
.domain-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.card-info { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #666; }
|
||||
.info-row { display: flex; align-items: center; gap: 8px; }
|
||||
.info-label { color: #999; width: 70px; flex-shrink: 0; }
|
||||
.info-value { color: #333; flex: 1; }
|
||||
.icp-value { cursor: pointer; padding: 1px 4px; border-radius: 4px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
|
||||
.icp-value:hover { background: #e6f4ff; color: #1677ff; }
|
||||
.copy-icon-small { font-size: 10px; opacity: 0.6; }
|
||||
.text-muted { color: #999; }
|
||||
.text-warning { color: #fa8c16; font-weight: 500; }
|
||||
.text-danger { color: #ff4d4f; font-weight: 500; }
|
||||
.card-actions { display: flex; gap: 8px; padding-top: 4px; border-top: 1px solid #f5f5f5; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 24px; padding-top: 16px; border-top: 1px solid #f0f0f0; }
|
||||
.form-section-header { display: flex; align-items: center; gap: 8px; padding: 8px 0 4px; font-size: 14px; font-weight: 500; color: #333; border-top: 1px solid #f0f0f0; margin-top: 12px; }
|
||||
.form-section-header:first-child { border-top: none; margin-top: 0; }
|
||||
.form-section-hint { font-size: 12px; font-weight: 400; color: #999; margin-left: 8px; }
|
||||
.ssl-info { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ssl-info-item { display: flex; align-items: center; gap: 12px; }
|
||||
.ssl-info-label { color: #999; width: 80px; flex-shrink: 0; font-size: 13px; }
|
||||
.ssl-info-value { color: #333; flex: 1; font-size: 14px; }
|
||||
</style>
|
||||
682
app/pages/developer/resources/git.vue
Normal file
682
app/pages/developer/resources/git.vue
Normal file
@@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- 工具栏 -->
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3 class="page-title">代码仓库</h3>
|
||||
<a-tag color="geekblue">{{ list.length }} 个</a-tag>
|
||||
<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>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索仓库名称"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@search="loadList"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 仓库卡片列表 -->
|
||||
<div v-if="loading" class="card-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<div v-else-if="list.length === 0" class="card-empty">
|
||||
<a-empty description="暂无可访问的代码仓库">
|
||||
<template v-if="!selectedAppId || getAppPermission(selectedAppId)?.canEditResource">
|
||||
<a-button type="primary" @click="showAdd = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加仓库
|
||||
</a-button>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
<div v-else class="git-grid">
|
||||
<div
|
||||
v-for="item in filteredList"
|
||||
:key="item.resourceId"
|
||||
class="git-card"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<div class="repo-icon">🐙</div>
|
||||
<div class="repo-info">
|
||||
<div class="repo-name">{{ item.name }}</div>
|
||||
<div class="repo-path">{{ item.gitPath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small">
|
||||
<template #icon><EllipsisOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }: any) => handleMenuAction(key, item)">
|
||||
<a-menu-item key="copy-https">
|
||||
<template #icon><CopyOutlined />复制 HTTPS</template>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="item.gitWebUrl" key="open-web">
|
||||
<template #icon><GlobalOutlined />网页访问</template>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="edit">
|
||||
<template #icon><EditOutlined />编辑</template>
|
||||
</a-menu-item>
|
||||
<a-menu-divider v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" />
|
||||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="delete" danger>
|
||||
<template #icon><DeleteOutlined />移除</template>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 卡片信息区 -->
|
||||
<div class="card-info">
|
||||
<!-- Clone URL -->
|
||||
<div class="clone-section">
|
||||
<div class="clone-label">
|
||||
<GithubOutlined /> Clone URL
|
||||
<a-tag v-if="item.gitAccessLevel" size="small" :color="accessLevelColor[item.gitAccessLevel]">
|
||||
{{ accessLevelLabel[item.gitAccessLevel] || item.gitAccessLevel }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="clone-url-row">
|
||||
<a-tooltip :title="item.gitCloneUrl || '未配置'">
|
||||
<span class="clone-url">{{ item.gitCloneUrl || '未配置' }}</span>
|
||||
</a-tooltip>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!item.gitCloneUrl"
|
||||
@click="copyCloneUrl(item)"
|
||||
>
|
||||
<template #icon><CopyOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用信息 -->
|
||||
<div v-if="item.appName" class="info-row">
|
||||
<span class="info-label"><AppstoreOutlined /></span>
|
||||
<span class="info-value">{{ item.appName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 权限提示 -->
|
||||
<div v-if="!item.isOwner" class="info-row access-hint">
|
||||
<LockOutlined style="color: #faad14; font-size: 12px;" />
|
||||
<span class="access-hint-text">{{ (item.accessLevel ?? 1) >= 2 ? '协作者(开发者)' : '协作者(只读)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部快捷操作 -->
|
||||
<div class="card-footer">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!item.gitCloneUrl"
|
||||
@click="copyCloneUrl(item)"
|
||||
>
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="item.gitWebUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openWebUrl(item.gitWebUrl!)"
|
||||
>
|
||||
<template #icon><GlobalOutlined /></template>
|
||||
访问
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑仓库弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showAdd"
|
||||
:title="editingItem ? '编辑代码仓库' : '添加代码仓库'"
|
||||
:confirm-loading="saving"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:mask-closable="false"
|
||||
width="520px"
|
||||
@ok="handleSave"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="mt-4"
|
||||
>
|
||||
<a-form-item label="仓库名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="如:Core Repository" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="所属应用" name="appId">
|
||||
<a-select
|
||||
v-model:value="form.appId"
|
||||
placeholder="请选择所属应用"
|
||||
:loading="loadingApps"
|
||||
>
|
||||
<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="gitPath">
|
||||
<a-input v-model:value="form.gitPath" placeholder="如:websopy/core" />
|
||||
<div class="form-tip">Gitea 上的仓库路径,格式:用户名/仓库名</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Clone URL" name="gitCloneUrl">
|
||||
<a-input v-model:value="form.gitCloneUrl" placeholder="https://git.websoft.top/websopy/core.git" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Web 访问地址" name="gitWebUrl">
|
||||
<a-input v-model:value="form.gitWebUrl" placeholder="https://git.websoft.top/websopy/core" />
|
||||
<div class="form-tip">Gitea 网页访问地址,用于快速跳转到仓库页面</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="访问权限" name="gitAccessLevel">
|
||||
<a-select v-model:value="form.gitAccessLevel">
|
||||
<a-select-option value="read">只读 (read)</a-select-option>
|
||||
<a-select-option value="write">读写 (write)</a-select-option>
|
||||
<a-select-option value="admin">管理员 (admin)</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="showDelete"
|
||||
title="确认删除"
|
||||
:confirm-loading="deleting"
|
||||
ok-text="确认删除"
|
||||
cancel-text="取消"
|
||||
ok-type="danger"
|
||||
@ok="handleDelete"
|
||||
>
|
||||
<p>确定要删除仓库 <strong>{{ deletingItem?.name }}</strong> 吗?此操作不可恢复。</p>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EllipsisOutlined,
|
||||
CopyOutlined,
|
||||
GlobalOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
LockOutlined,
|
||||
AppstoreOutlined,
|
||||
GithubOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { pageAppResource, addAppResource, updateAppResource, removeAppResource } from '@/api/app/appResource'
|
||||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||||
import type { AppResource } from '@/api/app/appResource/model'
|
||||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||||
import { useAppPermission } from '@/composables/useAppPermission'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '代码仓库 - 开发者中心' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const loadingApps = ref(false)
|
||||
const showAdd = ref(false)
|
||||
const showDelete = ref(false)
|
||||
const editingItem = ref<AppResource | null>(null)
|
||||
const deletingItem = ref<AppResource | null>(null)
|
||||
const searchText = ref('')
|
||||
const selectedAppId = ref<number | undefined>()
|
||||
const appOptions = ref<any[]>([])
|
||||
|
||||
const list = ref<AppResource[]>([])
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
appId: undefined as number | undefined,
|
||||
gitPath: '',
|
||||
gitCloneUrl: '',
|
||||
gitWebUrl: '',
|
||||
gitAccessLevel: 'read',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入仓库名称', trigger: 'blur' }],
|
||||
appId: [{ required: true, message: '请选择所属应用', trigger: 'change' }],
|
||||
gitPath: [{ required: true, message: '请输入仓库路径', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const accessLevelLabel: Record<string, string> = {
|
||||
read: '只读',
|
||||
write: '读写',
|
||||
admin: '管理员',
|
||||
}
|
||||
|
||||
const accessLevelColor: Record<string, string> = {
|
||||
read: 'blue',
|
||||
write: 'green',
|
||||
admin: 'purple',
|
||||
}
|
||||
|
||||
// 权限控制
|
||||
const { getAppPermission } = useAppPermission()
|
||||
|
||||
// 过滤后的列表(支持搜索)
|
||||
const filteredList = computed(() => {
|
||||
if (!searchText.value) return list.value
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
return list.value.filter(item =>
|
||||
item.name?.toLowerCase().includes(keyword) ||
|
||||
item.gitPath?.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 加载仓库列表
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pageAppResource({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
resourceType: 'git',
|
||||
appId: selectedAppId.value,
|
||||
})
|
||||
list.value = enrichResourcesWithPermission(result?.list ?? [])
|
||||
} catch (e) {
|
||||
console.error('加载仓库列表失败', e)
|
||||
message.error('加载仓库列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载应用列表
|
||||
async function loadAppOptions() {
|
||||
loadingApps.value = true
|
||||
try {
|
||||
appOptions.value = await getMyAccessibleApps()
|
||||
} catch {
|
||||
appOptions.value = []
|
||||
} finally {
|
||||
loadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制 Clone URL
|
||||
async function copyCloneUrl(item: AppResource) {
|
||||
if (!item.gitCloneUrl) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.gitCloneUrl)
|
||||
message.success('已复制 Clone URL')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开 Web URL
|
||||
function openWebUrl(url: string) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 菜单操作处理
|
||||
function handleMenuAction(key: string, item: AppResource) {
|
||||
switch (key) {
|
||||
case 'copy-https':
|
||||
copyCloneUrl(item)
|
||||
break
|
||||
case 'open-web':
|
||||
if (item.gitWebUrl) openWebUrl(item.gitWebUrl)
|
||||
break
|
||||
case 'edit':
|
||||
openEditModal(item)
|
||||
break
|
||||
case 'delete':
|
||||
openDeleteConfirm(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
function openEditModal(item: AppResource) {
|
||||
editingItem.value = item
|
||||
Object.assign(form, {
|
||||
name: item.name,
|
||||
appId: item.appId,
|
||||
gitPath: item.gitPath,
|
||||
gitCloneUrl: item.gitCloneUrl,
|
||||
gitWebUrl: item.gitWebUrl,
|
||||
gitAccessLevel: item.gitAccessLevel || 'read',
|
||||
remark: item.remark,
|
||||
})
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
// 打开删除确认弹窗
|
||||
function openDeleteConfirm(item: AppResource) {
|
||||
deletingItem.value = item
|
||||
showDelete.value = true
|
||||
}
|
||||
|
||||
// 保存仓库
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: Partial<AppResource> = {
|
||||
resourceType: 'git',
|
||||
name: form.name,
|
||||
appId: form.appId,
|
||||
gitPath: form.gitPath,
|
||||
gitCloneUrl: form.gitCloneUrl,
|
||||
gitWebUrl: form.gitWebUrl,
|
||||
gitAccessLevel: form.gitAccessLevel,
|
||||
remark: form.remark,
|
||||
status: 'running',
|
||||
}
|
||||
|
||||
if (editingItem.value?.resourceId) {
|
||||
data.resourceId = editingItem.value.resourceId
|
||||
await updateAppResource(data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await addAppResource(data)
|
||||
message.success('添加成功')
|
||||
}
|
||||
|
||||
showAdd.value = false
|
||||
resetForm()
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除仓库
|
||||
async function handleDelete() {
|
||||
if (!deletingItem.value?.resourceId) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await removeAppResource(deletingItem.value.resourceId)
|
||||
message.success('删除成功')
|
||||
showDelete.value = false
|
||||
deletingItem.value = null
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
editingItem.value = null
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
appId: undefined,
|
||||
gitPath: '',
|
||||
gitCloneUrl: '',
|
||||
gitWebUrl: '',
|
||||
gitAccessLevel: 'read',
|
||||
remark: '',
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗关闭,重置表单
|
||||
watch(showAdd, (val) => {
|
||||
if (!val) resetForm()
|
||||
})
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadAppOptions()
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 卡片网格 */
|
||||
.git-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.git-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.git-card:hover {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 2px 12px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.card-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.repo-icon {
|
||||
font-size: 28px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f0f5ff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.repo-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repo-path {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
.card-info {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.clone-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.clone-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.clone-url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.clone-url {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clone-url:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.access-hint {
|
||||
background: #fffbe6;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.access-hint-text {
|
||||
font-size: 12px;
|
||||
color: #7c4a00;
|
||||
}
|
||||
|
||||
/* 卡片底部 */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.card-empty,
|
||||
.card-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 表单提示 */
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
289
app/pages/developer/resources/index.vue
Normal file
289
app/pages/developer/resources/index.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header" style="margin-bottom: 24px">
|
||||
<h2 style="font-size: 20px; font-weight: 600; margin: 0 0 6px">资源中心</h2>
|
||||
<p style="color: #888; margin: 0; font-size: 14px">管理应用所需的基础设施资源,包括服务器、数据库、云存储、域名和 SSL 证书</p>
|
||||
</div>
|
||||
|
||||
<!-- 资源统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-bottom: 24px">
|
||||
<a-col v-for="item in resourceCards" :key="item.key" :xs="24" :sm="12" :md="8" :lg="8" :xl="4">
|
||||
<div class="resource-stat-card" @click="navigateTo(item.to)">
|
||||
<div class="stat-icon">{{ item.icon }}</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-count">
|
||||
<span class="count-num">{{ item.count }}</span>
|
||||
<span class="count-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-arrow">›</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="section-title">快速购买</div>
|
||||
<a-row :gutter="[16, 16]" style="margin-bottom: 32px">
|
||||
<a-col v-for="item in buyCards" :key="item.key" :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<div class="buy-card">
|
||||
<div class="buy-icon">{{ item.icon }}</div>
|
||||
<div class="buy-content">
|
||||
<div class="buy-title">{{ item.title }}</div>
|
||||
<div class="buy-desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
<a-button type="primary" size="small" ghost @click="handleBuy(item.key)">
|
||||
购买
|
||||
</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 协作权限说明 -->
|
||||
<div class="collab-notice">
|
||||
<LockOutlined class="notice-icon" />
|
||||
<span>资源信息按权限分级显示:<strong>创建者</strong>可查看完整信息(含密码/私钥);<strong>协作者</strong>可查看基础信息(IP、端口等),敏感信息不可见。</span>
|
||||
</div>
|
||||
|
||||
<!-- 最近添加的资源 -->
|
||||
<div class="section-title">最近添加</div>
|
||||
<a-table
|
||||
:columns="recentColumns"
|
||||
:data-source="recentResources"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="typeColor[record.resourceType]">{{ typeLabel[record.resourceType] }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 'running' ? 'success' : 'default'" :text="statusLabel[record.status]" />
|
||||
</template>
|
||||
<template v-if="column.key === 'permission'">
|
||||
<a-tag v-if="record.isOwner" color="blue" size="small">创建者</a-tag>
|
||||
<a-tag v-else color="orange" size="small"><LockOutlined /> 协作者</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="navigateTo(typeRoute[record.resourceType])">管理</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { navigateTo } from '#app'
|
||||
import { LockOutlined } from '@ant-design/icons-vue'
|
||||
import { statsAppResource, pageAppResource } from '@/api/app/appResource'
|
||||
import type { AppResource } from '@/api/app/appResource/model'
|
||||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '资源总览 - 开发者中心' })
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const resourceCards = ref([
|
||||
{ key: 'server', label: '服务器', icon: '🖥️', count: 0, to: '/developer/resources/servers' },
|
||||
{ key: 'database', label: '数据库', icon: '🗄️', count: 0, to: '/developer/resources/databases' },
|
||||
{ key: 'storage', label: '云存储', icon: '☁️', count: 0, to: '/developer/resources/storage' },
|
||||
{ key: 'domain', label: '域名', icon: '🌐', count: 0, to: '/developer/resources/domains' },
|
||||
{ key: 'ssl', label: 'SSL 证书', icon: '🔒', count: 0, to: '/developer/resources/ssl' },
|
||||
{ key: 'git', label: '代码仓库', icon: '🐙', count: 0, to: '/developer/resources/git' },
|
||||
])
|
||||
|
||||
const buyCards = [
|
||||
{ key: 'server', icon: '🖥️', title: '云服务器', desc: '高性能、稳定可靠的弹性计算服务' },
|
||||
{ key: 'database', icon: '🗄️', title: '云数据库', desc: '支持 MySQL / PostgreSQL / Redis' },
|
||||
{ key: 'storage', icon: '☁️', title: '对象存储', desc: '海量、安全、低成本的云端存储' },
|
||||
{ key: 'domain', icon: '🌐', title: '域名注册', desc: '注册您的专属域名,快速备案' },
|
||||
]
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
server: '服务器',
|
||||
database: '数据库',
|
||||
storage: '云存储',
|
||||
domain: '域名',
|
||||
ssl: 'SSL证书',
|
||||
git: '代码仓库',
|
||||
}
|
||||
|
||||
const typeColor: Record<string, string> = {
|
||||
server: 'blue',
|
||||
database: 'purple',
|
||||
storage: 'cyan',
|
||||
domain: 'green',
|
||||
ssl: 'orange',
|
||||
git: 'geekblue',
|
||||
}
|
||||
|
||||
const typeRoute: Record<string, string> = {
|
||||
server: '/developer/resources/servers',
|
||||
database: '/developer/resources/databases',
|
||||
storage: '/developer/resources/storage',
|
||||
domain: '/developer/resources/domains',
|
||||
ssl: '/developer/resources/ssl',
|
||||
git: '/developer/resources/git',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
expired: '已过期',
|
||||
pending: '配置中',
|
||||
}
|
||||
|
||||
const recentColumns = [
|
||||
{ title: '资源名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'resourceType', key: 'type' },
|
||||
{ title: '所属应用', dataIndex: 'appName', key: 'appName' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '到期时间', dataIndex: 'expireAt', key: 'expireAt' },
|
||||
{ title: '权限', key: 'permission', width: 90 },
|
||||
{ title: '操作', key: 'action' },
|
||||
]
|
||||
|
||||
// 最近添加的资源(最新 10 条)
|
||||
const recentResources = ref<AppResource[]>([])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [stats, recentResult] = await Promise.all([
|
||||
statsAppResource(),
|
||||
pageAppResource({ page: 1, limit: 10, sort: 'createTime', order: 'desc' }),
|
||||
])
|
||||
resourceCards.value.forEach(card => {
|
||||
card.count = stats[card.key] ?? 0
|
||||
})
|
||||
recentResources.value = enrichResourcesWithPermission(recentResult?.list ?? [])
|
||||
}
|
||||
catch (e) {
|
||||
console.error('加载资源数据失败', e)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleBuy(key: string) {
|
||||
// TODO: 跳转到购买页或弹出购买引导
|
||||
console.log('buy', key)
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
/* 协作权限说明 */
|
||||
.collab-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(90deg, #fffbe6 0%, #fff7e6 100%);
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: #7c4a00;
|
||||
}
|
||||
.collab-notice .notice-icon {
|
||||
color: #faad14;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.collab-notice strong {
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.resource-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.resource-stat-card:hover {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 2px 10px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
width: 44px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.count-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1677ff;
|
||||
}
|
||||
.count-unit {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.stat-arrow {
|
||||
font-size: 18px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 购买卡片 */
|
||||
.buy-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.buy-icon {
|
||||
font-size: 24px;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
.buy-content {
|
||||
flex: 1;
|
||||
}
|
||||
.buy-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.buy-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
1039
app/pages/developer/resources/servers.vue
Normal file
1039
app/pages/developer/resources/servers.vue
Normal file
File diff suppressed because it is too large
Load Diff
1108
app/pages/developer/resources/ssl.vue
Normal file
1108
app/pages/developer/resources/ssl.vue
Normal file
File diff suppressed because it is too large
Load Diff
568
app/pages/developer/resources/storage.vue
Normal file
568
app/pages/developer/resources/storage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user