|
<script setup lang="ts"> |
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'; |
|
import { useRoute, useRouter } from 'vue-router'; |
|
import { MessagePlugin } from 'tdesign-vue-next'; |
|
import { repoApi, type Account } from '../services/repoApi'; |
|
import { useAccountStore } from '../stores/accountStorage'; |
|
import MonacoEditor from '../components/MonacoEditor.vue'; |
|
import RepoHeader from '../components/RepoHeader.vue'; |
|
|
|
const route = useRoute(); |
|
const router = useRouter(); |
|
|
|
const contentText = ref<string>(''); |
|
const loading = ref(false); |
|
const showDiff = ref(false); |
|
const originalText = ref(''); |
|
const fileSha = ref(''); |
|
const selectedAccount = ref<number | ''>(''); |
|
const currentPath = ref(''); |
|
const newFileName = ref(''); |
|
const store = useAccountStore(); |
|
const isNewFile = ref(false); |
|
|
|
|
|
const fullPath = computed(() => { |
|
if (!isNewFile.value) return currentPath.value; |
|
const basePath = route.query.path as string || ''; |
|
return basePath ? `${basePath}/${newFileName.value}` : newFileName.value; |
|
}); |
|
|
|
|
|
const showCommitDialog = ref(false); |
|
const commitMessage = ref(''); |
|
|
|
const decodeContent = (base64Content: string, contentType: 'text' | 'binary' = 'text'): string | Uint8Array => { |
|
|
|
const binaryContent = atob(base64Content); |
|
|
|
if (contentType === 'binary') { |
|
|
|
const bytes = new Uint8Array(binaryContent.length); |
|
for (let i = 0; i < binaryContent.length; i++) { |
|
bytes[i] = binaryContent.charCodeAt(i); |
|
} |
|
return bytes; |
|
} else { |
|
|
|
try { |
|
|
|
const bytes = new Uint8Array(binaryContent.length); |
|
for (let i = 0; i < binaryContent.length; i++) { |
|
bytes[i] = binaryContent.charCodeAt(i); |
|
} |
|
return new TextDecoder('utf-8').decode(bytes); |
|
} catch (e) { |
|
|
|
return decodeURIComponent(escape(binaryContent)); |
|
} |
|
} |
|
}; |
|
|
|
const fetchContent = async () => { |
|
const account = store.accounts.find(acc => acc.id === selectedAccount.value); |
|
if (!account) { |
|
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`); |
|
return; |
|
} |
|
try { |
|
loading.value = true; |
|
contentText.value = ""; |
|
originalText.value = ""; |
|
const result = await repoApi.getFileContent(account, currentPath.value); |
|
if (result.content) { |
|
|
|
contentText.value = result.encoding && result.encoding == "base64" ? decodeContent(result.content) as string : result.content; |
|
originalText.value = contentText.value; |
|
fileSha.value = result.sha; |
|
} |
|
} catch (error) { |
|
MessagePlugin.error('获取文件内容失败'); |
|
} finally { |
|
loading.value = false; |
|
} |
|
}; |
|
|
|
const handleSave = () => { |
|
|
|
|
|
|
|
if (isNewFile.value && !newFileName.value) { |
|
MessagePlugin.error('请输入文件名'); |
|
return; |
|
} |
|
|
|
if (contentText.value === '') { |
|
MessagePlugin.error('内容不能为空'); |
|
return; |
|
} |
|
|
|
|
|
if (fileSha.value && originalText.value === contentText.value) { |
|
MessagePlugin.success('内容未修改'); |
|
return; |
|
} |
|
|
|
showCommitDialog.value = true; |
|
}; |
|
|
|
const handleConfirmSave = async () => { |
|
|
|
|
|
|
|
const path = isNewFile.value ? fullPath.value : (route.query.path as string); |
|
if (!path) { |
|
MessagePlugin.error('请输入文件名'); |
|
return; |
|
} |
|
|
|
loading.value = true; |
|
try { |
|
const account = store.accounts.find(acc => acc.id === selectedAccount.value); |
|
|
|
if (!account) { |
|
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`); |
|
return; |
|
} |
|
|
|
let response; |
|
if (isNewFile.value) { |
|
|
|
response = await repoApi.createFile( |
|
account, |
|
path, |
|
contentText.value, |
|
commitMessage.value.trim() || '创建文件' |
|
); |
|
} else { |
|
|
|
response = await repoApi.updateFile( |
|
account, |
|
path, |
|
contentText.value, |
|
fileSha.value, |
|
commitMessage.value.trim() || '更新文件' |
|
); |
|
} |
|
|
|
|
|
if (response && response.content && response.content.sha) { |
|
fileSha.value = response.content.sha; |
|
} |
|
|
|
MessagePlugin.success(isNewFile.value ? '创建成功' : '保存成功'); |
|
originalText.value = contentText.value; |
|
showCommitDialog.value = false; |
|
commitMessage.value = ''; |
|
|
|
|
|
if (isNewFile.value) { |
|
router.replace({ |
|
path: '/content', |
|
query: { |
|
...route.query, |
|
path, |
|
newFile: undefined |
|
} |
|
}); |
|
} |
|
} catch (error) { |
|
console.error(error); |
|
MessagePlugin.error('保存失败'); |
|
} finally { |
|
loading.value = false; |
|
} |
|
}; |
|
|
|
const handleCancelSave = () => { |
|
showCommitDialog.value = false; |
|
commitMessage.value = ''; |
|
}; |
|
|
|
|
|
const showDeleteDialog = ref(false); |
|
|
|
const handleDelete = () => { |
|
|
|
|
|
showDeleteDialog.value = true; |
|
}; |
|
|
|
const handleConfirmDelete = async () => { |
|
|
|
|
|
loading.value = true; |
|
try { |
|
const account = store.accounts.find(acc => acc.id === selectedAccount.value); |
|
|
|
if (!account) { |
|
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`); |
|
return; |
|
} |
|
|
|
await repoApi.deleteFile( |
|
account, |
|
currentPath.value as string, |
|
fileSha.value, |
|
'删除文件' |
|
); |
|
MessagePlugin.success('删除成功'); |
|
showDeleteDialog.value = false; |
|
router.back(); |
|
} catch (error) { |
|
MessagePlugin.error('删除失败'); |
|
} finally { |
|
loading.value = false; |
|
} |
|
}; |
|
|
|
const handleCancelDelete = () => { |
|
showDeleteDialog.value = false; |
|
}; |
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => { |
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { |
|
e.preventDefault(); |
|
handleSave(); |
|
} |
|
}; |
|
|
|
|
|
const toggleDiff = () => { |
|
showDiff.value = !showDiff.value; |
|
|
|
if (showDiff.value) { |
|
|
|
if (originalText.value === '') { |
|
originalText.value = contentText.value; |
|
} |
|
} |
|
}; |
|
|
|
|
|
watch(showDiff, (newVal) => { |
|
if (newVal && originalText.value === contentText.value) { |
|
|
|
MessagePlugin.info('当前没有差异可以显示'); |
|
} |
|
}, { immediate: true }); |
|
|
|
const handlePathClick = (path: string) => { |
|
router.push({ |
|
path: '/repo', |
|
query: { |
|
id: selectedAccount.value, |
|
path |
|
} |
|
}); |
|
}; |
|
|
|
const handleRootClick = () => { |
|
router.push({ |
|
path: '/repo', |
|
query: { |
|
id: selectedAccount.value, |
|
} |
|
}); |
|
}; |
|
|
|
const handleAccountChange = (val: number) => { |
|
selectedAccount.value = val; |
|
currentPath.value = ''; |
|
router.push({ |
|
path: '/repo', |
|
query: { |
|
id: selectedAccount.value, |
|
} |
|
}); |
|
} |
|
|
|
const getLanguageFromPath = computed(() => { |
|
const path = route.query.path as string; |
|
if (!path) return 'plaintext'; |
|
|
|
const ext = path.split('.').pop()?.toLowerCase() || ''; |
|
|
|
|
|
const languageMap: Record<string, string> = { |
|
'js': 'javascript', |
|
"mjs": 'javascript', |
|
'ts': 'typescript', |
|
'json': 'json', |
|
'md': 'markdown', |
|
'yml': 'yaml', |
|
'yaml': 'yaml', |
|
'py': 'python', |
|
'html': 'html', |
|
'css': 'css', |
|
'scss': 'scss', |
|
'less': 'less', |
|
'xml': 'xml', |
|
'sh': 'shell', |
|
'bash': 'shell', |
|
'vue': 'vue', |
|
'jsx': 'javascript', |
|
'tsx': 'typescript', |
|
'gitignore': 'plaintext', |
|
'env': 'plaintext', |
|
'txt': 'plaintext', |
|
"rs": 'rust', |
|
'go': 'go', |
|
}; |
|
|
|
return languageMap[ext] || 'plaintext'; |
|
}); |
|
|
|
|
|
watch(() => route.query, async (query) => { |
|
|
|
if (route.path !== '/content') return; |
|
const { id, path, newFile } = query; |
|
isNewFile.value = !!newFile; |
|
if (id) { |
|
await store.fetchAccounts(); |
|
const account = store.accounts.find(acc => acc.id === Number(id)); |
|
if (account) { |
|
selectedAccount.value = account.id; |
|
} |
|
currentPath.value = path as string; |
|
|
|
if (isNewFile.value) { |
|
contentText.value = ''; |
|
originalText.value = ''; |
|
newFileName.value = ''; |
|
return; |
|
} |
|
if (path) { |
|
await fetchContent(); |
|
} |
|
} |
|
}, { immediate: true }); |
|
|
|
onMounted(() => { |
|
window.addEventListener('keydown', handleKeyDown); |
|
|
|
|
|
|
|
}); |
|
|
|
onUnmounted(() => { |
|
|
|
window.removeEventListener('keydown', handleKeyDown); |
|
}); |
|
</script> |
|
|
|
<template> |
|
<div class="content-container h-full p-2 md:p-5"> |
|
<div class="flex flex-col gap-4 h-full"> |
|
<RepoHeader :selected-account="selectedAccount" :current-path="isNewFile ? fullPath : currentPath" |
|
:is-new-file="isNewFile" :accounts="store.accounts" @path-click="handlePathClick" |
|
@root-click="handleRootClick" @update:selected-account="handleAccountChange"> |
|
<div class="flex items-center justify-between w-full"> |
|
<div> |
|
<t-input v-if="isNewFile" v-model="newFileName" placeholder="请输入文件名" class="w-20" /> |
|
</div> |
|
<div class="flex gap-2"> |
|
<t-button variant="outline" @click="toggleDiff"> |
|
{{ showDiff ? '隐藏对比' : '显示对比' }} |
|
</t-button> |
|
<t-button theme="primary" @click="handleSave" :loading="loading"> |
|
保存 |
|
</t-button> |
|
<t-button v-if="!isNewFile" theme="danger" @click="handleDelete" :loading="loading"> |
|
删除 |
|
</t-button> |
|
</div> |
|
</div> |
|
</RepoHeader> |
|
<t-card bordered class="h-full"> |
|
<template #content> |
|
<div class="flex flex-col h-full"> |
|
<div class="editor-container flex-1"> |
|
<MonacoEditor v-model:value="contentText" |
|
:original-value="showDiff ? originalText : undefined" :language="getLanguageFromPath" |
|
:options="{ tabSize: 2 }" /> |
|
</div> |
|
</div> |
|
</template> |
|
</t-card> |
|
</div> |
|
|
|
<t-dialog v-model:visible="showCommitDialog" header="提交更改" :confirm-on-enter="true" @confirm="handleConfirmSave" |
|
@close="handleCancelSave"> |
|
<template #body> |
|
<div class="flex flex-col gap-2"> |
|
<div class="mb-2">请输入提交信息:</div> |
|
<t-input v-model:value="commitMessage" placeholder="描述此次更改的内容" :autofocus="true" /> |
|
</div> |
|
</template> |
|
<template #footer> |
|
<t-button theme="default" @click="handleCancelSave"> |
|
取消 |
|
</t-button> |
|
<t-button theme="primary" @click="handleConfirmSave" :loading="loading"> |
|
确认 |
|
</t-button> |
|
</template> |
|
</t-dialog> |
|
|
|
<t-dialog v-model:visible="showDeleteDialog" header="确认删除" :confirm-on-enter="true" |
|
@confirm="handleConfirmDelete" @close="handleCancelDelete"> |
|
<template #body> |
|
<div class="p-2"> |
|
确定要删除此文件吗?此操作不可恢复。 |
|
</div> |
|
</template> |
|
<template #footer> |
|
<t-button theme="default" @click="handleCancelDelete"> |
|
取消 |
|
</t-button> |
|
<t-button theme="primary" @click="handleConfirmDelete" :loading="loading"> |
|
确认 |
|
</t-button> |
|
</template> |
|
</t-dialog> |
|
</div> |
|
</template> |
|
|
|
<style scoped> |
|
.content-container { |
|
width: 100%; |
|
} |
|
|
|
:deep(.t-card__body) { |
|
height: 100%; |
|
} |
|
|
|
.editor-container { |
|
border: 1px solid var(--td-component-border); |
|
} |
|
</style> |
|
|