重构项目结构,移除旧Java客户端,添加前后端目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
98
frontend/src/components/AddProjectModal.vue
Normal file
98
frontend/src/components/AddProjectModal.vue
Normal 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">×</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>
|
||||
110
frontend/src/components/CodePreview.vue
Normal file
110
frontend/src/components/CodePreview.vue
Normal 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>
|
||||
64
frontend/src/components/CommitModal.vue
Normal file
64
frontend/src/components/CommitModal.vue
Normal 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()">×</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>
|
||||
54
frontend/src/components/DiffModal.vue
Normal file
54
frontend/src/components/DiffModal.vue
Normal 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()">×</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>
|
||||
113
frontend/src/components/EditProjectModal.vue
Normal file
113
frontend/src/components/EditProjectModal.vue
Normal 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">×</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>
|
||||
74
frontend/src/components/FileTree.vue
Normal file
74
frontend/src/components/FileTree.vue
Normal 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>
|
||||
43
frontend/src/components/HelloWorld.vue
Normal file
43
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
67
frontend/src/components/LogModal.vue
Normal file
67
frontend/src/components/LogModal.vue
Normal 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()">×</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>
|
||||
194
frontend/src/components/ProjectSidebar.vue
Normal file
194
frontend/src/components/ProjectSidebar.vue
Normal 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>
|
||||
77
frontend/src/components/Toolbar.vue
Normal file
77
frontend/src/components/Toolbar.vue
Normal 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>
|
||||
126
frontend/src/components/TreeNode.vue
Normal file
126
frontend/src/components/TreeNode.vue
Normal 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>
|
||||
Reference in New Issue
Block a user