初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

View 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>