重构项目结构,移除旧Java客户端,添加前后端目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liumangmang
2026-02-09 17:22:26 +08:00
parent ddeb7c65ff
commit 9ee9c96a91
71 changed files with 4893 additions and 2943 deletions

View File

@@ -0,0 +1,98 @@
<script setup>
import { ref } from 'vue'
import { useProjectsStore } from '../stores/projects'
const emit = defineEmits(['close', 'saved'])
const store = useProjectsStore()
const name = ref('')
const path = ref('')
const svnUrl = ref('')
const username = ref('')
const password = ref('')
const saving = ref(false)
const error = ref('')
async function save() {
if (!name.value.trim() || !path.value.trim()) {
error.value = '项目名称和路径不能为空'
return
}
saving.value = true
error.value = ''
try {
await store.addProject({
name: name.value.trim(),
path: path.value.trim(),
svnUrl: svnUrl.value.trim() || null,
username: username.value.trim() || null,
password: password.value.trim() || null
})
emit('saved')
name.value = ''
path.value = ''
svnUrl.value = ''
username.value = ''
password.value = ''
} catch (e) {
error.value = e.response?.data || e.message || '保存失败'
} finally {
saving.value = false
}
}
function close() {
emit('close')
}
</script>
<template>
<div class="modal-overlay" @click.self="close">
<div class="modal">
<div class="modal-header">
<h3>添加项目</h3>
<button class="modal-close" @click="close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称</label>
<input v-model="name" type="text" placeholder="项目名称" />
</div>
<div class="form-group">
<label>本地路径</label>
<input v-model="path" type="text" placeholder="/path/to/working-copy" />
</div>
<div class="form-group">
<label>SVN 地址可选</label>
<input v-model="svnUrl" type="text" />
</div>
<div class="form-group">
<label>用户名可选</label>
<input v-model="username" type="text" />
</div>
<div class="form-group">
<label>密码可选</label>
<input v-model="password" type="password" />
</div>
<p v-if="error" class="form-error">{{ error }}</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="close">取消</button>
<button class="btn-primary" @click="save" :disabled="saving">{{ saving ? '保存中...' : '确定' }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; min-width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border-color); }
.modal-header h3 { margin: 0; font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer; }
.modal-body { padding: 1rem; }
.form-group label { display: block; font-size: 0.875rem; margin-bottom: 0.25rem; color: var(--text-secondary); }
.form-group input { width: 100%; padding: 0.5rem; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); }
.form-error { color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; }
.modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem; border-top: 1px solid var(--border-color); }
.btn-primary { background: var(--accent-blue); color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
.btn-secondary { background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
</style>

View File

@@ -0,0 +1,110 @@
<script setup>
import { computed } from 'vue'
import { useProjectsStore } from '../stores/projects'
const store = useProjectsStore()
const fileName = computed(() => {
if (!store.selectedFilePath) return null
const p = store.selectedFilePath.replace(/\\/g, '/')
return p.split('/').pop() || p
})
const isEmpty = computed(() => !store.selectedFilePath)
const hasContent = computed(() => store.fileContent != null && store.fileContent !== '__DIR__')
const isDirectory = computed(() => store.fileContent === '__DIR__')
</script>
<template>
<div class="code-preview">
<template v-if="isEmpty">
<div class="preview-empty">
<i class="fas fa-file-code"></i>
<p>点击左侧文件查看代码</p>
</div>
</template>
<template v-else>
<div class="preview-header">
<span class="preview-file-name">{{ fileName }}</span>
<span class="preview-path">{{ store.selectedFilePath }}</span>
</div>
<div class="preview-content">
<pre v-if="hasContent"><code>{{ store.fileContent }}</code></pre>
<div v-else-if="isDirectory" class="preview-dir">目录</div>
<div v-else class="preview-loading">加载中...</div>
</div>
</template>
</div>
</template>
<style scoped>
.code-preview {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg-tertiary);
border-left: 1px solid var(--border-color);
}
.preview-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.preview-empty i {
font-size: 2.5rem;
opacity: 0.5;
}
.preview-header {
padding: 0.5rem 1rem;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono), monospace;
font-size: 12px;
}
.preview-file-name {
font-weight: 600;
color: var(--text-primary);
display: block;
}
.preview-path {
font-size: 11px;
color: var(--text-secondary);
}
.preview-content {
flex: 1;
overflow: auto;
padding: 1rem;
}
.preview-content pre {
margin: 0;
font-family: var(--font-mono), monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-primary);
}
.preview-content code {
font-family: inherit;
}
.preview-loading, .preview-dir {
color: var(--text-secondary);
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup>
import { ref, watch } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useModalsStore } from '../stores/modals'
const store = useProjectsStore()
const modals = useModalsStore()
const message = ref('')
const saving = ref(false)
const error = ref('')
watch(() => modals.commitOpen, (open) => { if (open) { message.value = ''; error.value = '' } })
async function submit() {
if (!message.value.trim()) { error.value = '提交消息不能为空'; return }
saving.value = true
error.value = ''
try {
await store.commit(message.value.trim())
modals.closeCommit()
} catch (e) {
error.value = e.response?.data || e.message || '提交失败'
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="modals.commitOpen" class="modal-overlay" @click.self="modals.closeCommit()">
<div class="modal">
<div class="modal-header">
<h3>提交修改</h3>
<button class="modal-close" @click="modals.closeCommit()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>提交消息</label>
<textarea v-model="message" rows="4" placeholder="请输入提交消息"></textarea>
</div>
<p v-if="error" class="form-error">{{ error }}</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="modals.closeCommit()">取消</button>
<button class="btn-primary" @click="submit" :disabled="saving">{{ saving ? '提交中...' : '提交' }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; min-width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border-color); }
.modal-header h3 { margin: 0; font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer; }
.modal-body { padding: 1rem; }
.form-group label { display: block; font-size: 0.875rem; margin-bottom: 0.25rem; color: var(--text-secondary); }
.form-group textarea { width: 100%; padding: 0.5rem; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); resize: vertical; }
.form-error { color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; }
.modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem; border-top: 1px solid var(--border-color); }
.btn-primary { background: var(--accent-blue); color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
.btn-secondary { background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
import { ref, watch } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useModalsStore } from '../stores/modals'
import { svnApi } from '../api/client'
const store = useProjectsStore()
const modals = useModalsStore()
const diffText = ref('')
const loading = ref(false)
watch(() => modals.diffOpen, async (open) => {
const id = store.currentId?.value ?? store.currentId
if (open && id) {
loading.value = true
diffText.value = ''
try {
const res = await svnApi.diff(id, modals.diffPath || undefined)
diffText.value = res.data?.diff ?? res.data ?? ''
} catch (e) {
diffText.value = '获取差异失败: ' + (e.response?.data || e.message)
} finally {
loading.value = false
}
}
})
</script>
<template>
<div v-if="modals.diffOpen" class="modal-overlay" @click.self="modals.closeDiff()">
<div class="modal modal-lg">
<div class="modal-header">
<h3>差异</h3>
<button class="modal-close" @click="modals.closeDiff()">&times;</button>
</div>
<div class="modal-body">
<div v-if="loading" class="loading">加载中...</div>
<pre v-else class="diff-content">{{ diffText || '(无差异)' }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; min-width: 400px; max-height: 80vh; display: flex; flex-direction: column; }
.modal-lg { width: 90vw; max-width: 800px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border-color); }
.modal-header h3 { margin: 0; font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer; }
.modal-body { padding: 1rem; overflow: auto; }
.loading { color: var(--text-secondary); }
.diff-content { font-family: var(--font-mono), monospace; font-size: 12px; white-space: pre; margin: 0; color: var(--text-primary); }
</style>

