重构项目结构,移除旧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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVN管理器 - 多项目管理</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1685
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

38
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup>
import { onMounted } from 'vue'
import { useProjectsStore } from './stores/projects'
import ProjectSidebar from './components/ProjectSidebar.vue'
import Toolbar from './components/Toolbar.vue'
import FileTree from './components/FileTree.vue'
import CodePreview from './components/CodePreview.vue'
import AddProjectModal from './components/AddProjectModal.vue'
import CommitModal from './components/CommitModal.vue'
import LogModal from './components/LogModal.vue'
import DiffModal from './components/DiffModal.vue'
const store = useProjectsStore()
onMounted(() => store.loadProjects())
</script>
<template>
<div class="app">
<ProjectSidebar />
<div class="main-area">
<Toolbar />
<div class="content-row">
<FileTree />
<CodePreview />
</div>
</div>
</div>
<CommitModal />
<LogModal />
<DiffModal />
</template>
<style scoped>
.app { display: flex; height: 100vh; overflow: hidden; }
.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg-tertiary); }
.content-row { flex: 1; display: flex; overflow: hidden; min-height: 0; }
.content-row :deep(.file-tree) { flex: 0 0 320px; min-width: 200px; }
</style>

View File

@@ -0,0 +1,26 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
timeout: 60000,
headers: { 'Content-Type': 'application/json' }
})
export const projectsApi = {
list: () => client.get('/projects'),
add: (project) => client.post('/projects', project),
update: (project) => client.put('/projects', project),
delete: (id) => client.delete(`/projects/${id}`)
}
export const svnApi = {
status: (projectId) => client.get(`/svn/status/${projectId}`),
update: (projectId) => client.post(`/svn/update/${projectId}`),
commit: (projectId, message) => client.post(`/svn/commit/${projectId}`, { message }),
log: (projectId, limit = 20) => client.get(`/svn/log/${projectId}`, { params: { limit } }),
diff: (projectId, path) => client.get(`/svn/diff/${projectId}`, { params: path ? { path } : {} }),
fileContent: (projectId, path) => client.get(`/svn/file/${projectId}`, {
params: { path },
responseType: 'text'
})
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

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>

8
frontend/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

View File

@@ -0,0 +1,29 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useModalsStore = defineStore('modals', () => {
const commitOpen = ref(false)
const logOpen = ref(false)
const diffOpen = ref(false)
const diffPath = ref(null)
function openCommit() { commitOpen.value = true }
function closeCommit() { commitOpen.value = false }
function openLog() { logOpen.value = true }
function closeLog() { logOpen.value = false }
function openDiff(path = null) { diffPath.value = path; diffOpen.value = true }
function closeDiff() { diffOpen.value = false; diffPath.value = null }
return {
commitOpen,
logOpen,
diffOpen,
diffPath,
openCommit,
closeCommit,
openLog,
closeLog,
openDiff,
closeDiff
}
})

View File

@@ -0,0 +1,179 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { projectsApi, svnApi } from '../api/client'
const CHANGED_STATUSES = ['MODIFIED', 'ADDED', 'DELETED', 'CONFLICTED', 'UNVERSIONED']
function isChanged(status) {
if (!status) return false
const s = (status + '').toUpperCase()
return CHANGED_STATUSES.some(c => s.includes(c))
}
function filterTreeToChanged(node) {
if (!node) return null
const children = node.children || []
const filteredChildren = children
.map(c => filterTreeToChanged(c))
.filter(Boolean)
const hasChangedChild = filteredChildren.length > 0
const selfChanged = isChanged(node.status)
if (selfChanged || hasChangedChild) {
return { ...node, children: filteredChildren }
}
return null
}
export const useProjectsStore = defineStore('projects', () => {
const list = ref([])
const currentId = ref(null)
const statusData = ref(null)
const loading = ref(false)
const error = ref(null)
const showOnlyModified = ref(false)
const selectedFilePath = ref(null)
const fileContent = ref(null)
const current = computed(() => list.value.find(p => p.id === currentId.value) || null)
const rawFileTree = computed(() => statusData.value?.fileTree || null)
const currentFileTree = computed(() => {
const tree = rawFileTree.value
if (!tree) return null
if (!showOnlyModified.value) return tree
return filterTreeToChanged(tree)
})
async function loadProjects() {
loading.value = true
error.value = null
try {
const res = await projectsApi.list()
list.value = res.data || []
if (list.value.length && !currentId.value) {
currentId.value = list.value[0].id
await refreshStatus()
}
} catch (e) {
error.value = e.message || '加载项目列表失败'
} finally {
loading.value = false
}
}
async function addProject(project) {
const res = await projectsApi.add(project)
await loadProjects()
return res.data
}
async function updateProject(project) {
const res = await projectsApi.update(project)
await loadProjects()
return res.data
}
async function refreshStatus() {
if (!currentId.value) return
loading.value = true
error.value = null
try {
const res = await svnApi.status(currentId.value)
statusData.value = res.data
} catch (e) {
error.value = e.response?.data || e.message || '获取状态失败'
statusData.value = null
} finally {
loading.value = false
}
}
async function updateSvn() {
if (!currentId.value) return
loading.value = true
error.value = null
try {
const res = await svnApi.update(currentId.value)
await refreshStatus()
return res.data
} catch (e) {
error.value = e.response?.data || e.message || '更新失败'
throw e
} finally {
loading.value = false
}
}
async function commit(message) {
if (!currentId.value) return
loading.value = true
error.value = null
try {
const res = await svnApi.commit(currentId.value, message)
await refreshStatus()
return res.data
} catch (e) {
error.value = e.response?.data || e.message || '提交失败'
throw e
} finally {
loading.value = false
}
}
function setCurrent(id) {
currentId.value = id
statusData.value = null
selectedFilePath.value = null
fileContent.value = null
if (id) refreshStatus()
}
function setSelectedFile(node) {
if (!node) {
selectedFilePath.value = null
fileContent.value = null
return
}
selectedFilePath.value = node.path
fileContent.value = null
if (node.directory) {
fileContent.value = '__DIR__'
return
}
if (!currentId.value) return
const project = current.value
if (!project) return
const relPath = node.path.startsWith(project.path)
? node.path.slice(project.path.length).replace(/^[/\\]/, '')
: node.path
svnApi.fileContent(currentId.value, relPath)
.then(res => { fileContent.value = res.data })
.catch(() => { fileContent.value = null })
}
function toggleShowOnlyModified() {
showOnlyModified.value = !showOnlyModified.value
}
return {
list,
currentId,
current,
currentFileTree,
rawFileTree,
statusData,
loading,
error,
showOnlyModified,
selectedFilePath,
fileContent,
loadProjects,
addProject,
updateProject,
refreshStatus,
updateSvn,
commit,
setCurrent,
setSelectedFile,
toggleShowOnlyModified
}
})

36
frontend/src/style.css Normal file
View File

@@ -0,0 +1,36 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap');
:root {
--bg-primary: #1E293B;
--bg-secondary: #334155;
--bg-tertiary: #0F172A;
--text-primary: #F8FAFC;
--text-secondary: #CBD5E1;
--border-color: #334155;
--accent-green: #22C55E;
--accent-blue: #3B82F6;
--accent-yellow: #F59E0B;
--accent-red: #EF4444;
--font-mono: 'JetBrains Mono', monospace;
}
* {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
background: var(--bg-tertiary);
color: var(--text-primary);
min-height: 100vh;
overflow: hidden;
}
#app {
height: 100vh;
}
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-secondary); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }
}
}
})