Some checks failed
Release / build-windows (push) Has been cancelled
✨ 新功能: - GitLab 平台完整支持(16+7 个 API 方法、前端图标/选择器) - Release 同步支持手动输入 Token 🐛 Bug 修复: - 自建平台凭证匹配(BaseURL 优先精确匹配) - Gitea 迁移 API service 字段类型修复(HTTP 422) - 构建时不再覆盖已有 config.yaml(数据丢失问题) - 错误信息显示实际 URL 🔨 改进: - 源平台开启代理时自动跳过 Gitea 原生迁移,改用本地代理中转 - Push 超时从 30s 提升到 10 分钟 - 设置 http.postBuffer=500MB,push --all 失败自动逐分支推送 - 新增 RunWithProxyTimeout 方法
1744 lines
60 KiB
Vue
1744 lines
60 KiB
Vue
<script setup>
|
||
import { ref, onMounted, onBeforeUnmount, computed, h } from 'vue'
|
||
import {
|
||
FolderOutlined,
|
||
FolderOpenOutlined,
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
CloudDownloadOutlined,
|
||
LoadingOutlined,
|
||
AppstoreOutlined,
|
||
CloudUploadOutlined,
|
||
SyncOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
BranchesOutlined,
|
||
KeyOutlined,
|
||
GroupOutlined,
|
||
SwapOutlined,
|
||
ExportOutlined,
|
||
CloudSyncOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import { Events } from '@wailsio/runtime'
|
||
import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app'
|
||
|
||
const props = defineProps({
|
||
selectedProject: { type: Object, default: null }
|
||
})
|
||
|
||
const emit = defineEmits(['select-project', 'tree-updated'])
|
||
|
||
const tree = ref([])
|
||
const expandedKeys = ref([])
|
||
const selectedKeys = ref([])
|
||
|
||
// 添加项目弹窗
|
||
const showAddDialog = ref(false)
|
||
const addForm = ref({ name: '', path: '', group: '' })
|
||
|
||
// 克隆项目弹窗
|
||
const showCloneDialog = ref(false)
|
||
const cloneForm = ref({ repoURL: '', parentDir: '', name: '', group: '' })
|
||
const cloneLoading = ref(false)
|
||
|
||
// 分组弹窗
|
||
const showGroupDialog = ref(false)
|
||
const groupDialogMode = ref('add')
|
||
const groupForm = ref({ name: '', icon: '', oldName: '' })
|
||
|
||
// 凭证弹窗
|
||
const showCredentialDialog = ref(false)
|
||
const credentialDialogMode = ref('add')
|
||
const credentialForm = ref({ platform: '', baseUrl: '', username: '', token: '', oldPlatform: '', oldUsername: '' })
|
||
|
||
// 凭证列表弹窗
|
||
const showCredentialList = ref(false)
|
||
const credentials = ref([])
|
||
|
||
// ---- 批量操作 ----
|
||
const showBatchModal = ref(false)
|
||
const batchOverviews = ref([])
|
||
const batchLoading = ref(false)
|
||
const selectedBatchPaths = ref([])
|
||
const batchResults = ref([])
|
||
const batchActionLoading = ref('')
|
||
const batchResultMap = computed(() => {
|
||
const map = {}
|
||
batchResults.value.forEach(r => { map[r.path] = r })
|
||
return map
|
||
})
|
||
|
||
// 右键菜单
|
||
const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', data: {} })
|
||
|
||
// ---- 迁移操作 ----
|
||
const showMigrateModal = ref(false)
|
||
const migrateLoading = ref(false)
|
||
const migrateForm = ref({ targetPlatform: '', targetUsername: '', description: '', private: false })
|
||
const selectedMigratePaths = ref([])
|
||
const migrateResults = ref([])
|
||
const migrateCredentials = ref([])
|
||
const migrateResultMap = computed(() => {
|
||
const map = {}
|
||
migrateResults.value.forEach(r => { map[r.path] = r })
|
||
return map
|
||
})
|
||
|
||
// ---- 在线迁移 ----
|
||
const showOnlineMigrateModal = ref(false)
|
||
const onlineMigrateStep = ref('form') // form | compare | execute
|
||
const onlineMigrateSrc = ref({ platform: '', baseUrl: '', username: '', token: '', useProxy: false })
|
||
const onlineMigrateTgt = ref({ platform: '', baseUrl: '', username: '', token: '', useProxy: false })
|
||
const onlineCompareResult = ref({ sourceRepos: [], targetRepos: [], missingRepos: [] })
|
||
const selectedOnlineRepos = ref([])
|
||
const onlineCompareLoading = ref(false)
|
||
const onlineMigrateLoading = ref(false)
|
||
const onlineMigrateResults = ref([])
|
||
const onlineMigrateCredentials = ref([])
|
||
const migrateOpts = ref({ labels: true, issues: true, pullRequests: true, releases: true, milestones: true })
|
||
const migrateProgress = ref({ current: 0, total: 0, repoName: '', phase: '', percent: 0, message: '' })
|
||
let migrateProgressUnsubscribe = null
|
||
|
||
// 获取所有分组名(用于下拉选择)
|
||
const groupNames = computed(() => {
|
||
const names = []
|
||
;(tree.value || []).forEach(node => {
|
||
if (node.type === 'group') {
|
||
names.push(node.label)
|
||
}
|
||
})
|
||
return names
|
||
})
|
||
|
||
// 构建 key → 原始节点 的映射
|
||
const nodeMap = computed(() => {
|
||
const map = {}
|
||
;(tree.value || []).forEach(group => {
|
||
group.children?.forEach(proj => {
|
||
map[proj.key] = proj
|
||
})
|
||
})
|
||
return map
|
||
})
|
||
|
||
// 转换后端树数据为 a-tree 格式
|
||
const treeData = computed(() => {
|
||
return (tree.value || []).map(group => ({
|
||
key: group.key,
|
||
title: group.label,
|
||
type: 'group',
|
||
icon: group.icon,
|
||
isLeaf: false,
|
||
children: (group.children || []).map(proj => ({
|
||
key: proj.key,
|
||
title: proj.label,
|
||
type: 'project',
|
||
path: proj.path,
|
||
isLeaf: true,
|
||
}))
|
||
}))
|
||
})
|
||
|
||
async function loadTree() {
|
||
try {
|
||
const result = await AppService.GetProjectTree()
|
||
tree.value = result || []
|
||
const keys = []
|
||
tree.value.forEach(node => {
|
||
keys.push(node.key)
|
||
})
|
||
expandedKeys.value = keys
|
||
} catch (e) {
|
||
console.error('加载项目树失败:', e)
|
||
}
|
||
}
|
||
|
||
function onTreeSelect(keys, { node }) {
|
||
if (node.type === 'project') {
|
||
selectedKeys.value = keys
|
||
const raw = nodeMap.value[node.key]
|
||
const path = raw?.path || node.path || ''
|
||
emit('select-project', { key: node.key, label: node.title || raw?.label, path, type: 'project' })
|
||
}
|
||
}
|
||
|
||
// --- 右键菜单 ---
|
||
function onRightClick({ event, node }) {
|
||
event.preventDefault()
|
||
const type = node.type
|
||
const data = {}
|
||
if (type === 'group') {
|
||
data.groupName = node.title
|
||
} else if (type === 'project') {
|
||
data.name = node.title
|
||
data.path = node.path
|
||
}
|
||
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, type, data }
|
||
}
|
||
|
||
function onSidebarContextMenu(e) {
|
||
e.preventDefault()
|
||
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, type: 'empty', data: {} }
|
||
}
|
||
|
||
function hideContextMenu() {
|
||
contextMenu.value.visible = false
|
||
}
|
||
|
||
function onContextMenuClick({ key: action }) {
|
||
const { type, data } = contextMenu.value
|
||
hideContextMenu()
|
||
switch (action) {
|
||
case 'add-group': openAddGroupDialog(); break
|
||
case 'edit-group': openEditGroupDialog(data.groupName); break
|
||
case 'remove-group': removeGroup(data.groupName); break
|
||
case 'add-project': openAddDialog(data.groupName); break
|
||
case 'clone-project': openCloneDialog(data.groupName); break
|
||
case 'remove-project': removeProject(data.path); break
|
||
case 'manage-credentials': openCredentialList(); break
|
||
}
|
||
}
|
||
|
||
const contextMenuItems = computed(() => {
|
||
const { type } = contextMenu.value
|
||
if (type === 'empty') {
|
||
return [
|
||
{ key: 'add-group', label: '添加分组', icon: h(GroupOutlined) },
|
||
{ key: 'add-project', label: '添加项目', icon: h(FolderOutlined) },
|
||
{ key: 'clone-project', label: '克隆项目', icon: h(CloudDownloadOutlined) },
|
||
{ type: 'divider' },
|
||
{ key: 'manage-credentials', label: '凭证管理', icon: h(KeyOutlined) },
|
||
]
|
||
}
|
||
if (type === 'group') {
|
||
return [
|
||
{ key: 'add-project', label: '添加项目', icon: h(FolderOutlined) },
|
||
{ key: 'clone-project', label: '克隆项目', icon: h(CloudDownloadOutlined) },
|
||
{ type: 'divider' },
|
||
{ key: 'edit-group', label: '编辑分组', icon: h(EditOutlined) },
|
||
{ key: 'add-group', label: '添加分组', icon: h(GroupOutlined) },
|
||
{ type: 'divider' },
|
||
{ key: 'remove-group', label: '删除分组', danger: true, icon: h(DeleteOutlined) },
|
||
]
|
||
}
|
||
if (type === 'project') {
|
||
return [
|
||
{ key: 'add-project', label: '添加项目', icon: h(FolderOutlined) },
|
||
{ key: 'clone-project', label: '克隆项目', icon: h(CloudDownloadOutlined) },
|
||
{ type: 'divider' },
|
||
{ key: 'remove-project', label: '删除项目', danger: true, icon: h(DeleteOutlined) },
|
||
]
|
||
}
|
||
return []
|
||
})
|
||
|
||
// --- 分组操作 ---
|
||
function openAddGroupDialog() {
|
||
groupDialogMode.value = 'add'
|
||
groupForm.value = { name: '', icon: 'folder', oldName: '' }
|
||
showGroupDialog.value = true
|
||
}
|
||
|
||
function openEditGroupDialog(groupName) {
|
||
groupDialogMode.value = 'edit'
|
||
groupForm.value = { name: groupName, icon: '', oldName: groupName }
|
||
showGroupDialog.value = true
|
||
}
|
||
|
||
async function saveGroup() {
|
||
if (!groupForm.value.name) return
|
||
try {
|
||
if (groupDialogMode.value === 'add') {
|
||
await AppService.AddGroup(groupForm.value.name, groupForm.value.icon || 'folder')
|
||
} else {
|
||
await AppService.UpdateGroup(groupForm.value.oldName, groupForm.value.name, groupForm.value.icon)
|
||
}
|
||
showGroupDialog.value = false
|
||
await loadTree()
|
||
emit('tree-updated')
|
||
} catch (e) {
|
||
message.error('操作失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
async function removeGroup(name) {
|
||
if (!confirm(`确定删除分组 "${name}" 吗?分组下的项目将移到"未分组"。`)) return
|
||
try {
|
||
await AppService.RemoveGroup(name)
|
||
await loadTree()
|
||
emit('tree-updated')
|
||
} catch (e) {
|
||
message.error('删除失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
// --- 项目操作 ---
|
||
function openAddDialog(groupName) {
|
||
addForm.value = { name: '', path: '', group: groupName || '' }
|
||
showAddDialog.value = true
|
||
}
|
||
|
||
async function pickDirectory() {
|
||
try {
|
||
const path = await AppService.SelectDirectory()
|
||
if (path) {
|
||
addForm.value.path = path
|
||
if (!addForm.value.name) {
|
||
const parts = path.replace(/\\/g, '/').split('/')
|
||
addForm.value.name = parts[parts.length - 1] || ''
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('选择文件夹失败:', e)
|
||
}
|
||
}
|
||
|
||
async function addProject() {
|
||
if (!addForm.value.name || !addForm.value.path) return
|
||
try {
|
||
await AppService.AddProject(addForm.value.name, addForm.value.path, addForm.value.group)
|
||
showAddDialog.value = false
|
||
await loadTree()
|
||
emit('tree-updated')
|
||
} catch (e) {
|
||
message.error('添加项目失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
// --- 克隆项目 ---
|
||
function openCloneDialog(groupName) {
|
||
cloneForm.value = { repoURL: '', parentDir: '', name: '', group: groupName || '' }
|
||
showCloneDialog.value = true
|
||
}
|
||
|
||
async function pickCloneDirectory() {
|
||
try {
|
||
const path = await AppService.SelectDirectory()
|
||
if (path) {
|
||
cloneForm.value.parentDir = path
|
||
}
|
||
} catch (e) {
|
||
console.error('选择文件夹失败:', e)
|
||
}
|
||
}
|
||
|
||
function onRepoURLChange() {
|
||
if (!cloneForm.value.name && cloneForm.value.repoURL) {
|
||
const url = cloneForm.value.repoURL.trim()
|
||
const match = url.match(/\/([^\/]+?)(\.git)?$/)
|
||
if (match) {
|
||
cloneForm.value.name = match[1]
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cloneProject() {
|
||
if (!cloneForm.value.repoURL || !cloneForm.value.parentDir || !cloneForm.value.name) return
|
||
cloneLoading.value = true
|
||
try {
|
||
await AppService.CloneProject(
|
||
cloneForm.value.repoURL,
|
||
cloneForm.value.parentDir,
|
||
cloneForm.value.name,
|
||
cloneForm.value.group
|
||
)
|
||
showCloneDialog.value = false
|
||
message.success('克隆成功')
|
||
await loadTree()
|
||
emit('tree-updated')
|
||
} catch (e) {
|
||
message.error('克隆失败: ' + String(e))
|
||
} finally {
|
||
cloneLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function removeProject(path) {
|
||
const name = path.split(/[/\\]/).pop()
|
||
if (!confirm(`确定删除项目 "${name}" 吗?`)) return
|
||
try {
|
||
await AppService.RemoveProject(path)
|
||
await loadTree()
|
||
emit('tree-updated')
|
||
} catch (e) {
|
||
message.error('删除项目失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
// --- 凭证管理 ---
|
||
async function openCredentialList() {
|
||
try {
|
||
const list = await AppService.GetCredentials()
|
||
credentials.value = list || []
|
||
} catch (e) {
|
||
credentials.value = []
|
||
}
|
||
showCredentialList.value = true
|
||
}
|
||
|
||
function openAddCredentialDialog() {
|
||
credentialDialogMode.value = 'add'
|
||
credentialForm.value = { platform: '', baseUrl: '', username: '', token: '', oldPlatform: '', oldUsername: '' }
|
||
showCredentialDialog.value = true
|
||
}
|
||
|
||
function openEditCredentialDialog(cred) {
|
||
credentialDialogMode.value = 'edit'
|
||
credentialForm.value = {
|
||
platform: cred.platform,
|
||
baseUrl: cred.baseUrl || '',
|
||
username: cred.username,
|
||
token: cred.token || '',
|
||
oldPlatform: cred.platform,
|
||
oldUsername: cred.username
|
||
}
|
||
showCredentialDialog.value = true
|
||
}
|
||
|
||
async function saveCredential() {
|
||
if (!credentialForm.value.platform || !credentialForm.value.username) return
|
||
try {
|
||
if (credentialDialogMode.value === 'add') {
|
||
await AppService.AddCredential(
|
||
credentialForm.value.platform,
|
||
credentialForm.value.baseUrl,
|
||
credentialForm.value.username,
|
||
credentialForm.value.token
|
||
)
|
||
} else {
|
||
await AppService.UpdateCredential(
|
||
credentialForm.value.oldPlatform,
|
||
credentialForm.value.oldUsername,
|
||
credentialForm.value.baseUrl,
|
||
credentialForm.value.token
|
||
)
|
||
}
|
||
showCredentialDialog.value = false
|
||
await openCredentialList()
|
||
} catch (e) {
|
||
message.error('操作失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
async function removeCredential(platform, username) {
|
||
if (!confirm(`确定删除凭证 "${platform}/${username}" 吗?`)) return
|
||
try {
|
||
await AppService.RemoveCredential(platform, username)
|
||
await openCredentialList()
|
||
} catch (e) {
|
||
message.error('删除失败: ' + String(e))
|
||
}
|
||
}
|
||
|
||
// ---- 批量操作 ----
|
||
async function openBatchModal() {
|
||
showBatchModal.value = true
|
||
batchResults.value = []
|
||
await loadBatchOverview()
|
||
}
|
||
|
||
async function loadBatchOverview() {
|
||
batchLoading.value = true
|
||
try {
|
||
const overviews = await AppService.GetAllProjectOverview()
|
||
batchOverviews.value = Array.isArray(overviews) ? overviews : []
|
||
selectedBatchPaths.value = batchOverviews.value
|
||
.filter(p => !p.error)
|
||
.map(p => p.path)
|
||
} catch (e) {
|
||
console.error('获取项目概览失败:', e)
|
||
} finally {
|
||
batchLoading.value = false
|
||
}
|
||
}
|
||
|
||
function toggleBatchPath(path) {
|
||
const idx = selectedBatchPaths.value.indexOf(path)
|
||
if (idx >= 0) {
|
||
selectedBatchPaths.value = selectedBatchPaths.value.filter(p => p !== path)
|
||
} else {
|
||
selectedBatchPaths.value = [...selectedBatchPaths.value, path]
|
||
}
|
||
}
|
||
|
||
function toggleAllBatchPaths(e) {
|
||
if (e.target.checked) {
|
||
selectedBatchPaths.value = batchOverviews.value
|
||
.filter(p => !p.error)
|
||
.map(p => p.path)
|
||
} else {
|
||
selectedBatchPaths.value = []
|
||
}
|
||
}
|
||
|
||
async function doBatchPull() {
|
||
if (!selectedBatchPaths.value.length) return
|
||
batchActionLoading.value = 'pull'
|
||
try {
|
||
const results = await AppService.BatchPull(selectedBatchPaths.value, 'origin')
|
||
batchResults.value = Array.isArray(results) ? results : []
|
||
const successCount = batchResults.value.filter(r => r.success).length
|
||
message.info(`批量 Pull 完成:${successCount}/${batchResults.value.length} 成功`)
|
||
await loadBatchOverview()
|
||
} catch (e) {
|
||
Modal.error({ title: '批量 Pull 失败', content: String(e) })
|
||
} finally {
|
||
batchActionLoading.value = ''
|
||
}
|
||
}
|
||
|
||
async function doBatchPush() {
|
||
if (!selectedBatchPaths.value.length) return
|
||
batchActionLoading.value = 'push'
|
||
try {
|
||
const results = await AppService.BatchPush(selectedBatchPaths.value, 'origin')
|
||
batchResults.value = Array.isArray(results) ? results : []
|
||
const successCount = batchResults.value.filter(r => r.success).length
|
||
message.info(`批量 Push 完成:${successCount}/${batchResults.value.length} 成功`)
|
||
await loadBatchOverview()
|
||
} catch (e) {
|
||
Modal.error({ title: '批量 Push 失败', content: String(e) })
|
||
} finally {
|
||
batchActionLoading.value = ''
|
||
}
|
||
}
|
||
|
||
// ---- 迁移功能 ----
|
||
async function openMigrateModal() {
|
||
showMigrateModal.value = true
|
||
migrateResults.value = []
|
||
migrateForm.value = { targetPlatform: '', targetUsername: '', description: '', private: false }
|
||
// 加载凭证和项目概览
|
||
try {
|
||
const creds = await AppService.GetCredentials()
|
||
migrateCredentials.value = Array.isArray(creds) ? creds.filter(c => c.token) : []
|
||
} catch (e) {
|
||
migrateCredentials.value = []
|
||
}
|
||
// 复用 batch overview
|
||
if (!batchOverviews.value.length) {
|
||
await loadBatchOverview()
|
||
}
|
||
selectedMigratePaths.value = batchOverviews.value
|
||
.filter(p => !p.error)
|
||
.map(p => p.path)
|
||
}
|
||
|
||
function toggleMigratePath(path) {
|
||
const idx = selectedMigratePaths.value.indexOf(path)
|
||
if (idx >= 0) {
|
||
selectedMigratePaths.value = selectedMigratePaths.value.filter(p => p !== path)
|
||
} else {
|
||
selectedMigratePaths.value = [...selectedMigratePaths.value, path]
|
||
}
|
||
}
|
||
|
||
function toggleAllMigratePaths(e) {
|
||
if (e.target.checked) {
|
||
selectedMigratePaths.value = batchOverviews.value
|
||
.filter(p => !p.error)
|
||
.map(p => p.path)
|
||
} else {
|
||
selectedMigratePaths.value = []
|
||
}
|
||
}
|
||
|
||
// 选择凭证后自动填充 platform + username
|
||
function onMigrateCredentialChange(val) {
|
||
const parts = val.split('/')
|
||
migrateForm.value.targetPlatform = parts[0]
|
||
migrateForm.value.targetUsername = parts.slice(1).join('/')
|
||
}
|
||
|
||
async function doMigrate() {
|
||
if (!selectedMigratePaths.value.length) {
|
||
message.warning('请选择要迁移的项目')
|
||
return
|
||
}
|
||
if (!migrateForm.value.targetPlatform || !migrateForm.value.targetUsername) {
|
||
message.warning('请选择目标凭证')
|
||
return
|
||
}
|
||
migrateLoading.value = true
|
||
try {
|
||
const results = await AppService.BatchMigrateProjects(
|
||
selectedMigratePaths.value,
|
||
migrateForm.value.targetPlatform,
|
||
migrateForm.value.targetUsername,
|
||
migrateForm.value.description,
|
||
migrateForm.value.private
|
||
)
|
||
migrateResults.value = Array.isArray(results) ? results : []
|
||
const successCount = migrateResults.value.filter(r => r.success).length
|
||
message.info(`迁移完成:${successCount}/${migrateResults.value.length} 成功`)
|
||
await loadTree()
|
||
} catch (e) {
|
||
Modal.error({ title: '迁移失败', content: String(e) })
|
||
} finally {
|
||
migrateLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 在线迁移功能 ----
|
||
async function openOnlineMigrateModal() {
|
||
showOnlineMigrateModal.value = true
|
||
onlineMigrateStep.value = 'form'
|
||
onlineMigrateSrc.value = { platform: '', baseUrl: '', username: '', token: '', useProxy: false }
|
||
onlineMigrateTgt.value = { platform: '', baseUrl: '', username: '', token: '', useProxy: false }
|
||
onlineCompareResult.value = { sourceRepos: [], targetRepos: [], missingRepos: [] }
|
||
selectedOnlineRepos.value = []
|
||
onlineMigrateResults.value = []
|
||
migrateProgress.value = { current: 0, total: 0, repoName: '', phase: '', percent: 0, message: '' }
|
||
// 监听迁移进度事件
|
||
if (migrateProgressUnsubscribe) migrateProgressUnsubscribe()
|
||
migrateProgressUnsubscribe = Events.On('online-migrate-progress', (event) => {
|
||
const data = event.data?.[0] || event.data
|
||
if (data) {
|
||
migrateProgress.value = data
|
||
}
|
||
})
|
||
// 加载凭证列表用于快捷选择
|
||
try {
|
||
const creds = await AppService.GetCredentials()
|
||
onlineMigrateCredentials.value = Array.isArray(creds) ? creds.filter(c => c.token) : []
|
||
} catch (e) {
|
||
onlineMigrateCredentials.value = []
|
||
}
|
||
}
|
||
|
||
function onOnlineSrcCredentialChange(val) {
|
||
const cred = onlineMigrateCredentials.value.find(c => (c.platform + '/' + c.username) === val)
|
||
if (cred) {
|
||
onlineMigrateSrc.value.platform = cred.platform
|
||
onlineMigrateSrc.value.baseUrl = cred.baseUrl || ''
|
||
onlineMigrateSrc.value.username = cred.username
|
||
onlineMigrateSrc.value.token = cred.token || ''
|
||
}
|
||
}
|
||
|
||
function onOnlineTgtCredentialChange(val) {
|
||
const cred = onlineMigrateCredentials.value.find(c => (c.platform + '/' + c.username) === val)
|
||
if (cred) {
|
||
onlineMigrateTgt.value.platform = cred.platform
|
||
onlineMigrateTgt.value.baseUrl = cred.baseUrl || ''
|
||
onlineMigrateTgt.value.username = cred.username
|
||
onlineMigrateTgt.value.token = cred.token || ''
|
||
}
|
||
}
|
||
|
||
async function doOnlineCompare() {
|
||
const src = onlineMigrateSrc.value
|
||
const tgt = onlineMigrateTgt.value
|
||
if (!src.platform || !src.username || !src.token) {
|
||
message.warning('请填写完整的源平台信息')
|
||
return
|
||
}
|
||
if (!tgt.platform || !tgt.username || !tgt.token) {
|
||
message.warning('请填写完整的目标平台信息')
|
||
return
|
||
}
|
||
onlineCompareLoading.value = true
|
||
try {
|
||
const result = await AppService.OnlineMigrateCompare(
|
||
src.platform, src.baseUrl, src.username, src.token,
|
||
tgt.platform, tgt.baseUrl, tgt.username, tgt.token
|
||
)
|
||
onlineCompareResult.value = result || { sourceRepos: [], targetRepos: [], missingRepos: [] }
|
||
// 默认全选缺少的仓库
|
||
selectedOnlineRepos.value = (onlineCompareResult.value.missingRepos || []).map(r => r.name)
|
||
onlineMigrateStep.value = 'compare'
|
||
if (!onlineCompareResult.value.missingRepos?.length) {
|
||
message.success('目标平台已拥有所有仓库,无需迁移')
|
||
}
|
||
} catch (e) {
|
||
Modal.error({ title: '对比失败', content: String(e) })
|
||
} finally {
|
||
onlineCompareLoading.value = false
|
||
}
|
||
}
|
||
|
||
function toggleOnlineRepo(name) {
|
||
const idx = selectedOnlineRepos.value.indexOf(name)
|
||
if (idx >= 0) {
|
||
selectedOnlineRepos.value = selectedOnlineRepos.value.filter(n => n !== name)
|
||
} else {
|
||
selectedOnlineRepos.value = [...selectedOnlineRepos.value, name]
|
||
}
|
||
}
|
||
|
||
function toggleAllOnlineRepos(e) {
|
||
if (e.target.checked) {
|
||
selectedOnlineRepos.value = (onlineCompareResult.value.missingRepos || []).map(r => r.name)
|
||
} else {
|
||
selectedOnlineRepos.value = []
|
||
}
|
||
}
|
||
|
||
async function doOnlineMigrateExecute() {
|
||
if (!selectedOnlineRepos.value.length) {
|
||
message.warning('请选择要迁移的仓库')
|
||
return
|
||
}
|
||
const src = onlineMigrateSrc.value
|
||
const tgt = onlineMigrateTgt.value
|
||
onlineMigrateLoading.value = true
|
||
onlineMigrateStep.value = 'execute'
|
||
try {
|
||
const results = await AppService.OnlineMigrateExecute(
|
||
src.platform, src.baseUrl, src.username, src.token,
|
||
tgt.platform, tgt.baseUrl, tgt.username, tgt.token,
|
||
selectedOnlineRepos.value,
|
||
src.useProxy,
|
||
tgt.useProxy,
|
||
migrateOpts.value
|
||
)
|
||
onlineMigrateResults.value = Array.isArray(results) ? results : []
|
||
const successCount = onlineMigrateResults.value.filter(r => r.success).length
|
||
message.info(`在线迁移完成:${successCount}/${onlineMigrateResults.value.length} 成功`)
|
||
} catch (e) {
|
||
Modal.error({ title: '在线迁移失败', content: String(e) })
|
||
} finally {
|
||
onlineMigrateLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 分组图标
|
||
const groupIcons = {
|
||
github: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`,
|
||
gitee: `<svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z"/></svg>`,
|
||
gitea: `<svg viewBox="0 0 640 640" fill="currentColor"><path d="M395.022 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM243.2 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM319.111 22.756C158.578 22.756 28.444 152.889 28.444 313.422s130.133 290.667 290.667 290.667 290.667-130.133 290.667-290.667S479.644 22.756 319.111 22.756zm165.689 361.244c0 5.333-0.711 10.667-1.778 15.644-20.267 93.867-148.089 167.111-305.067 167.111a360.604 360.604 0 0 1-65.778-5.689c-21.333 18.133-56.889 37.689-101.333 49.422-7.822 2.133-16 3.911-24.889 5.333h-0.711c-4.267 0-7.822-3.556-8.889-7.822v-0.356c-1.067-4.622 2.133-7.467 4.978-10.667 17.067-18.844 36.622-34.844 48-71.467-51.911-36.267-83.911-85.067-83.911-139.733 0-106.311 107.378-192.356 239.644-192.356 118.4 0 220.089 68.267 238.578 160.356 1.778 6.4 3.556 14.578 3.556 22.4-0.356 2.489-0.356 5.333-0.356 7.822z"/></svg>`,
|
||
gitlab: `<svg viewBox="0 0 512 512" fill="currentColor"><path d="M494.07 281.6l-25.18-78.08-49.71-153.87a11.68 11.68 0 0 0-22.2 0l-49.71 153.87H164.73L115.02 49.65a11.68 11.68 0 0 0-22.2 0L43.11 203.52 17.93 281.6a23.4 23.4 0 0 0 8.49 26.14L256 493.49l229.58-185.75a23.4 23.4 0 0 0 8.49-26.14z"/></svg>`,
|
||
}
|
||
|
||
function getGroupIcon(icon) {
|
||
return groupIcons[icon] || null
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadTree()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (migrateProgressUnsubscribe) migrateProgressUnsubscribe()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<aside class="sidebar">
|
||
<div class="sidebar-header">
|
||
<span class="logo">GitPilot</span>
|
||
<a-space :size="0">
|
||
<a-button type="text" size="small" @click="openOnlineMigrateModal" title="在线迁移">
|
||
<template #icon><CloudSyncOutlined /></template>
|
||
</a-button>
|
||
<a-button type="text" size="small" @click="openMigrateModal" title="迁移项目">
|
||
<template #icon><ExportOutlined /></template>
|
||
</a-button>
|
||
<a-button type="text" size="small" @click="openBatchModal" title="批量操作">
|
||
<template #icon><AppstoreOutlined /></template>
|
||
</a-button>
|
||
<a-button type="text" size="small" @click="openAddGroupDialog" title="添加分组">
|
||
<template #icon><PlusOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<div class="sidebar-content" @contextmenu="onSidebarContextMenu">
|
||
<a-tree
|
||
v-if="treeData.length"
|
||
v-model:expandedKeys="expandedKeys"
|
||
v-model:selectedKeys="selectedKeys"
|
||
:tree-data="treeData"
|
||
block-node
|
||
:show-icon="true"
|
||
@select="onTreeSelect"
|
||
@rightClick="onRightClick"
|
||
@contextmenu.stop
|
||
>
|
||
<template #title="{ title, type, icon }">
|
||
<div class="tree-node-title">
|
||
<span
|
||
v-if="type === 'group' && getGroupIcon(icon)"
|
||
class="group-logo"
|
||
v-html="getGroupIcon(icon)"
|
||
></span>
|
||
<GroupOutlined v-else-if="type === 'group'" class="node-icon" />
|
||
<FolderOutlined v-else class="node-icon" />
|
||
<span class="node-label">{{ title }}</span>
|
||
</div>
|
||
</template>
|
||
</a-tree>
|
||
<div v-else class="empty-tip">右键添加分组或项目</div>
|
||
</div>
|
||
|
||
<!-- 右键菜单 -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="contextMenu.visible"
|
||
class="context-menu-mask"
|
||
@click="hideContextMenu"
|
||
@contextmenu.prevent="hideContextMenu"
|
||
>
|
||
<div @click.stop @contextmenu.stop>
|
||
<a-menu
|
||
class="context-menu-popup"
|
||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||
:items="contextMenuItems"
|
||
@click="onContextMenuClick"
|
||
mode="vertical"
|
||
:selectable="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 添加项目弹窗 -->
|
||
<a-modal
|
||
v-model:open="showAddDialog"
|
||
title="添加项目"
|
||
@ok="addProject"
|
||
ok-text="添加"
|
||
cancel-text="取消"
|
||
:width="420"
|
||
>
|
||
<a-form layout="vertical" :style="{ marginTop: '16px' }">
|
||
<a-form-item label="项目名称">
|
||
<a-input v-model:value="addForm.name" placeholder="my-project" />
|
||
</a-form-item>
|
||
<a-form-item label="本地路径">
|
||
<a-input v-model:value="addForm.path" placeholder="F:/Projects/xxx">
|
||
<template #suffix>
|
||
<FolderOpenOutlined
|
||
style="cursor: pointer; color: var(--accent, #89b4fa);"
|
||
title="选择文件夹"
|
||
@click="pickDirectory"
|
||
/>
|
||
</template>
|
||
</a-input>
|
||
</a-form-item>
|
||
<a-form-item label="分组">
|
||
<a-select v-model:value="addForm.group" placeholder="选择分组(可选)" allow-clear>
|
||
<a-select-option v-for="g in groupNames" :key="g" :value="g">{{ g }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 克隆项目弹窗 -->
|
||
<a-modal
|
||
v-model:open="showCloneDialog"
|
||
title="克隆项目"
|
||
@ok="cloneProject"
|
||
:ok-text="cloneLoading ? '克隆中...' : '克隆'"
|
||
cancel-text="取消"
|
||
:ok-button-props="{ loading: cloneLoading, disabled: !cloneForm.repoURL || !cloneForm.parentDir || !cloneForm.name }"
|
||
:closable="!cloneLoading"
|
||
:maskClosable="!cloneLoading"
|
||
:width="480"
|
||
>
|
||
<a-form layout="vertical" :style="{ marginTop: '16px' }">
|
||
<a-form-item label="仓库地址" required>
|
||
<a-input
|
||
v-model:value="cloneForm.repoURL"
|
||
placeholder="https://github.com/user/repo.git"
|
||
@blur="onRepoURLChange"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="克隆到目录" required>
|
||
<a-input v-model:value="cloneForm.parentDir" placeholder="选择父目录" read-only>
|
||
<template #suffix>
|
||
<FolderOpenOutlined
|
||
style="cursor: pointer; color: var(--accent, #89b4fa);"
|
||
title="选择文件夹"
|
||
@click="pickCloneDirectory"
|
||
/>
|
||
</template>
|
||
</a-input>
|
||
</a-form-item>
|
||
<a-form-item label="项目名称" required>
|
||
<a-input v-model:value="cloneForm.name" placeholder="自动从仓库地址提取" />
|
||
</a-form-item>
|
||
<a-form-item label="分组">
|
||
<a-select v-model:value="cloneForm.group" placeholder="选择分组(可选)" allow-clear>
|
||
<a-select-option v-for="g in groupNames" :key="g" :value="g">{{ g }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<div v-if="cloneForm.parentDir && cloneForm.name" style="font-size: 12px; color: var(--text-muted); margin-top: -8px; margin-bottom: 8px;">
|
||
<span>目标路径:</span>
|
||
<span style="color: var(--accent, #89b4fa);">{{ cloneForm.parentDir }}/{{ cloneForm.name }}</span>
|
||
</div>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 分组弹窗 -->
|
||
<a-modal
|
||
v-model:open="showGroupDialog"
|
||
:title="groupDialogMode === 'add' ? '添加分组' : '编辑分组'"
|
||
@ok="saveGroup"
|
||
:ok-text="groupDialogMode === 'add' ? '添加' : '保存'"
|
||
cancel-text="取消"
|
||
:width="420"
|
||
>
|
||
<a-form layout="vertical" :style="{ marginTop: '16px' }">
|
||
<a-form-item label="分组名称">
|
||
<a-input v-model:value="groupForm.name" placeholder="我的项目" />
|
||
</a-form-item>
|
||
<a-form-item label="图标">
|
||
<a-select v-model:value="groupForm.icon" placeholder="选择图标">
|
||
<a-select-option value="folder">📁 文件夹</a-select-option>
|
||
<a-select-option value="github">🐙 GitHub</a-select-option>
|
||
<a-select-option value="gitee">🟠 Gitee</a-select-option>
|
||
<a-select-option value="gitea">🍵 Gitea</a-select-option>
|
||
<a-select-option value="gitlab">🦊 GitLab</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 凭证列表弹窗 -->
|
||
<a-modal
|
||
v-model:open="showCredentialList"
|
||
title="凭证管理"
|
||
:footer="null"
|
||
:width="520"
|
||
>
|
||
<div style="margin-bottom: 12px;">
|
||
<a-button size="small" @click="openAddCredentialDialog">
|
||
<template #icon><PlusOutlined /></template>
|
||
添加凭证
|
||
</a-button>
|
||
</div>
|
||
<div v-if="!credentials.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
|
||
暂无凭证
|
||
</div>
|
||
<div v-else class="credential-list">
|
||
<div v-for="cred in credentials" :key="cred.platform + '/' + cred.username" class="credential-item">
|
||
<div class="credential-info">
|
||
<div class="credential-name">
|
||
<a-tag :color="cred.platform === 'github' ? 'default' : cred.platform === 'gitee' ? 'red' : 'green'" size="small">
|
||
{{ cred.platform }}
|
||
</a-tag>
|
||
<span>{{ cred.username }}</span>
|
||
</div>
|
||
<div v-if="cred.baseUrl" class="credential-url">{{ cred.baseUrl }}</div>
|
||
</div>
|
||
<a-space :size="4">
|
||
<a-button type="text" size="small" @click="openEditCredentialDialog(cred)">
|
||
<template #icon><EditOutlined /></template>
|
||
</a-button>
|
||
<a-button type="text" size="small" danger @click="removeCredential(cred.platform, cred.username)">
|
||
<template #icon><DeleteOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 凭证编辑弹窗 -->
|
||
<a-modal
|
||
v-model:open="showCredentialDialog"
|
||
:title="credentialDialogMode === 'add' ? '添加凭证' : '编辑凭证'"
|
||
@ok="saveCredential"
|
||
:ok-text="credentialDialogMode === 'add' ? '添加' : '保存'"
|
||
cancel-text="取消"
|
||
:width="420"
|
||
>
|
||
<a-form layout="vertical" :style="{ marginTop: '16px' }">
|
||
<a-form-item label="平台">
|
||
<a-select
|
||
v-model:value="credentialForm.platform"
|
||
placeholder="选择平台"
|
||
:disabled="credentialDialogMode === 'edit'"
|
||
>
|
||
<a-select-option value="github">GitHub</a-select-option>
|
||
<a-select-option value="gitee">Gitee</a-select-option>
|
||
<a-select-option value="gitea">Gitea</a-select-option>
|
||
<a-select-option value="gitlab">GitLab</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="用户名">
|
||
<a-input
|
||
v-model:value="credentialForm.username"
|
||
placeholder="your-username"
|
||
:disabled="credentialDialogMode === 'edit'"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<template #label>
|
||
Base URL <span class="form-hint">(自建平台需要填写)</span>
|
||
</template>
|
||
<a-input v-model:value="credentialForm.baseUrl" placeholder="http://192.168.1.10:3000" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<template #label>
|
||
Token <span class="form-hint">(可选,用于 API 认证)</span>
|
||
</template>
|
||
<a-input-password v-model:value="credentialForm.token" placeholder="ghp_xxxx / Bearer token" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 批量操作弹窗 -->
|
||
<a-modal
|
||
v-model:open="showBatchModal"
|
||
title="批量操作"
|
||
:width="600"
|
||
:footer="null"
|
||
>
|
||
<div class="batch-panel">
|
||
<div class="batch-header">
|
||
<a-checkbox
|
||
:checked="selectedBatchPaths.length === batchOverviews.filter(p => !p.error).length && batchOverviews.length > 0"
|
||
:indeterminate="selectedBatchPaths.length > 0 && selectedBatchPaths.length < batchOverviews.filter(p => !p.error).length"
|
||
@change="toggleAllBatchPaths"
|
||
>
|
||
全选
|
||
</a-checkbox>
|
||
<span style="flex:1"></span>
|
||
<a-space :size="4">
|
||
<a-button size="small" :loading="batchActionLoading === 'pull'" :disabled="!selectedBatchPaths.length" @click="doBatchPull">
|
||
<template #icon><CloudDownloadOutlined /></template>
|
||
Pull
|
||
</a-button>
|
||
<a-button size="small" :loading="batchActionLoading === 'push'" :disabled="!selectedBatchPaths.length" @click="doBatchPush">
|
||
<template #icon><CloudUploadOutlined /></template>
|
||
Push
|
||
</a-button>
|
||
<a-button size="small" :loading="batchLoading" @click="loadBatchOverview">
|
||
<template #icon><SyncOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div class="batch-list">
|
||
<template v-if="batchLoading">
|
||
<div v-for="i in 4" :key="i" style="padding: 10px 12px;">
|
||
<div style="height: 14px; background: var(--bg-hover, #333); border-radius: 4px; animation: pulse 1.5s infinite;" :style="{ width: (50 + i * 8) + '%' }"></div>
|
||
</div>
|
||
</template>
|
||
<div v-else-if="!batchOverviews.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
|
||
暂无项目
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="proj in batchOverviews"
|
||
:key="proj.key"
|
||
class="batch-item"
|
||
:class="{ error: !!proj.error }"
|
||
>
|
||
<a-checkbox
|
||
:checked="selectedBatchPaths.includes(proj.path)"
|
||
:disabled="!!proj.error"
|
||
@change="toggleBatchPath(proj.path)"
|
||
style="flex-shrink: 0;"
|
||
/>
|
||
<div class="batch-item-info">
|
||
<div class="batch-item-name">{{ proj.name }}</div>
|
||
<div class="batch-item-meta">
|
||
<a-tag v-if="proj.branch" size="small" color="green">
|
||
<BranchesOutlined /> {{ proj.branch }}
|
||
</a-tag>
|
||
<a-tag v-if="proj.hasChanges" size="small" color="orange">有变更</a-tag>
|
||
<a-tag v-if="proj.unpushed > 0" size="small" color="blue">{{ proj.unpushed }} 未推送</a-tag>
|
||
<span v-if="proj.error" style="color: #f38ba8; font-size: 11px;">{{ proj.error }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="batchResultMap[proj.path]" class="batch-result-icon">
|
||
<CheckCircleOutlined v-if="batchResultMap[proj.path].success" style="color: #a6e3a1;" />
|
||
<a-tooltip v-else :title="batchResultMap[proj.path].message">
|
||
<CloseCircleOutlined style="color: #f38ba8;" />
|
||
</a-tooltip>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 迁移弹窗 -->
|
||
<a-modal
|
||
v-model:open="showMigrateModal"
|
||
title="迁移项目到其他平台"
|
||
:width="640"
|
||
:footer="null"
|
||
>
|
||
<div class="batch-panel">
|
||
<!-- 目标凭证选择 -->
|
||
<div style="margin-bottom: 12px;">
|
||
<div style="margin-bottom: 6px; font-size: 12px; color: var(--text-muted);">目标平台凭证</div>
|
||
<a-select
|
||
style="width: 100%"
|
||
placeholder="选择目标平台凭证(需要 Token)"
|
||
@change="onMigrateCredentialChange"
|
||
>
|
||
<a-select-option
|
||
v-for="c in migrateCredentials"
|
||
:key="c.platform + '/' + c.username"
|
||
:value="c.platform + '/' + c.username"
|
||
>
|
||
{{ c.platform }} / {{ c.username }}
|
||
<span v-if="c.baseUrl" style="color: var(--text-muted); font-size: 11px;"> ({{ c.baseUrl }})</span>
|
||
</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
|
||
<div style="flex: 1;">
|
||
<div style="margin-bottom: 4px; font-size: 12px; color: var(--text-muted);">仓库描述(可选)</div>
|
||
<a-input v-model:value="migrateForm.description" placeholder="仓库描述" size="small" />
|
||
</div>
|
||
<div style="display: flex; align-items: flex-end;">
|
||
<a-checkbox v-model:checked="migrateForm.private">私有</a-checkbox>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 项目列表 -->
|
||
<div class="batch-header">
|
||
<a-checkbox
|
||
:checked="selectedMigratePaths.length === batchOverviews.filter(p => !p.error).length && batchOverviews.length > 0"
|
||
:indeterminate="selectedMigratePaths.length > 0 && selectedMigratePaths.length < batchOverviews.filter(p => !p.error).length"
|
||
@change="toggleAllMigratePaths"
|
||
>
|
||
全选 ({{ selectedMigratePaths.length }}/{{ batchOverviews.filter(p => !p.error).length }})
|
||
</a-checkbox>
|
||
<span style="flex:1"></span>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="migrateLoading"
|
||
:disabled="!selectedMigratePaths.length || !migrateForm.targetPlatform"
|
||
@click="doMigrate"
|
||
>
|
||
<template #icon><ExportOutlined /></template>
|
||
开始迁移
|
||
</a-button>
|
||
</div>
|
||
<div class="batch-list">
|
||
<div v-if="!batchOverviews.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
|
||
暂无项目
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="proj in batchOverviews"
|
||
:key="'mig-' + proj.key"
|
||
class="batch-item"
|
||
:class="{ error: !!proj.error }"
|
||
>
|
||
<a-checkbox
|
||
:checked="selectedMigratePaths.includes(proj.path)"
|
||
:disabled="!!proj.error"
|
||
@change="toggleMigratePath(proj.path)"
|
||
style="flex-shrink: 0;"
|
||
/>
|
||
<div class="batch-item-info">
|
||
<div class="batch-item-name">{{ proj.name }}</div>
|
||
<div class="batch-item-meta">
|
||
<a-tag v-if="proj.branch" size="small" color="green">
|
||
<BranchesOutlined /> {{ proj.branch }}
|
||
</a-tag>
|
||
<span v-if="proj.error" style="color: #f38ba8; font-size: 11px;">{{ proj.error }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="migrateResultMap[proj.path]" class="batch-result-icon">
|
||
<CheckCircleOutlined v-if="migrateResultMap[proj.path].success" style="color: #a6e3a1;" />
|
||
<a-tooltip v-else :title="migrateResultMap[proj.path].message">
|
||
<CloseCircleOutlined style="color: #f38ba8;" />
|
||
</a-tooltip>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 迁移结果 -->
|
||
<div v-if="migrateResults.length" style="margin-top: 12px; border-top: 1px solid var(--border-color); padding-top: 8px;">
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px;">迁移结果</div>
|
||
<div v-for="r in migrateResults" :key="r.path" style="display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 12px;">
|
||
<CheckCircleOutlined v-if="r.success" style="color: #a6e3a1;" />
|
||
<CloseCircleOutlined v-else style="color: #f38ba8;" />
|
||
<span>{{ r.name || r.path }}</span>
|
||
<span style="color: var(--text-muted);">{{ r.message }}</span>
|
||
<a v-if="r.cloneUrl" :href="r.cloneUrl" target="_blank" style="font-size: 11px; color: var(--accent);">{{ r.cloneUrl }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 在线迁移弹窗 -->
|
||
<a-modal
|
||
v-model:open="showOnlineMigrateModal"
|
||
title="在线迁移"
|
||
:width="720"
|
||
:footer="null"
|
||
:maskClosable="!onlineCompareLoading && !onlineMigrateLoading"
|
||
:closable="!onlineCompareLoading && !onlineMigrateLoading"
|
||
>
|
||
<!-- 步骤条 -->
|
||
<a-steps :current="onlineMigrateStep === 'form' ? 0 : onlineMigrateStep === 'compare' ? 1 : 2" size="small" style="margin-bottom: 16px;">
|
||
<a-step title="配置平台" />
|
||
<a-step title="对比仓库" />
|
||
<a-step title="迁移结果" />
|
||
</a-steps>
|
||
|
||
<!-- Step 1: 配置平台 -->
|
||
<div v-if="onlineMigrateStep === 'form'">
|
||
<div class="online-migrate-platforms">
|
||
<!-- 源平台 -->
|
||
<div class="online-migrate-platform-card">
|
||
<div class="platform-card-title">📤 源平台</div>
|
||
<a-form layout="vertical" size="small">
|
||
<a-form-item label="快捷选择凭证">
|
||
<a-select
|
||
placeholder="选择已保存的凭证"
|
||
allow-clear
|
||
@change="onOnlineSrcCredentialChange"
|
||
style="width: 100%"
|
||
>
|
||
<a-select-option
|
||
v-for="c in onlineMigrateCredentials"
|
||
:key="'src-' + c.platform + '/' + c.username"
|
||
:value="c.platform + '/' + c.username"
|
||
>
|
||
{{ c.platform }} / {{ c.username }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="平台" required>
|
||
<a-select v-model:value="onlineMigrateSrc.platform" placeholder="选择平台">
|
||
<a-select-option value="github">GitHub</a-select-option>
|
||
<a-select-option value="gitee">Gitee</a-select-option>
|
||
<a-select-option value="gitea">Gitea</a-select-option>
|
||
<a-select-option value="gitlab">GitLab</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item v-if="onlineMigrateSrc.platform === 'gitea' || onlineMigrateSrc.platform === 'gitlab'" label="Base URL" required>
|
||
<a-input v-model:value="onlineMigrateSrc.baseUrl" placeholder="http://192.168.1.10:3000" />
|
||
</a-form-item>
|
||
<a-form-item label="用户名" required>
|
||
<a-input v-model:value="onlineMigrateSrc.username" placeholder="用户名" />
|
||
</a-form-item>
|
||
<a-form-item label="Token" required>
|
||
<a-input-password v-model:value="onlineMigrateSrc.token" placeholder="API Token" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-checkbox v-model:checked="onlineMigrateSrc.useProxy">使用代理</a-checkbox>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
|
||
<!-- 箭头 -->
|
||
<div class="online-migrate-arrow">
|
||
<SwapOutlined style="font-size: 24px; color: var(--accent);" />
|
||
</div>
|
||
|
||
<!-- 目标平台 -->
|
||
<div class="online-migrate-platform-card">
|
||
<div class="platform-card-title">📥 目标平台</div>
|
||
<a-form layout="vertical" size="small">
|
||
<a-form-item label="快捷选择凭证">
|
||
<a-select
|
||
placeholder="选择已保存的凭证"
|
||
allow-clear
|
||
@change="onOnlineTgtCredentialChange"
|
||
style="width: 100%"
|
||
>
|
||
<a-select-option
|
||
v-for="c in onlineMigrateCredentials"
|
||
:key="'tgt-' + c.platform + '/' + c.username"
|
||
:value="c.platform + '/' + c.username"
|
||
>
|
||
{{ c.platform }} / {{ c.username }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="平台" required>
|
||
<a-select v-model:value="onlineMigrateTgt.platform" placeholder="选择平台">
|
||
<a-select-option value="github">GitHub</a-select-option>
|
||
<a-select-option value="gitee">Gitee</a-select-option>
|
||
<a-select-option value="gitea">Gitea</a-select-option>
|
||
<a-select-option value="gitlab">GitLab</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item v-if="onlineMigrateTgt.platform === 'gitea' || onlineMigrateTgt.platform === 'gitlab'" label="Base URL" required>
|
||
<a-input v-model:value="onlineMigrateTgt.baseUrl" placeholder="http://192.168.1.10:3000" />
|
||
</a-form-item>
|
||
<a-form-item label="用户名" required>
|
||
<a-input v-model:value="onlineMigrateTgt.username" placeholder="用户名" />
|
||
</a-form-item>
|
||
<a-form-item label="Token" required>
|
||
<a-input-password v-model:value="onlineMigrateTgt.token" placeholder="API Token" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-checkbox v-model:checked="onlineMigrateTgt.useProxy">使用代理</a-checkbox>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="text-align: center; margin-top: 16px;">
|
||
<a-button
|
||
type="primary"
|
||
:loading="onlineCompareLoading"
|
||
:disabled="!onlineMigrateSrc.platform || !onlineMigrateSrc.username || !onlineMigrateSrc.token || !onlineMigrateTgt.platform || !onlineMigrateTgt.username || !onlineMigrateTgt.token"
|
||
@click="doOnlineCompare"
|
||
>
|
||
<template #icon><SyncOutlined /></template>
|
||
开始对比
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: 对比结果 -->
|
||
<div v-else-if="onlineMigrateStep === 'compare'">
|
||
<!-- 统计信息 -->
|
||
<div class="online-migrate-stats">
|
||
<a-tag color="blue">源平台: {{ onlineCompareResult.sourceRepos?.length || 0 }} 个仓库</a-tag>
|
||
<a-tag color="green">目标平台: {{ onlineCompareResult.targetRepos?.length || 0 }} 个仓库</a-tag>
|
||
<a-tag color="orange">需迁移: {{ onlineCompareResult.missingRepos?.length || 0 }} 个仓库</a-tag>
|
||
</div>
|
||
|
||
<!-- 缺少仓库列表 -->
|
||
<div v-if="onlineCompareResult.missingRepos?.length" class="batch-panel">
|
||
<div class="batch-header">
|
||
<a-checkbox
|
||
:checked="selectedOnlineRepos.length === onlineCompareResult.missingRepos.length"
|
||
:indeterminate="selectedOnlineRepos.length > 0 && selectedOnlineRepos.length < onlineCompareResult.missingRepos.length"
|
||
@change="toggleAllOnlineRepos"
|
||
>
|
||
全选 ({{ selectedOnlineRepos.length }}/{{ onlineCompareResult.missingRepos.length }})
|
||
</a-checkbox>
|
||
</div>
|
||
<div class="batch-list" style="max-height: 340px;">
|
||
<div
|
||
v-for="repo in onlineCompareResult.missingRepos"
|
||
:key="repo.name"
|
||
class="batch-item"
|
||
>
|
||
<a-checkbox
|
||
:checked="selectedOnlineRepos.includes(repo.name)"
|
||
@change="toggleOnlineRepo(repo.name)"
|
||
style="flex-shrink: 0;"
|
||
/>
|
||
<div class="batch-item-info">
|
||
<div class="batch-item-name">{{ repo.name }}</div>
|
||
<div class="batch-item-meta">
|
||
<span v-if="repo.description" style="font-size: 11px; color: var(--text-muted);">{{ repo.description }}</span>
|
||
<a-tag v-if="repo.private" size="small" color="red">私有</a-tag>
|
||
<a-tag v-if="repo.fork" size="small" color="purple">Fork</a-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else style="padding: 32px; text-align: center; color: var(--text-muted);">
|
||
✅ 目标平台已包含所有仓库,无需迁移
|
||
</div>
|
||
|
||
<!-- 迁移选项 -->
|
||
<div v-if="onlineCompareResult.missingRepos?.length" class="migrate-options-section">
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px;">迁移选项(除代码外,还同步以下内容)</div>
|
||
<div class="migrate-options-grid">
|
||
<a-checkbox v-model:checked="migrateOpts.labels">🏷️ 标签</a-checkbox>
|
||
<a-checkbox v-model:checked="migrateOpts.milestones">🎯 里程碑</a-checkbox>
|
||
<a-checkbox v-model:checked="migrateOpts.issues">📋 工单</a-checkbox>
|
||
<a-checkbox v-model:checked="migrateOpts.releases">📦 发布</a-checkbox>
|
||
<a-checkbox v-model:checked="migrateOpts.pullRequests">🔀 合并请求</a-checkbox>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: flex; justify-content: space-between; margin-top: 16px;">
|
||
<a-button @click="onlineMigrateStep = 'form'">
|
||
返回修改
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
:disabled="!selectedOnlineRepos.length"
|
||
:loading="onlineMigrateLoading"
|
||
@click="doOnlineMigrateExecute"
|
||
>
|
||
<template #icon><ExportOutlined /></template>
|
||
开始迁移 ({{ selectedOnlineRepos.length }})
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: 迁移结果 -->
|
||
<div v-else-if="onlineMigrateStep === 'execute'">
|
||
<!-- 进度条 -->
|
||
<div v-if="onlineMigrateLoading" class="migrate-progress-section">
|
||
<a-progress
|
||
:percent="Math.round(migrateProgress.percent)"
|
||
:status="migrateProgress.phase === 'error' ? 'exception' : 'active'"
|
||
:stroke-color="{ from: '#89b4fa', to: '#a6e3a1' }"
|
||
style="margin-bottom: 12px;"
|
||
/>
|
||
<div class="migrate-progress-info">
|
||
<div class="migrate-progress-repo">
|
||
<LoadingOutlined style="margin-right: 6px; color: var(--accent);" />
|
||
<span>{{ migrateProgress.repoName || '准备中...' }}</span>
|
||
<a-tag v-if="migrateProgress.current && migrateProgress.total" size="small" style="margin-left: 8px;">
|
||
{{ migrateProgress.current }}/{{ migrateProgress.total }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="migrate-progress-phase">
|
||
<a-tag v-if="migrateProgress.phase === 'clone'" color="blue">克隆中</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'create'" color="cyan">创建仓库</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'push'" color="green">推送中</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'labels'" color="orange">迁移标签</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'milestones'" color="purple">迁移里程碑</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'issues'" color="geekblue">迁移工单</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'releases'" color="magenta">迁移发布</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase === 'pullRequests'" color="volcano">迁移合并请求</a-tag>
|
||
<a-tag v-else-if="migrateProgress.phase" color="default">{{ migrateProgress.phase }}</a-tag>
|
||
</div>
|
||
<div class="migrate-progress-message">{{ migrateProgress.message }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div class="online-migrate-stats" style="margin-bottom: 12px;">
|
||
<a-tag color="green">成功: {{ onlineMigrateResults.filter(r => r.success).length }}</a-tag>
|
||
<a-tag color="red">失败: {{ onlineMigrateResults.filter(r => !r.success).length }}</a-tag>
|
||
</div>
|
||
<div class="batch-list" style="max-height: 400px;">
|
||
<div
|
||
v-for="r in onlineMigrateResults"
|
||
:key="r.name"
|
||
class="batch-item"
|
||
>
|
||
<div class="batch-result-icon">
|
||
<CheckCircleOutlined v-if="r.success" style="color: #a6e3a1;" />
|
||
<CloseCircleOutlined v-else style="color: #f38ba8;" />
|
||
</div>
|
||
<div class="batch-item-info">
|
||
<div class="batch-item-name">{{ r.name }}</div>
|
||
<div class="batch-item-meta">
|
||
<span style="font-size: 11px; color: var(--text-muted);">{{ r.message }}</span>
|
||
<a v-if="r.cloneUrl" :href="r.cloneUrl" target="_blank" style="font-size: 11px; color: var(--accent);">{{ r.cloneUrl }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-top: 16px;">
|
||
<a-button @click="onlineMigrateStep = 'form'">
|
||
重新配置
|
||
</a-button>
|
||
<a-button type="primary" @click="showOnlineMigrateModal = false">
|
||
完成
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sidebar {
|
||
height: 100%;
|
||
background: var(--bg-secondary);
|
||
border-right: 1px solid var(--border-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
min-width: 180px;
|
||
}
|
||
|
||
.sidebar-header {
|
||
height: var(--header-height);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
--wails-draggable: drag;
|
||
}
|
||
|
||
.logo {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.sidebar-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.tree-node-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.group-logo {
|
||
width: 16px;
|
||
height: 16px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-logo :deep(svg) {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.node-icon {
|
||
font-size: 14px;
|
||
color: var(--text-muted);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.node-label {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.empty-tip {
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-hint {
|
||
font-weight: 400;
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* 右键菜单遮罩 */
|
||
.context-menu-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.context-menu-popup {
|
||
position: fixed !important;
|
||
z-index: 2001;
|
||
border-radius: 6px !important;
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45) !important;
|
||
min-width: 160px;
|
||
}
|
||
|
||
/* Ant Design 暗色主题覆盖 */
|
||
:deep(.ant-tree) {
|
||
background: transparent;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-node-content-wrapper) {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-node-content-wrapper:hover) {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected) {
|
||
background: var(--bg-active);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-switcher) {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-treenode) {
|
||
padding: 2px 0;
|
||
}
|
||
|
||
:deep(.ant-tree .ant-tree-icon__customize) {
|
||
display: none;
|
||
}
|
||
|
||
/* 批量操作 */
|
||
.batch-panel {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.batch-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid var(--border-color, #313244);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.batch-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.batch-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 4px;
|
||
border-bottom: 1px solid var(--border-color, rgba(255,255,255,0.04));
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.batch-item:hover {
|
||
background: var(--bg-hover, rgba(255,255,255,0.04));
|
||
}
|
||
|
||
.batch-item.error {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.batch-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.batch-item-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.batch-item-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 2px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.batch-result-icon {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 0.4; }
|
||
50% { opacity: 0.8; }
|
||
}
|
||
|
||
/* 凭证列表 */
|
||
.credential-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.credential-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 4px;
|
||
border-bottom: 1px solid var(--border-color, rgba(255,255,255,0.04));
|
||
}
|
||
|
||
.credential-item:hover {
|
||
background: var(--bg-hover, rgba(255,255,255,0.04));
|
||
}
|
||
|
||
.credential-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.credential-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.credential-url {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* 在线迁移 */
|
||
.online-migrate-platforms {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.online-migrate-platform-card {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: var(--bg-hover, rgba(255,255,255,0.04));
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
border: 1px solid var(--border-color, #313244);
|
||
}
|
||
|
||
.platform-card-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 10px;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.online-migrate-arrow {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding-top: 100px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.online-migrate-stats {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 迁移选项 */
|
||
.migrate-options-section {
|
||
margin-top: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--bg-hover, rgba(255,255,255,0.04));
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border-color, #313244);
|
||
}
|
||
|
||
.migrate-options-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px 20px;
|
||
}
|
||
|
||
/* 迁移进度 */
|
||
.migrate-progress-section {
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.migrate-progress-info {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.migrate-progress-repo {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.migrate-progress-phase {
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.migrate-progress-message {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
word-break: break-all;
|
||
}
|
||
</style>
|