feat: 新增冲突处理、提交搜索、批量操作功能\n\n冲突处理:\n- 检测合并状态并显示冲突提示栏\n- 冲突文件列表展示和内容编辑器\n- 支持保存冲突文件、标记已解决、中止合并、完成合并\n\n提交搜索:\n- 支持按提交信息关键字和作者搜索\n- 搜索结果与完整历史切换显示\n\n批量操作:\n- 侧边栏新增批量操作入口\n- 展示所有项目概览(分支/变更/未推送状态)\n- 支持批量 Pull/Push 并显示执行结果"

This commit is contained in:
zyj
2026-03-11 10:06:28 +08:00
parent 6354cbfc94
commit af15e86df6
7 changed files with 1203 additions and 41 deletions

View File

@@ -31,6 +31,9 @@ import {
GlobalOutlined,
SaveOutlined,
DownloadOutlined,
SearchOutlined,
WarningOutlined,
StopOutlined,
} from '@ant-design/icons-vue'
import { Modal, message } from 'ant-design-vue'
import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app'
@@ -100,6 +103,21 @@ const showSettings = ref(false)
const settingsLoading = ref(false)
const gitConfig = ref({ userName: '', userEmail: '' })
// ---- 冲突处理 ----
const isMerging = ref(false)
const conflictFiles = ref([])
const selectedConflictFile = ref(null)
const conflictContent = ref('')
const conflictSaving = ref(false)
// ---- 提交搜索 ----
const searchKeyword = ref('')
const searchAuthor = ref('')
const searchLoading = ref(false)
const isSearchMode = ref(false)
const searchResults = ref([])
const displayLogs = computed(() => isSearchMode.value ? searchResults.value : commitLogs.value)
// ---- 文件列表拖拽调整宽度 ----
const fileListWidth = ref(300)
const MIN_FILELIST = 180
@@ -256,6 +274,20 @@ async function loadStatus() {
loadingFiles.value = false
}
// 检查合并冲突状态
try {
isMerging.value = await AppService.IsMerging(props.project.path)
if (isMerging.value) {
const cFiles = await AppService.GetConflictFiles(props.project.path)
conflictFiles.value = Array.isArray(cFiles) ? cFiles : []
} else {
conflictFiles.value = []
selectedConflictFile.value = null
}
} catch (e) {
console.error('检查合并状态失败:', e)
}
// 第三步:并行加载分支列表 + 提交历史
loadBranches()
if (activeTab.value === 'history') {
@@ -794,6 +826,122 @@ async function saveSettings() {
}
}
// ---- 冲突处理 ----
async function selectConflictFile(file) {
selectedConflictFile.value = file
conflictContent.value = ''
try {
const content = await AppService.GetConflictFileContent(props.project.path, file.filePath)
conflictContent.value = content
} catch (e) {
conflictContent.value = '读取冲突文件失败: ' + e
}
}
async function saveConflictContent() {
if (!selectedConflictFile.value) return
conflictSaving.value = true
try {
await AppService.SaveConflictFile(props.project.path, selectedConflictFile.value.filePath, conflictContent.value)
message.success('冲突文件已保存')
} catch (e) {
Modal.error({ title: '保存失败', content: String(e) })
} finally {
conflictSaving.value = false
}
}
async function resolveConflictFiles(files) {
if (!props.project?.path) return
try {
const paths = files.map(f => f.filePath)
await AppService.ResolveConflictFile(props.project.path, paths)
message.success('已标记为已解决')
isMerging.value = await AppService.IsMerging(props.project.path)
if (isMerging.value) {
const cFiles = await AppService.GetConflictFiles(props.project.path)
conflictFiles.value = Array.isArray(cFiles) ? cFiles : []
} else {
conflictFiles.value = []
}
selectedConflictFile.value = null
await refreshFiles()
} catch (e) {
Modal.error({ title: '解决冲突失败', content: String(e) })
}
}
async function completeMerge() {
if (!props.project?.path) return
const msg = commitMessage.value.trim() || 'Merge completed'
commitLoading.value = true
try {
await AppService.CommitChanges(props.project.path, msg)
commitMessage.value = ''
isMerging.value = false
conflictFiles.value = []
selectedConflictFile.value = null
await loadStatus()
} catch (e) {
Modal.error({ title: '完成合并失败', content: String(e) })
} finally {
commitLoading.value = false
}
}
async function handleAbortMerge() {
if (!props.project?.path) return
Modal.confirm({
title: '确认中止合并',
icon: h(ExclamationCircleOutlined),
content: '中止合并将丢弃所有合并更改,回到合并之前的状态。确定继续?',
okText: '中止合并',
cancelText: '取消',
okButtonProps: { danger: true },
async onOk() {
try {
await AppService.AbortMerge(props.project.path)
message.success('已中止合并')
isMerging.value = false
conflictFiles.value = []
selectedConflictFile.value = null
await loadStatus()
} catch (e) {
Modal.error({ title: '中止合并失败', content: String(e) })
}
},
})
}
// ---- 提交搜索 ----
async function searchCommits() {
if (!props.project?.path) return
if (!searchKeyword.value.trim() && !searchAuthor.value.trim()) return
searchLoading.value = true
isSearchMode.value = true
try {
const results = await AppService.SearchCommitLog(
props.project.path,
searchKeyword.value.trim(),
searchAuthor.value.trim(),
100
)
searchResults.value = Array.isArray(results) ? results : []
} catch (e) {
console.error('搜索提交失败:', e)
searchResults.value = []
} finally {
searchLoading.value = false
}
}
function clearSearch() {
isSearchMode.value = false
searchKeyword.value = ''
searchAuthor.value = ''
searchResults.value = []
}
// ---- 提交历史 ----
async function loadCommitLog() {
if (!props.project?.path) return
@@ -1237,6 +1385,57 @@ watch(activeTab, (tab) => {
</div>
</div>
<!-- 合并冲突提示栏 -->
<div v-if="isMerging" class="conflict-banner">
<div class="conflict-banner-header">
<WarningOutlined style="color: #fab387;" />
<span style="font-weight: 600;">合并冲突</span>
<span v-if="conflictFiles.length" style="color: var(--text-muted); font-size: 12px;">
{{ conflictFiles.length }} 个文件需要解决
</span>
<span style="flex:1"></span>
<a-space :size="4">
<a-button size="small" danger @click="handleAbortMerge">
<template #icon><StopOutlined /></template>
中止
</a-button>
<a-button
size="small"
type="primary"
:disabled="conflictFiles.length > 0"
:loading="commitLoading"
@click="completeMerge"
>
完成合并
</a-button>
</a-space>
</div>
<div v-if="conflictFiles.length" class="conflict-file-list">
<div
v-for="cf in conflictFiles"
:key="cf.filePath"
class="conflict-file-item"
:class="{ active: selectedConflictFile?.filePath === cf.filePath }"
@click="selectConflictFile(cf)"
>
<WarningOutlined style="color: #fab387; font-size: 12px;" />
<span class="conflict-file-name">{{ cf.filePath }}</span>
<div class="conflict-file-actions" @click.stop>
<a-tooltip title="标记为已解决">
<span class="conflict-resolve-btn" @click="resolveConflictFiles([cf])">
<CheckCircleOutlined />
</span>
</a-tooltip>
</div>
</div>
</div>
<div v-if="conflictFiles.length > 1" style="padding: 4px 8px; text-align: right; border-top: 1px solid var(--border-color);">
<a-button size="small" type="link" @click="resolveConflictFiles(conflictFiles)">
<CheckCircleOutlined /> 全部标记为已解决
</a-button>
</div>
</div>
<div class="file-list-content">
<!-- 骨架屏 -->
<template v-if="loadingBase || loadingFiles">
@@ -1316,20 +1515,46 @@ watch(activeTab, (tab) => {
<!-- ===== 提交历史面板 ===== -->
<template v-else-if="activeTab === 'history'">
<div class="file-list-content history-panel">
<!-- 搜索栏 -->
<div class="search-bar">
<a-input
v-model:value="searchKeyword"
placeholder="搜索提交信息..."
size="small"
allow-clear
@pressEnter="searchCommits"
>
<template #prefix><SearchOutlined style="color: var(--text-muted);" /></template>
</a-input>
<a-input
v-model:value="searchAuthor"
placeholder="作者"
size="small"
style="width: 100px;"
allow-clear
@pressEnter="searchCommits"
>
<template #prefix><UserOutlined style="color: var(--text-muted);" /></template>
</a-input>
<a-button size="small" type="primary" :loading="searchLoading" @click="searchCommits">
<template #icon><SearchOutlined /></template>
</a-button>
<a-button v-if="isSearchMode" size="small" @click="clearSearch">清除</a-button>
</div>
<!-- 提交列表区域 -->
<div class="commit-list-section" :class="{ 'has-selected': selectedCommit }">
<template v-if="commitLogsLoading">
<template v-if="commitLogsLoading || searchLoading">
<div v-for="i in 8" :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: (40 + i * 7) + '%' }"></div>
<div style="height: 10px; background: var(--bg-hover, #333); border-radius: 4px; animation: pulse 1.5s infinite; margin-top: 4px; width: 40%;"></div>
</div>
</template>
<div v-else-if="!commitLogs.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
暂无提交历史
<div v-else-if="!displayLogs.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
{{ isSearchMode ? '未找到匹配的提交' : '暂无提交历史' }}
</div>
<template v-else>
<div
v-for="log in commitLogs"
v-for="log in displayLogs"
:key="log.hash"
class="commit-item"
:class="{ active: selectedCommit?.hash === log.hash, unpushed: !log.pushed }"
@@ -1503,7 +1728,34 @@ watch(activeTab, (tab) => {
<div class="file-viewer">
<!-- 变更模式的文件查看器 -->
<template v-if="activeTab === 'changes'">
<div v-if="!selectedFile" class="empty-state small">
<!-- 冲突文件编辑器 -->
<template v-if="selectedConflictFile">
<div class="viewer-header">
<WarningOutlined style="color: #fab387; margin-right: 6px;" />
<span class="viewer-path">{{ selectedConflictFile.filePath }}</span>
<a-tag color="warning" size="small" style="margin-left: 8px;">冲突</a-tag>
<span style="flex:1"></span>
<a-space :size="6">
<a-button size="small" :loading="conflictSaving" @click="saveConflictContent">
<template #icon><SaveOutlined /></template>
保存
</a-button>
<a-button size="small" type="primary" @click="resolveConflictFiles([selectedConflictFile])">
<template #icon><CheckCircleOutlined /></template>
标记已解决
</a-button>
</a-space>
</div>
<div class="viewer-content conflict-editor">
<textarea
v-model="conflictContent"
class="conflict-textarea"
spellcheck="false"
></textarea>
</div>
</template>
<!-- 普通文件查看器 -->
<div v-else-if="!selectedFile" class="empty-state small">
<span style="color: var(--text-muted)">点击左侧文件查看详情</span>
</div>
<template v-else>
@@ -2375,4 +2627,108 @@ watch(activeTab, (tab) => {
margin-left: auto;
flex-shrink: 0;
}
/* 冲突处理 */
.conflict-banner {
border-bottom: 1px solid var(--border-color);
background: rgba(250, 179, 135, 0.08);
}
.conflict-banner-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
font-size: 13px;
}
.conflict-file-list {
max-height: 150px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
}
.conflict-file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
}
.conflict-file-item:hover {
background: var(--bg-hover);
}
.conflict-file-item.active {
background: var(--bg-active);
}
.conflict-file-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Consolas', 'Courier New', monospace;
color: var(--text-secondary);
}
.conflict-file-actions {
visibility: hidden;
flex-shrink: 0;
}
.conflict-file-item:hover .conflict-file-actions {
visibility: visible;
}
.conflict-resolve-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.12s;
}
.conflict-resolve-btn:hover {
background: rgba(166, 227, 161, 0.2);
color: var(--success, #a6e3a1);
}
.conflict-editor {
display: flex;
flex-direction: column;
}
.conflict-textarea {
flex: 1;
width: 100%;
background: var(--bg-primary, #1e1e2e);
color: var(--text-primary);
border: none;
outline: none;
resize: none;
padding: 12px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
tab-size: 4;
}
/* 提交搜索 */
.search-bar {
display: flex;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
</style>

View File

@@ -9,8 +9,14 @@ import {
DeleteOutlined,
CloudDownloadOutlined,
LoadingOutlined,
AppstoreOutlined,
CloudUploadOutlined,
SyncOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
BranchesOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app'
const props = defineProps({
@@ -32,6 +38,19 @@ const showCloneDialog = ref(false)
const cloneForm = ref({ platform: '', username: '', repoURL: '', parentDir: '', name: '' })
const cloneLoading = ref(false)
// ---- 批量操作 ----
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: {} })
@@ -393,6 +412,79 @@ async function removeProject(platform, username, name) {
}
}
// ---- 批量操作 ----
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)
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)
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 = ''
}
}
// 平台 SVG Logo
const platformLogos = {
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>`,
@@ -415,9 +507,14 @@ onMounted(() => {
<aside class="sidebar">
<div class="sidebar-header">
<span class="logo">GitPilot</span>
<a-button type="text" size="small" @click="openAddPlatformDialog" title="添加平台">
<template #icon><PlusOutlined /></template>
</a-button>
<a-space :size="0">
<a-button type="text" size="small" @click="openBatchModal" title="批量操作">
<template #icon><AppstoreOutlined /></template>
</a-button>
<a-button type="text" size="small" @click="openAddPlatformDialog" title="添加平台">
<template #icon><PlusOutlined /></template>
</a-button>
</a-space>
</div>
<div class="sidebar-content" @contextmenu="onSidebarContextMenu">
@@ -600,6 +697,82 @@ onMounted(() => {
</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>
</aside>
</template>
@@ -726,4 +899,72 @@ onMounted(() => {
: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; }
}
</style>