Files
tiantian-system/app/pages/developer/resources/git.vue
2026-04-08 17:10:58 +08:00

683 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div class="dev-page">
<!-- 工具栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<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>