View File

@@ -0,0 +1,113 @@
<script setup>
import { ref, watch } from 'vue'
import { useProjectsStore } from '../stores/projects'
const props = defineProps({
project: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const store = useProjectsStore()
const name = ref('')
const path = ref('')
const svnUrl = ref('')
const username = ref('')
const password = ref('')
const id = ref('')
const saving = ref(false)
const error = ref('')
watch(() => props.project, (p) => {
if (p) {
id.value = p.id || ''
name.value = p.name || ''
path.value = p.path || ''
svnUrl.value = p.svnUrl || ''
username.value = p.username || ''
password.value = '' // 不预填密码
error.value = ''
}
}, { immediate: true })
async function save() {
if (!name.value.trim() || !path.value.trim()) {
error.value = '项目名称和路径不能为空'
return
}
saving.value = true
error.value = ''
try {
await store.updateProject({
id: id.value,
name: name.value.trim(),
path: path.value.trim(),
svnUrl: svnUrl.value.trim() || null,
username: username.value.trim() || null,
password: password.value.trim() || undefined
})
emit('saved')
emit('close')
} catch (e) {
error.value = e.response?.data || e.message || '保存失败'
} finally {
saving.value = false
}
}
function close() {
emit('close')
}
</script>
<template>
<div v-if="project" class="modal-overlay" @click.self="close">
<div class="modal">
<div class="modal-header">
<h3>修改项目</h3>
<button class="modal-close" @click="close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称</label>
<input v-model="name" type="text" placeholder="项目名称" />
</div>
<div class="form-group">
<label>本地路径</label>
<input v-model="path" type="text" placeholder="/path/to/working-copy" />
</div>
<div class="form-group">
<label>SVN 地址可选</label>
<input v-model="svnUrl" type="text" />
</div>
<div class="form-group">
<label>用户名可选用于远程操作如更新/日志</label>
<input v-model="username" type="text" placeholder="SVN 用户名" />
</div>
<div class="form-group">
<label>密码可选修改时填写</label>
<input v-model="password" type="password" placeholder="留空则清除" />
</div>
<p v-if="error" class="form-error">{{ error }}</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="close">取消</button>
<button class="btn-primary" @click="save" :disabled="saving">{{ saving ? '保存中...' : '确定' }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; min-width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border-color); }
.modal-header h3 { margin: 0; font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer; }
.modal-body { padding: 1rem; }
.form-group label { display: block; font-size: 0.875rem; margin-bottom: 0.25rem; color: var(--text-secondary); }
.form-group input { width: 100%; padding: 0.5rem; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary); }
.form-error { color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; }
.modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem; border-top: 1px solid var(--border-color); }
.btn-primary { background: var(--accent-blue); color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
.btn-secondary { background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
</style>

View File

@@ -0,0 +1,74 @@
<script setup>
import { ref, computed } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useModalsStore } from '../stores/modals'
import TreeNode from './TreeNode.vue'
const store = useProjectsStore()
const modals = useModalsStore()
const selectedPath = ref(null)
const fileTree = computed(() => store.currentFileTree)
const statusCode = (status) => {
if (!status) return ''
const c = status.code || status
return typeof c === 'string' ? c : String.fromCharCode(c)
}
const statusClass = (status) => {
if (!status) return ''
const name = (status + '').toUpperCase()
if (name.includes('MODIFIED')) return 'modified'
if (name.includes('ADDED')) return 'added'
if (name.includes('DELETED')) return 'deleted'
if (name.includes('CONFLICTED')) return 'conflicted'
if (name.includes('UNVERSIONED')) return 'unversioned'
return ''
}
function selectNode(node) {
selectedPath.value = node?.path ?? null
store.setSelectedFile(node)
}
function copyPath() {
if (!selectedPath.value) return
navigator.clipboard.writeText(selectedPath.value)
}
</script>
<template>
<div class="file-tree">
<template v-if="fileTree">
<TreeNode
:node="fileTree"
:depth="0"
:selected-path="selectedPath"
@select="selectNode"
@diff="modals.openDiff($event)"
@copy-path="copyPath"
/>
</template>
<div v-else-if="store.loading" class="tree-loading">加载中...</div>
<div v-else-if="store.error" class="tree-error">{{ store.error }}</div>
<div v-else-if="!store.currentId" class="tree-empty">请选择项目</div>
<div v-else class="tree-empty">暂无文件数据</div>
</div>
</template>
<style scoped>
.file-tree {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
font-family: var(--font-mono), monospace;
font-size: 13px;
}
.tree-loading, .tree-error, .tree-empty {
padding: 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.tree-error { color: var(--accent-red); }
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup>
import { ref, watch } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useModalsStore } from '../stores/modals'
import { svnApi } from '../api/client'
const store = useProjectsStore()
const modals = useModalsStore()
const logs = ref([])
const loading = ref(false)
watch(() => modals.logOpen, async (open) => {
if (open && store.currentId) {
loading.value = true
try {
const id = store.currentId?.value ?? store.currentId
if (!id) return
const res = await svnApi.log(id, 50)
logs.value = res.data || []
} catch (e) {
logs.value = []
} finally {
loading.value = false
}
}
})
</script>
<template>
<div v-if="modals.logOpen" class="modal-overlay" @click.self="modals.closeLog()">
<div class="modal modal-lg">
<div class="modal-header">
<h3>提交日志</h3>
<button class="modal-close" @click="modals.closeLog()">&times;</button>
</div>
<div class="modal-body">
<div v-if="loading" class="loading">加载中...</div>
<div v-else class="log-list">
<div v-for="log in logs" :key="log.revision" class="log-item">
<div class="log-meta">
<span class="log-rev">r{{ log.revision }}</span>
<span class="log-author">{{ log.author }}</span>
<span class="log-date">{{ log.date }}</span>
</div>
<div class="log-message">{{ log.message || '(无消息)' }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; min-width: 400px; max-height: 80vh; display: flex; flex-direction: column; }
.modal-lg { width: 600px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border-color); }
.modal-header h3 { margin: 0; font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer; }
.modal-body { padding: 1rem; overflow-y: auto; }
.loading { color: var(--text-secondary); }
.log-list { display: flex; flex-direction: column; gap: 0.75rem; }
.log-item { padding: 0.5rem; background: var(--bg-secondary); border-radius: 4px; }
.log-meta { font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem; }
.log-rev { font-weight: 600; margin-right: 0.5rem; }
.log-message { font-size: 0.875rem; white-space: pre-wrap; word-break: break-word; }
</style>

