重构项目结构,移除旧Java客户端,添加前后端目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
5
frontend/README.md
Normal 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
14
frontend/index.html
Normal 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
1685
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
38
frontend/src/App.vue
Normal 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>
|
||||
26
frontend/src/api/client.js
Normal file
26
frontend/src/api/client.js
Normal 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'
|
||||
})
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
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>
|
||||
8
frontend/src/main.js
Normal file
8
frontend/src/main.js
Normal 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')
|
||||
29
frontend/src/stores/modals.js
Normal file
29
frontend/src/stores/modals.js
Normal 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
|
||||
}
|
||||
})
|
||||
179
frontend/src/stores/projects.js
Normal file
179
frontend/src/stores/projects.js
Normal 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
36
frontend/src/style.css
Normal 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
12
frontend/vite.config.js
Normal 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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user