| <script setup lang="ts"> |
| import { ref, onMounted, onUnmounted, watch, computed, onBeforeUnmount } 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 imageUrl = ref<string>(''); |
|
|
| |
| 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 = ""; |
| imageUrl.value = ""; |
|
|
| const result = await repoApi.getFileContent(account, currentPath.value); |
| if (result) { |
| if (isImageFile.value) { |
| if (result.content) { |
| |
| const byteCharacters = atob(result.content); |
| const byteNumbers = new Array(byteCharacters.length); |
| for (let i = 0; i < byteCharacters.length; i++) { |
| byteNumbers[i] = byteCharacters.charCodeAt(i); |
| } |
| const byteArray = new Uint8Array(byteNumbers); |
| const blob = new Blob([byteArray]); |
| imageUrl.value = URL.createObjectURL(blob); |
| } else if (result.download_url) { |
| |
| imageUrl.value = result.download_url; |
| } |
| } else if (result.content) { |
| |
| contentText.value = 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'; |
| }); |
|
|
| const isImageFile = computed(() => { |
| const path = route.query.path as string; |
| if (!path) return false; |
|
|
| const ext = path.split('.').pop()?.toLowerCase() || ''; |
| const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico']; |
|
|
| return imageExtensions.includes(ext); |
| }); |
|
|
| |
| watch(() => route.query, async (query) => { |
| |
| if (route.path !== '/content') return; |
| imageUrl.value = ''; |
| 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); |
| }); |
|
|
| |
| onBeforeUnmount(() => { |
| if (imageUrl.value && imageUrl.value.startsWith('blob:')) { |
| URL.revokeObjectURL(imageUrl.value); |
| imageUrl.value = ''; |
| } |
| }); |
| </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"> |
| |
| <template v-if="!isImageFile"> |
| <t-button variant="outline" @click="toggleDiff"> |
| {{ showDiff ? '隐藏对比' : '显示对比' }} |
| </t-button> |
| <t-button theme="primary" @click="handleSave" :loading="loading"> |
| 保存 |
| </t-button> |
| </template> |
| <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"> |
| <div v-if="isImageFile" class="w-full h-full flex items-center justify-center"> |
| <img :src="imageUrl" /> |
| </div> |
| <MonacoEditor v-else 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> |
|
|