View File

@@ -0,0 +1,194 @@
<script setup>
import { ref, computed } from 'vue'
import { useProjectsStore } from '../stores/projects'
import AddProjectModal from './AddProjectModal.vue'
import EditProjectModal from './EditProjectModal.vue'
const store = useProjectsStore()
const showAdd = ref(false)
const editingProject = ref(null)
function openEdit(p, e) {
e?.stopPropagation()
editingProject.value = p
}
function closeEdit() {
editingProject.value = null
}
const projectCount = computed(() => store.list.length)
function statusClass(status) {
if (!status) return 'unknown'
const s = (status + '').toLowerCase()
if (s === 'synced') return 'synced'
if (s === 'updates_available') return 'updates'
if (s === 'disconnected') return 'disconnected'
return 'unknown'
}
function statusLabel(status) {
const map = { SYNCED: '已同步', UPDATES_AVAILABLE: '有更新', DISCONNECTED: '未连接', UNKNOWN: '未知' }
return map[status] || '未知'
}
</script>
<template>
<aside class="project-sidebar">
<div class="sidebar-header">
<div class="brand">
<div class="brand-icon"><i class="fas fa-code-branch"></i></div>
<div>
<h1>SVN管理器</h1>
<p>多项目管理</p>
</div>
</div>
<button class="btn-add" @click="showAdd = true">
<i class="fas fa-plus"></i>
添加项目
</button>
</div>
<div class="project-list">
<div
v-for="p in store.list"
:key="p.id"
class="project-item"
:class="{ active: store.currentId === p.id }"
@click="store.setCurrent(p.id)"
>
<div class="project-info">
<i class="fas fa-folder folder-icon"></i>
<div class="project-meta">
<div class="project-name">{{ p.name }}</div>
<div class="project-path">{{ p.path }}</div>
</div>
<button
class="btn-edit"
title="修改项目"
@click="openEdit(p, $event)"
>
<i class="fas fa-pen"></i>
</button>
</div>
<div class="project-status-row">
<span class="status-badge" :class="statusClass(p.status)">{{ statusLabel(p.status) }}</span>
<span class="revision">r{{ p.workingCopyVersion || p.currentVersion || '-' }}</span>
</div>
</div>
</div>
<div class="sidebar-footer">
{{ projectCount }} 个项目
</div>
</aside>
<AddProjectModal v-if="showAdd" @close="showAdd = false" @saved="showAdd = false; store.loadProjects()" />
<EditProjectModal
v-if="editingProject"
:project="editingProject"
@close="closeEdit"
@saved="closeEdit"
/>
</template>
<style scoped>
.project-sidebar {
width: 16rem;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.brand-icon {
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, var(--accent-blue), #9333ea);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.875rem;
}
.brand h1 { font-size: 0.875rem; font-weight: 600; color: var(--text-primary); margin: 0; }
.brand p { font-size: 0.75rem; color: var(--text-secondary); margin: 0.25rem 0 0 0; }
.btn-add {
width: 100%;
background: var(--accent-blue);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-add:hover { filter: brightness(1.1); }
.project-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.project-item {
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.2s ease;
margin-bottom: 0.25rem;
}
.project-item:hover { background: var(--bg-secondary); }
.project-item.active {
background: var(--bg-secondary);
border-left-color: var(--accent-blue);
}
.project-info { display: flex; align-items: center; gap: 0.5rem; }
.btn-edit {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
opacity: 0.7;
}
.btn-edit:hover { color: var(--accent-blue); background: var(--bg-secondary); opacity: 1; }
.folder-icon { color: var(--accent-blue); font-size: 0.875rem; }
.project-meta { flex: 1; min-width: 0; }
.project-name { font-size: 0.875rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-path { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-status-row { margin-top: 0.25rem; display: flex; align-items: center; gap: 0.5rem; }
.status-badge { font-size: 0.75rem; padding: 0.125rem 0.375rem; border-radius: 4px; }
.status-badge.synced { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.status-badge.updates { background: rgba(245, 158, 11, 0.2); color: var(--accent-yellow); }
.status-badge.disconnected { background: rgba(148, 163, 184, 0.2); color: #94A3B8; }
.status-badge.unknown { background: var(--bg-secondary); color: var(--text-secondary); }
.revision { font-size: 0.75rem; color: var(--text-secondary); }
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
font-size: 0.75rem;
color: var(--text-secondary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { useProjectsStore } from '../stores/projects'
import { useModalsStore } from '../stores/modals'
const store = useProjectsStore()
const modals = useModalsStore()
</script>
<template>
<div class="toolbar">
<div class="toolbar-left">
<h2 class="project-title">{{ store.current?.name || '未选择项目' }}</h2>
<span class="project-path">{{ store.current?.path || '' }}</span>
</div>
<div class="toolbar-actions">
<button
class="toolbar-btn"
:class="{ active: store.showOnlyModified }"
title="只显示修改(类似 IDEA 提交视图)"
@click="store.toggleShowOnlyModified"
:disabled="!store.currentId"
>
<i class="fas fa-filter"></i>
<span class="btn-label">只显示修改</span>
</button>
<span class="toolbar-divider"></span>
<button class="toolbar-btn" title="刷新状态" @click="store.refreshStatus" :disabled="!store.currentId">
<i class="fas fa-sync-alt"></i>
</button>
<button class="toolbar-btn" title="更新" @click="store.updateSvn" :disabled="!store.currentId">
<i class="fas fa-download"></i>
</button>
<button class="toolbar-btn" title="提交" @click="modals.openCommit()" :disabled="!store.currentId">
<i class="fas fa-upload"></i>
</button>
<button class="toolbar-btn" title="状态" @click="store.refreshStatus" :disabled="!store.currentId">
<i class="fas fa-info-circle"></i>
</button>
<button class="toolbar-btn" title="日志" @click="modals.openLog()" :disabled="!store.currentId">
<i class="fas fa-history"></i>
</button>
<button class="toolbar-btn" title="差异" @click="modals.openDiff()" :disabled="!store.currentId">
<i class="fas fa-code"></i>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
.project-title { font-size: 0.875rem; font-weight: 600; color: var(--text-primary); margin: 0; }
.project-path { font-size: 0.75rem; color: var(--text-secondary); }
.toolbar-actions { display: flex; gap: 0.25rem; }
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
}
.toolbar-btn:hover:not(:disabled) { background: var(--bg-secondary); color: var(--text-primary); }
.toolbar-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.toolbar-btn.active { background: var(--accent-blue); color: white; }
.toolbar-btn.active:hover:not(:disabled) { background: #2563eb; }
.btn-label { margin-left: 0.25rem; font-size: 0.75rem; }
.toolbar-divider { width: 1px; height: 1.25rem; background: var(--border-color); margin: 0 0.25rem; }
</style>

View File

@@ -0,0 +1,126 @@
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 },
selectedPath: { type: String, default: null }
})
const emit = defineEmits(['select', 'diff', 'copyPath'])
const expanded = ref(true)
const isSelected = computed(() => props.selectedPath === props.node.path)
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
const codeMap = { MODIFIED: 'M', ADDED: 'A', DELETED: 'D', CONFLICTED: 'C', UNVERSIONED: '?', NORMAL: '', MISSING: '!', IGNORED: 'I', EXTERNAL: 'X' }
const statusCode = (status) => {
if (!status) return ''
if (typeof status === 'string') return codeMap[status] || ''
return status.code != null ? String.fromCharCode(status.code) : (codeMap[status + ''] || '')
}
const statusClass = (status) => {
if (!status) return ''
const s = (status + '').toUpperCase()
if (s.includes('MODIFIED')) return 'modified'
if (s.includes('ADDED')) return 'added'
if (s.includes('DELETED')) return 'deleted'
if (s.includes('CONFLICTED')) return 'conflicted'
if (s.includes('UNVERSIONED')) return 'unversioned'
return ''
}
function toggle() {
if (hasChildren.value) expanded.value = !expanded.value
}
function handleClick() {
emit('select', props.node)
}
function handleContextMenu(e) {
e.preventDefault()
emit('select', props.node)
// Could show context menu here
}
</script>
<template>
<div class="tree-wrapper">
<div
class="tree-node"
:class="{ selected: isSelected }"
:style="{ paddingLeft: (depth * 16) + 'px' }"
@click="handleClick"
@contextmenu="handleContextMenu"
>
<span v-if="hasChildren" class="tree-toggle" @click.stop="toggle">
<i class="fas" :class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</span>
<span v-else class="tree-toggle-placeholder"></span>
<span class="tree-icon">
<i v-if="node.directory" class="fas" :class="expanded ? 'fa-folder-open text-blue-400' : 'fa-folder text-gray-500'"></i>
<i v-else class="fas fa-file text-gray-400"></i>
</span>
<span class="tree-label">
<span>{{ node.name }}</span>
<span v-if="node.status && statusCode(node.status)" class="svn-badge" :class="statusClass(node.status)">
{{ statusCode(node.status) }}
</span>
</span>
</div>
<template v-if="expanded && hasChildren">
<TreeNode
v-for="child in node.children"
:key="child.path"
:node="child"
:depth="depth + 1"
:selected-path="selectedPath"
@select="$emit('select', $event)"
@diff="$emit('diff', $event)"
@copy-path="$emit('copyPath')"
/>
</template>
</div>
</template>
<style scoped>
.tree-wrapper { width: 100%; }
.tree-node {
display: flex;
align-items: center;
padding: 2px 4px;
cursor: pointer;
user-select: none;
transition: background-color 0.15s ease;
min-height: 24px;
}
.tree-node:hover { background: var(--bg-secondary); }
.tree-node.selected { background: var(--accent-blue); color: white; }
.tree-toggle, .tree-toggle-placeholder {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 4px;
color: var(--text-secondary);
font-size: 10px;
}
.tree-toggle-placeholder { visibility: hidden; }
.tree-icon { width: 16px; margin-right: 6px; display: inline-flex; justify-content: center; }
.tree-label { flex: 1; display: flex; align-items: center; gap: 6px; }
.svn-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
}
.svn-badge.modified { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.svn-badge.added { background: rgba(59, 130, 246, 0.2); color: var(--accent-blue); }
.svn-badge.deleted { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
.svn-badge.conflicted { background: rgba(245, 158, 11, 0.2); color: var(--accent-yellow); }
.svn-badge.unversioned { background: rgba(148, 163, 184, 0.2); color: #94A3B8; }
.tree-node.selected .svn-badge { color: inherit; background: rgba(255,255,255,0.3); }
</style>