2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 03:07:20 +08:00

feat: 阶段提交

This commit is contained in:
2026-01-12 05:24:53 +08:00
parent c3054dcb91
commit fe90dd3dc8
10 changed files with 2166 additions and 19 deletions

View File

@@ -0,0 +1,520 @@
<script setup lang="ts">
import { useEditorStore, useThemeStore } from '@/store'
import { languageByPath } from '@/utils/file'
import { getMonaco } from '@/utils/monaco'
import type * as Monaco from 'monaco-editor'
import { useThemeVars } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const themeStore = useThemeStore()
const themeVars = useThemeVars()
const props = defineProps<{
readOnly?: boolean
}>()
const containerRef = ref<HTMLDivElement>()
const editorRef = shallowRef<Monaco.editor.IStandaloneCodeEditor>()
const monacoRef = shallowRef<typeof Monaco>()
const editorReady = ref(false)
const tabsContainerRef = ref<HTMLDivElement>()
// 标签页滚轮横向滚动
function handleTabsWheel(e: WheelEvent) {
if (tabsContainerRef.value) {
e.preventDefault()
tabsContainerRef.value.scrollLeft += e.deltaY
}
}
// 初始化编辑器
async function initEditor() {
if (!containerRef.value) return
const monaco = await getMonaco(themeStore.locale)
monacoRef.value = monaco
const settings = editorStore.settings
editorRef.value = monaco.editor.create(containerRef.value, {
value: '',
language: 'plaintext',
theme: 'vs' + (themeStore.darkMode ? '-dark' : ''),
readOnly: props.readOnly,
automaticLayout: true,
// Basic settings
tabSize: settings.tabSize,
insertSpaces: settings.insertSpaces,
wordWrap: settings.wordWrap,
fontSize: settings.fontSize,
minimap: { enabled: settings.minimap },
// Display settings
lineNumbers: settings.lineNumbers,
renderWhitespace: settings.renderWhitespace,
'bracketPairColorization.enabled': settings.bracketPairColorization,
guides: {
indentation: settings.guides,
bracketPairs: settings.guides
},
folding: settings.folding,
// Cursor settings
cursorStyle: settings.cursorStyle,
cursorBlinking: settings.cursorBlinking,
smoothScrolling: settings.smoothScrolling,
// Behavior settings
mouseWheelZoom: settings.mouseWheelZoom,
formatOnPaste: settings.formatOnPaste,
formatOnType: settings.formatOnType
})
// 监听内容变化
editorRef.value.onDidChangeModelContent(() => {
if (!editorStore.activeTab) return
const newValue = editorRef.value?.getValue() ?? ''
editorStore.updateContent(editorStore.activeTab.path, newValue)
})
// 监听光标位置变化
editorRef.value.onDidChangeCursorPosition((e) => {
if (!editorStore.activeTab) return
editorStore.updateCursor(editorStore.activeTab.path, e.position.lineNumber, e.position.column)
})
editorReady.value = true
updateEditorContent()
}
// 更新编辑器内容
function updateEditorContent() {
if (!editorRef.value || !monacoRef.value) return
const tab = editorStore.activeTab
if (!tab) {
editorRef.value.setValue('')
return
}
// 更新内容
const currentValue = editorRef.value.getValue()
if (currentValue !== tab.content) {
editorRef.value.setValue(tab.content)
}
// 更新语言
const model = editorRef.value.getModel()
if (model) {
const language = languageByPath(tab.path)
monacoRef.value.editor.setModelLanguage(model, language)
// 更新主题nginx 特殊处理)
const theme =
(language === 'nginx' ? 'nginx-theme' : 'vs') + (themeStore.darkMode ? '-dark' : '')
monacoRef.value.editor.setTheme(theme)
}
}
// 关闭标签页
function handleCloseTab(path: string, e: MouseEvent) {
e.stopPropagation()
const tab = editorStore.tabs.find((t) => t.path === path)
if (tab?.modified) {
window.$dialog.warning({
title: $gettext('Unsaved Changes'),
content: $gettext('This file has unsaved changes. Are you sure you want to close it?'),
positiveText: $gettext('Close'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
editorStore.closeTab(path)
}
})
} else {
editorStore.closeTab(path)
}
}
// 切换标签页
function handleSwitchTab(path: string) {
editorStore.switchTab(path)
}
// 拖拽排序
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function handleDragStart(e: DragEvent, index: number) {
dragIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
}
}
function handleDragOver(e: DragEvent, index: number) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move'
}
dragOverIndex.value = index
}
function handleDragLeave() {
dragOverIndex.value = null
}
function handleDrop(e: DragEvent, toIndex: number) {
e.preventDefault()
if (dragIndex.value !== null && dragIndex.value !== toIndex) {
editorStore.reorderTabs(dragIndex.value, toIndex)
}
dragIndex.value = null
dragOverIndex.value = null
}
function handleDragEnd() {
dragIndex.value = null
dragOverIndex.value = null
}
// 右键菜单
const contextMenuOptions = computed(() => [
{
label: $gettext('Close'),
key: 'close'
},
{
label: $gettext('Close Others'),
key: 'closeOthers'
},
{
label: $gettext('Close All'),
key: 'closeAll'
},
{
label: $gettext('Close Saved'),
key: 'closeSaved'
}
])
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const showContextMenu = ref(false)
const contextMenuPath = ref('')
function handleContextMenu(e: MouseEvent, path: string) {
e.preventDefault()
contextMenuPath.value = path
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
showContextMenu.value = true
}
function handleContextMenuSelect(key: string) {
showContextMenu.value = false
switch (key) {
case 'close':
handleCloseTab(contextMenuPath.value, new MouseEvent('click'))
break
case 'closeOthers':
editorStore.closeOtherTabs(contextMenuPath.value)
break
case 'closeAll':
editorStore.closeAllTabs()
break
case 'closeSaved':
editorStore.closeSavedTabs()
break
}
}
function handleClickOutside() {
showContextMenu.value = false
}
// 监听当前标签页变化
watch(
() => editorStore.activeTabPath,
() => {
if (editorReady.value) {
updateEditorContent()
}
}
)
// 监听当前标签页内容变化(外部更新)
watch(
() => editorStore.activeTab?.content,
(newContent) => {
if (!editorRef.value || !editorStore.activeTab) return
const currentValue = editorRef.value.getValue()
if (newContent !== undefined && currentValue !== newContent) {
editorRef.value.setValue(newContent)
}
}
)
// 监听主题变化
watch(
() => themeStore.darkMode,
() => {
if (!monacoRef.value || !editorStore.activeTab) return
const language = languageByPath(editorStore.activeTab.path)
const theme =
(language === 'nginx' ? 'nginx-theme' : 'vs') + (themeStore.darkMode ? '-dark' : '')
monacoRef.value.editor.setTheme(theme)
}
)
// 监听编辑器设置变化
watch(
() => editorStore.settings,
(settings) => {
if (!editorRef.value) return
editorRef.value.updateOptions({
// Basic settings
tabSize: settings.tabSize,
insertSpaces: settings.insertSpaces,
wordWrap: settings.wordWrap,
fontSize: settings.fontSize,
minimap: { enabled: settings.minimap },
// Display settings
lineNumbers: settings.lineNumbers,
renderWhitespace: settings.renderWhitespace,
'bracketPairColorization.enabled': settings.bracketPairColorization,
guides: {
indentation: settings.guides,
bracketPairs: settings.guides
},
folding: settings.folding,
// Cursor settings
cursorStyle: settings.cursorStyle,
cursorBlinking: settings.cursorBlinking,
smoothScrolling: settings.smoothScrolling,
// Behavior settings
mouseWheelZoom: settings.mouseWheelZoom,
formatOnPaste: settings.formatOnPaste,
formatOnType: settings.formatOnType
})
},
{ deep: true }
)
onMounted(() => {
initEditor()
})
onBeforeUnmount(() => {
editorRef.value?.dispose()
})
// 暴露方法
defineExpose({
getEditor: () => editorRef.value,
focus: () => editorRef.value?.focus()
})
</script>
<template>
<div class="editor-pane">
<!-- 标签页栏 -->
<div class="tabs-bar" v-if="editorStore.tabs.length > 0">
<div ref="tabsContainerRef" class="tabs-container" @wheel="handleTabsWheel">
<div
v-for="(tab, index) in editorStore.tabs"
:key="tab.path"
class="tab-item"
:class="{
active: tab.path === editorStore.activeTabPath,
dragging: dragIndex === index,
'drag-over': dragOverIndex === index && dragIndex !== index
}"
draggable="true"
@click="handleSwitchTab(tab.path)"
@contextmenu="handleContextMenu($event, tab.path)"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver($event, index)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<span class="tab-name" :class="{ modified: tab.modified }">
{{ tab.name }}
<span v-if="tab.modified" class="modified-dot"></span>
</span>
<n-button
quaternary
size="tiny"
class="close-btn"
@click="handleCloseTab(tab.path, $event)"
>
<template #icon>
<i-mdi-close />
</template>
</n-button>
</div>
</div>
</div>
<!-- 编辑器容器 -->
<div class="editor-container">
<div v-if="editorStore.tabs.length === 0" class="empty-state">
<i-mdi-file-document-outline class="empty-icon" />
<p>{{ $gettext('Select a file to edit') }}</p>
</div>
<div
v-show="editorStore.tabs.length > 0"
ref="containerRef"
class="monaco-container"
/>
<div v-if="editorStore.activeTab?.loading" class="loading-overlay">
<n-spin size="medium" />
</div>
</div>
<!-- 右键菜单 -->
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
:show="showContextMenu"
@select="handleContextMenuSelect"
@clickoutside="handleClickOutside"
/>
</div>
</template>
<style scoped lang="scss">
.editor-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tabs-bar {
flex-shrink: 0;
border-bottom: 1px solid v-bind('themeVars.borderColor');
background: v-bind('themeVars.cardColor');
}
.tabs-container {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
}
.tab-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
cursor: pointer;
border-right: 1px solid v-bind('themeVars.borderColor');
white-space: nowrap;
transition: background-color 0.2s, opacity 0.2s;
position: relative;
user-select: none;
&:hover {
background: v-bind('themeVars.buttonColor2Hover');
}
&.active {
background: v-bind('themeVars.buttonColor2Hover');
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: v-bind('themeVars.primaryColor');
}
}
&.dragging {
opacity: 0.5;
}
&.drag-over {
border-left: 2px solid v-bind('themeVars.primaryColor');
}
}
.tab-name {
font-size: 13px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
&.modified {
font-style: italic;
}
}
.modified-dot {
color: v-bind('themeVars.warningColor');
margin-left: 4px;
}
.close-btn {
opacity: 0.6;
padding: 2px;
&:hover {
opacity: 1;
}
}
.editor-container {
flex: 1;
position: relative;
overflow: visible; /* 允许 tooltip 溢出显示 */
}
.monaco-container {
width: 100%;
height: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: v-bind('themeVars.textColor3');
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
p {
font-size: 14px;
}
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
z-index: 10;
}
</style>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { useEditorStore } from '@/store'
import { useThemeVars } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const themeVars = useThemeVars()
// 支持的语言列表
const languages = [
'plaintext',
'javascript',
'typescript',
'html',
'css',
'scss',
'less',
'json',
'xml',
'yaml',
'markdown',
'python',
'go',
'java',
'php',
'ruby',
'rust',
'c',
'cpp',
'csharp',
'shell',
'sql',
'nginx',
'dockerfile'
]
// 支持的编码列表
const encodings = ['utf-8', 'gbk', 'gb2312', 'iso-8859-1', 'utf-16', 'utf-16le', 'utf-16be']
// 缩进选项
const indentOptions = computed(() => [
{ label: `${$gettext('Spaces')}: 2`, value: { tabSize: 2, insertSpaces: true } },
{ label: `${$gettext('Spaces')}: 4`, value: { tabSize: 4, insertSpaces: true } },
{ label: `${$gettext('Tabs')}: 2`, value: { tabSize: 2, insertSpaces: false } },
{ label: `${$gettext('Tabs')}: 4`, value: { tabSize: 4, insertSpaces: false } }
])
// 当前缩进显示
const currentIndent = computed(() => {
const { tabSize, insertSpaces } = editorStore.settings
return insertSpaces ? `${$gettext('Spaces')}: ${tabSize}` : `${$gettext('Tabs')}: ${tabSize}`
})
// 更新行分隔符
function handleLineEndingChange(value: 'LF' | 'CRLF') {
if (editorStore.activeTab) {
editorStore.updateLineEnding(editorStore.activeTab.path, value)
}
}
// 更新编码
function handleEncodingChange(value: string) {
if (editorStore.activeTab) {
editorStore.updateEncoding(editorStore.activeTab.path, value)
}
}
// 更新语言
function handleLanguageChange(value: string) {
if (editorStore.activeTab) {
editorStore.updateLanguage(editorStore.activeTab.path, value)
}
}
// 更新缩进
function handleIndentChange(value: { tabSize: number; insertSpaces: boolean }) {
editorStore.updateSettings(value)
}
</script>
<template>
<div class="editor-status-bar" v-if="editorStore.activeTab">
<!-- 文件路径 -->
<div class="status-item path">
{{ $gettext('Path') }}:
<n-ellipsis style="max-width: 400px">
{{ editorStore.activeTab.path }}
</n-ellipsis>
</div>
<div class="status-spacer" />
<!-- 行分隔符 -->
<n-popselect
:value="editorStore.activeTab.lineEnding"
:options="[
{ label: 'LF', value: 'LF' },
{ label: 'CRLF', value: 'CRLF' }
]"
@update:value="handleLineEndingChange"
>
<div class="status-item clickable">
{{ editorStore.activeTab.lineEnding }}
</div>
</n-popselect>
<!-- 光标位置 -->
<div class="status-item">
{{ $gettext('Ln') }} {{ editorStore.activeTab.cursorLine }}, {{ $gettext('Col') }}
{{ editorStore.activeTab.cursorColumn }}
</div>
<!-- 缩进 -->
<n-popselect :options="indentOptions" @update:value="handleIndentChange">
<div class="status-item clickable">
{{ currentIndent }}
</div>
</n-popselect>
<!-- 编码 -->
<n-popselect
:value="editorStore.activeTab.encoding"
:options="encodings.map((e) => ({ label: e.toUpperCase(), value: e }))"
@update:value="handleEncodingChange"
scrollable
>
<div class="status-item clickable">
{{ $gettext('Encoding') }}: {{ editorStore.activeTab.encoding }}
</div>
</n-popselect>
<!-- 语言 -->
<n-popselect
:value="editorStore.activeTab.language"
:options="languages.map((l) => ({ label: l, value: l }))"
@update:value="handleLanguageChange"
scrollable
>
<div class="status-item clickable">
{{ $gettext('Language') }}: {{ editorStore.activeTab.language }}
</div>
</n-popselect>
</div>
<div class="editor-status-bar empty" v-else>
<span class="status-item">{{ $gettext('No file open') }}</span>
</div>
</template>
<style scoped lang="scss">
.editor-status-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 12px;
font-size: 12px;
background: v-bind('themeVars.cardColor');
border-top: 1px solid v-bind('themeVars.borderColor');
flex-shrink: 0;
height: 26px;
line-height: 26px;
&.empty {
color: v-bind('themeVars.textColor3');
}
}
.status-item {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
&.clickable {
cursor: pointer;
&:hover {
background: v-bind('themeVars.buttonColor2Hover');
}
}
&.path {
min-width: 0;
flex-shrink: 1;
}
}
.status-spacer {
flex: 1;
}
</style>

View File

@@ -0,0 +1,307 @@
<script setup lang="ts">
import file from '@/api/panel/file'
import { useEditorStore } from '@/store'
import { decodeBase64 } from '@/utils'
import { useThemeVars } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const themeVars = useThemeVars()
const emit = defineEmits<{
(e: 'search'): void
(e: 'replace'): void
(e: 'goto'): void
(e: 'settings'): void
}>()
const saving = ref(false)
const savingAll = ref(false)
// 保存当前文件
function handleSave() {
const tab = editorStore.activeTab
if (!tab) {
window.$message.warning($gettext('No file to save'))
return
}
if (!tab.modified) {
window.$message.info($gettext('No changes to save'))
return
}
saving.value = true
useRequest(file.save(tab.path, tab.content))
.onSuccess(() => {
editorStore.markSaved(tab.path)
window.$message.success($gettext('Saved successfully'))
})
.onComplete(() => {
saving.value = false
})
}
// 保存所有文件
async function handleSaveAll() {
const unsavedTabs = editorStore.unsavedTabs
if (unsavedTabs.length === 0) {
window.$message.info($gettext('No changes to save'))
return
}
savingAll.value = true
let successCount = 0
let failCount = 0
for (const tab of unsavedTabs) {
try {
await new Promise<void>((resolve, reject) => {
useRequest(file.save(tab.path, tab.content))
.onSuccess(() => {
editorStore.markSaved(tab.path)
successCount++
resolve()
})
.onError(() => {
failCount++
reject()
})
})
} catch {
// 继续处理下一个文件
}
}
savingAll.value = false
if (failCount === 0) {
window.$message.success($gettext('All files saved successfully'))
} else {
window.$message.warning(
$gettext('Saved %{ success } files, %{ fail } failed', {
success: successCount,
fail: failCount
})
)
}
}
// 刷新当前文件
function handleRefresh() {
const tab = editorStore.activeTab
if (!tab) return
if (tab.modified) {
window.$dialog.warning({
title: $gettext('Unsaved Changes'),
content: $gettext('This file has unsaved changes. Refreshing will discard them. Continue?'),
positiveText: $gettext('Refresh'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
doRefresh(tab.path)
}
})
} else {
doRefresh(tab.path)
}
}
function doRefresh(path: string) {
editorStore.setLoading(path, true)
useRequest(file.content(encodeURIComponent(path)))
.onSuccess(({ data }) => {
const content = decodeBase64(data.content)
editorStore.reloadFile(path, content)
window.$message.success($gettext('Refreshed successfully'))
})
.onComplete(() => {
editorStore.setLoading(path, false)
})
}
// 搜索
function handleSearch() {
emit('search')
}
// 替换
function handleReplace() {
emit('replace')
}
// 跳转行
function handleGoto() {
emit('goto')
}
// 设置
function handleSettings() {
emit('settings')
}
// 字体大小调整
function handleFontSizeChange(delta: number) {
const newSize = Math.max(10, Math.min(24, editorStore.settings.fontSize + delta))
editorStore.updateSettings({ fontSize: newSize })
}
// 切换小地图
function handleToggleMinimap() {
editorStore.updateSettings({ minimap: !editorStore.settings.minimap })
}
// 切换自动换行
function handleToggleWordWrap() {
const current = editorStore.settings.wordWrap
editorStore.updateSettings({ wordWrap: current === 'on' ? 'off' : 'on' })
}
</script>
<template>
<div class="editor-toolbar">
<n-flex align="center" :wrap="false">
<!-- 文件操作 -->
<n-button-group size="small">
<n-button
@click="handleSave"
:disabled="!editorStore.activeTab?.modified"
:loading="saving"
:title="$gettext('Save (Ctrl+S)')"
>
<template #icon>
<i-mdi-content-save />
</template>
{{ $gettext('Save') }}
</n-button>
<n-button
@click="handleSaveAll"
:disabled="!editorStore.hasUnsavedFiles"
:loading="savingAll"
:title="$gettext('Save All (Ctrl+Shift+S)')"
>
<template #icon>
<i-mdi-content-save-all />
</template>
{{ $gettext('Save All') }}
</n-button>
<n-button
@click="handleRefresh"
:disabled="!editorStore.activeTab"
:title="$gettext('Refresh')"
>
<template #icon>
<i-mdi-refresh />
</template>
{{ $gettext('Refresh') }}
</n-button>
</n-button-group>
<n-divider vertical />
<!-- 编辑操作 -->
<n-button-group size="small">
<n-button
@click="handleSearch"
:disabled="!editorStore.activeTab"
:title="$gettext('Search (Ctrl+F)')"
>
<template #icon>
<i-mdi-magnify />
</template>
{{ $gettext('Search') }}
</n-button>
<n-button
@click="handleReplace"
:disabled="!editorStore.activeTab"
:title="$gettext('Replace (Ctrl+H)')"
>
<template #icon>
<i-mdi-find-replace />
</template>
{{ $gettext('Replace') }}
</n-button>
<n-button
@click="handleGoto"
:disabled="!editorStore.activeTab"
:title="$gettext('Go to Line (Ctrl+G)')"
>
<template #icon>
<i-mdi-arrow-right-bold />
</template>
{{ $gettext('Go to') }}
</n-button>
</n-button-group>
<n-divider vertical />
<!-- 视图操作 -->
<n-button-group size="small">
<n-button @click="handleFontSizeChange(-1)" :title="$gettext('Decrease Font Size')">
<template #icon>
<i-mdi-format-font-size-decrease />
</template>
</n-button>
<n-button class="font-size-display">
{{ editorStore.settings.fontSize }}
</n-button>
<n-button @click="handleFontSizeChange(1)" :title="$gettext('Increase Font Size')">
<template #icon>
<i-mdi-format-font-size-increase />
</template>
</n-button>
</n-button-group>
<n-button
size="small"
:type="editorStore.settings.wordWrap === 'on' ? 'primary' : 'default'"
@click="handleToggleWordWrap"
:title="$gettext('Toggle Word Wrap')"
>
<template #icon>
<i-mdi-wrap />
</template>
</n-button>
<n-button
size="small"
:type="editorStore.settings.minimap ? 'primary' : 'default'"
@click="handleToggleMinimap"
:title="$gettext('Toggle Minimap')"
>
<template #icon>
<i-mdi-map-outline />
</template>
</n-button>
<div class="spacer" />
<!-- 设置 -->
<n-button size="small" quaternary @click="handleSettings" :title="$gettext('Settings')">
<template #icon>
<i-mdi-cog />
</template>
</n-button>
</n-flex>
</div>
</template>
<style scoped lang="scss">
.editor-toolbar {
padding: 8px 12px;
border-bottom: 1px solid v-bind('themeVars.borderColor');
background: v-bind('themeVars.cardColor');
flex-shrink: 0;
}
.spacer {
flex: 1;
}
.font-size-display {
min-width: 40px;
cursor: default;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { useEditorStore } from '@/store'
import { useGettext } from 'vue3-gettext'
import EditorPane from './EditorPane.vue'
import EditorStatusBar from './EditorStatusBar.vue'
import EditorToolbar from './EditorToolbar.vue'
import FileTree from './FileTree.vue'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const props = defineProps<{
initialPath?: string
readOnly?: boolean
}>()
// 侧边栏折叠状态
const siderCollapsed = ref(false)
const siderWidth = ref(250)
// 文件树根目录
const rootPath = ref(props.initialPath || editorStore.rootPath || '/')
// 编辑器面板引用
const editorPaneRef = ref<InstanceType<typeof EditorPane>>()
const fileTreeRef = ref<InstanceType<typeof FileTree>>()
// 设置弹窗
const showSettings = ref(false)
// 处理工具栏事件
function handleSearch() {
const editor = editorPaneRef.value?.getEditor()
if (editor) {
editor.getAction('actions.find')?.run()
}
}
function handleReplace() {
const editor = editorPaneRef.value?.getEditor()
if (editor) {
editor.getAction('editor.action.startFindReplaceAction')?.run()
}
}
function handleGoto() {
const editor = editorPaneRef.value?.getEditor()
if (editor) {
// 需要先聚焦编辑器gotoLine 才能正常工作
editor.focus()
editor.getAction('editor.action.gotoLine')?.run()
}
}
function handleSettings() {
showSettings.value = true
}
// 监听根目录变化
watch(rootPath, (newPath) => {
editorStore.setRootPath(newPath)
})
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
// Ctrl+S 保存
if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
e.preventDefault()
// 触发保存
const toolbar = document.querySelector('.editor-toolbar button') as HTMLButtonElement
toolbar?.click()
}
// Ctrl+Shift+S 全部保存
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
})
// 暴露方法
defineExpose({
refresh: () => fileTreeRef.value?.refresh(),
focus: () => editorPaneRef.value?.focus()
})
</script>
<template>
<div class="file-editor-view">
<!-- 顶部工具栏 -->
<EditorToolbar
@search="handleSearch"
@replace="handleReplace"
@goto="handleGoto"
@settings="handleSettings"
/>
<!-- 主体区域 -->
<div class="editor-main">
<n-layout has-sider class="editor-layout">
<!-- 左侧文件树 -->
<n-layout-sider
bordered
:collapsed="siderCollapsed"
:collapsed-width="0"
:width="siderWidth"
show-trigger="bar"
collapse-mode="width"
@update:collapsed="siderCollapsed = $event"
class="file-tree-sider"
>
<FileTree ref="fileTreeRef" v-model:root-path="rootPath" />
</n-layout-sider>
<!-- 右侧编辑器区域包含编辑器和状态栏 -->
<n-layout class="editor-content">
<div class="editor-wrapper">
<EditorPane ref="editorPaneRef" :read-only="readOnly" />
<EditorStatusBar />
</div>
</n-layout>
</n-layout>
</div>
<!-- 设置弹窗 -->
<n-modal v-model:show="showSettings" preset="card" :title="$gettext('Editor Settings')" style="width: 600px">
<n-scrollbar style="max-height: 70vh">
<n-form label-placement="left" label-width="140" class="settings-form">
<!-- 基础设置 -->
<n-divider title-placement="left">{{ $gettext('Basic') }}</n-divider>
<n-form-item :label="$gettext('Tab Size')">
<n-input-number
v-model:value="editorStore.settings.tabSize"
:min="1"
:max="8"
@update:value="(v) => editorStore.updateSettings({ tabSize: v || 4 })"
/>
</n-form-item>
<n-form-item :label="$gettext('Use Spaces')">
<n-switch
:value="editorStore.settings.insertSpaces"
@update:value="(v) => editorStore.updateSettings({ insertSpaces: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Font Size')">
<n-input-number
v-model:value="editorStore.settings.fontSize"
:min="10"
:max="24"
@update:value="(v) => editorStore.updateSettings({ fontSize: v || 14 })"
/>
</n-form-item>
<n-form-item :label="$gettext('Word Wrap')">
<n-select
:value="editorStore.settings.wordWrap"
:options="[
{ label: $gettext('Off'), value: 'off' },
{ label: $gettext('On'), value: 'on' },
{ label: $gettext('Word Wrap Column'), value: 'wordWrapColumn' },
{ label: $gettext('Bounded'), value: 'bounded' }
]"
@update:value="(v) => editorStore.updateSettings({ wordWrap: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Show Minimap')">
<n-switch
:value="editorStore.settings.minimap"
@update:value="(v) => editorStore.updateSettings({ minimap: v })"
/>
</n-form-item>
<!-- 显示设置 -->
<n-divider title-placement="left">{{ $gettext('Display') }}</n-divider>
<n-form-item :label="$gettext('Line Numbers')">
<n-select
:value="editorStore.settings.lineNumbers"
:options="[
{ label: $gettext('On'), value: 'on' },
{ label: $gettext('Off'), value: 'off' },
{ label: $gettext('Relative'), value: 'relative' },
{ label: $gettext('Interval'), value: 'interval' }
]"
@update:value="(v) => editorStore.updateSettings({ lineNumbers: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Render Whitespace')">
<n-select
:value="editorStore.settings.renderWhitespace"
:options="[
{ label: $gettext('None'), value: 'none' },
{ label: $gettext('Boundary'), value: 'boundary' },
{ label: $gettext('Selection'), value: 'selection' },
{ label: $gettext('Trailing'), value: 'trailing' },
{ label: $gettext('All'), value: 'all' }
]"
@update:value="(v) => editorStore.updateSettings({ renderWhitespace: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Bracket Colorization')">
<n-switch
:value="editorStore.settings.bracketPairColorization"
@update:value="(v) => editorStore.updateSettings({ bracketPairColorization: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Indent Guides')">
<n-switch
:value="editorStore.settings.guides"
@update:value="(v) => editorStore.updateSettings({ guides: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Code Folding')">
<n-switch
:value="editorStore.settings.folding"
@update:value="(v) => editorStore.updateSettings({ folding: v })"
/>
</n-form-item>
<!-- 光标设置 -->
<n-divider title-placement="left">{{ $gettext('Cursor') }}</n-divider>
<n-form-item :label="$gettext('Cursor Style')">
<n-select
:value="editorStore.settings.cursorStyle"
:options="[
{ label: $gettext('Line'), value: 'line' },
{ label: $gettext('Block'), value: 'block' },
{ label: $gettext('Underline'), value: 'underline' },
{ label: $gettext('Line Thin'), value: 'line-thin' },
{ label: $gettext('Block Outline'), value: 'block-outline' },
{ label: $gettext('Underline Thin'), value: 'underline-thin' }
]"
@update:value="(v) => editorStore.updateSettings({ cursorStyle: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Cursor Blinking')">
<n-select
:value="editorStore.settings.cursorBlinking"
:options="[
{ label: $gettext('Blink'), value: 'blink' },
{ label: $gettext('Smooth'), value: 'smooth' },
{ label: $gettext('Phase'), value: 'phase' },
{ label: $gettext('Expand'), value: 'expand' },
{ label: $gettext('Solid'), value: 'solid' }
]"
@update:value="(v) => editorStore.updateSettings({ cursorBlinking: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Smooth Scrolling')">
<n-switch
:value="editorStore.settings.smoothScrolling"
@update:value="(v) => editorStore.updateSettings({ smoothScrolling: v })"
/>
</n-form-item>
<!-- 行为设置 -->
<n-divider title-placement="left">{{ $gettext('Behavior') }}</n-divider>
<n-form-item :label="$gettext('Mouse Wheel Zoom')">
<n-switch
:value="editorStore.settings.mouseWheelZoom"
@update:value="(v) => editorStore.updateSettings({ mouseWheelZoom: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Format On Paste')">
<n-switch
:value="editorStore.settings.formatOnPaste"
@update:value="(v) => editorStore.updateSettings({ formatOnPaste: v })"
/>
</n-form-item>
<n-form-item :label="$gettext('Format On Type')">
<n-switch
:value="editorStore.settings.formatOnType"
@update:value="(v) => editorStore.updateSettings({ formatOnType: v })"
/>
</n-form-item>
</n-form>
</n-scrollbar>
</n-modal>
</div>
</template>
<style scoped lang="scss">
.file-editor-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--n-card-color);
}
.editor-main {
flex: 1;
overflow: hidden;
}
.editor-layout {
height: 100%;
}
.file-tree-sider {
height: 100%;
:deep(.n-layout-sider-scroll-container) {
height: 100%;
}
}
.editor-content {
height: 100%;
overflow: visible; /* 允许 Monaco tooltip 溢出 */
}
.editor-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: visible; /* 允许 Monaco tooltip 溢出 */
}
</style>

View File

@@ -0,0 +1,474 @@
<script setup lang="ts">
import file from '@/api/panel/file'
import TheIcon from '@/components/custom/TheIcon.vue'
import { useEditorStore } from '@/store'
import { decodeBase64 } from '@/utils'
import { getExt, getIconByExt } from '@/utils/file'
import type { TreeOption } from 'naive-ui'
import { useThemeVars } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const themeVars = useThemeVars()
const props = defineProps<{
rootPath: string
}>()
const emit = defineEmits<{
(e: 'update:rootPath', path: string): void
}>()
// 文件树数据
const treeData = ref<TreeOption[]>([])
const expandedKeys = ref<string[]>([])
const selectedKeys = ref<string[]>([])
const loading = ref(false)
const searchKeyword = ref('')
// 新建文件/目录弹窗
const showCreateModal = ref(false)
const createType = ref<'file' | 'dir'>('file')
const createName = ref('')
const createParentPath = ref('')
const createLoading = ref(false)
// 加载目录内容
async function loadDirectory(path: string): Promise<TreeOption[]> {
return new Promise((resolve, reject) => {
useRequest(file.list(path, '', false, 'name', 1, 1000))
.onSuccess(({ data }) => {
const items = data.items || []
// 排序:目录在前,文件在后,同类型按名称排序
const sortedItems = [...items].sort((a: any, b: any) => {
if (a.dir && !b.dir) return -1
if (!a.dir && b.dir) return 1
return a.name.localeCompare(b.name)
})
resolve(
sortedItems.map((item: any) => ({
key: item.full,
label: item.name,
isLeaf: !item.dir,
prefix: () =>
h(TheIcon, {
icon: item.dir ? 'mdi:folder' : getIconByExt(getExt(item.name)),
size: 18,
color: item.dir ? '#f59e0b' : '#6b7280'
}),
isDir: item.dir,
children: item.dir ? undefined : undefined
}))
)
})
.onError(() => {
reject(new Error('Failed to load directory'))
})
})
}
// 初始化加载根目录
async function initTree() {
loading.value = true
try {
treeData.value = await loadDirectory(props.rootPath)
expandedKeys.value = []
} catch {
treeData.value = []
} finally {
loading.value = false
}
}
// 懒加载子目录
async function handleLoad(node: TreeOption): Promise<void> {
if (node.isLeaf) return
try {
const children = await loadDirectory(node.key as string)
node.children = children
} catch {
node.children = []
}
}
// 展开节点
function handleExpandedKeysUpdate(keys: string[]) {
expandedKeys.value = keys
}
// 选择节点(打开文件)
async function handleSelect(keys: string[], option: TreeOption[]) {
if (keys.length === 0) return
selectedKeys.value = keys
const node = option[0]
if (node && node.isLeaf) {
// 打开文件
const path = node.key as string
const existingTab = editorStore.tabs.find((t) => t.path === path)
if (existingTab) {
editorStore.switchTab(path)
} else {
// 加载文件内容
editorStore.openFile(path, '', 'utf-8')
editorStore.setLoading(path, true)
useRequest(file.content(encodeURIComponent(path)))
.onSuccess(({ data }) => {
const content = decodeBase64(data.content)
editorStore.reloadFile(path, content)
})
.onError(() => {
editorStore.closeTab(path)
window.$message.error($gettext('Failed to load file'))
})
.onComplete(() => {
editorStore.setLoading(path, false)
})
}
}
}
// 上一级目录
function handleGoUp() {
const parts = props.rootPath.split('/').filter(Boolean)
if (parts.length > 0) {
parts.pop()
const newPath = '/' + parts.join('/')
emit('update:rootPath', newPath || '/')
}
}
// 刷新
function handleRefresh() {
initTree()
}
// 显示新建弹窗
function showCreate(type: 'file' | 'dir') {
createType.value = type
createName.value = ''
// 使用选中的目录或根目录作为父目录
if (selectedKeys.value.length > 0) {
const selectedNode = findNode(treeData.value, selectedKeys.value[0])
if (selectedNode && !selectedNode.isLeaf) {
createParentPath.value = selectedKeys.value[0]
} else {
// 如果选中的是文件,使用其父目录
const parts = selectedKeys.value[0].split('/')
parts.pop()
createParentPath.value = parts.join('/') || props.rootPath
}
} else {
createParentPath.value = props.rootPath
}
showCreateModal.value = true
}
// 查找节点
function findNode(nodes: TreeOption[], key: string): TreeOption | null {
for (const node of nodes) {
if (node.key === key) return node
if (node.children) {
const found = findNode(node.children, key)
if (found) return found
}
}
return null
}
// 确认新建
async function handleCreate() {
if (!createName.value.trim()) {
window.$message.warning($gettext('Please enter a name'))
return
}
const fullPath = `${createParentPath.value}/${createName.value}`.replace(/\/+/g, '/')
createLoading.value = true
useRequest(file.create(fullPath, createType.value === 'dir'))
.onSuccess(async () => {
window.$message.success($gettext('Created successfully'))
showCreateModal.value = false
// 刷新父目录
if (expandedKeys.value.includes(createParentPath.value)) {
const parentNode = findNode(treeData.value, createParentPath.value)
if (parentNode) {
parentNode.children = await loadDirectory(createParentPath.value)
}
} else if (createParentPath.value === props.rootPath) {
// 如果是在根目录创建,刷新整个树
await initTree()
} else {
// 展开父目录
expandedKeys.value = [...expandedKeys.value, createParentPath.value]
}
// 如果是文件,自动打开
if (createType.value === 'file') {
editorStore.openFile(fullPath, '', 'utf-8')
}
})
.onError(() => {
window.$message.error($gettext('Failed to create'))
})
.onComplete(() => {
createLoading.value = false
})
}
// 搜索过滤
function filterTree(pattern: string, option: TreeOption): boolean {
if (!pattern) return true
return (option.label as string).toLowerCase().includes(pattern.toLowerCase())
}
// 路径编辑
const isEditingPath = ref(false)
const editingPath = ref('')
const pathInputRef = ref<HTMLInputElement | null>(null)
function startEditPath() {
editingPath.value = props.rootPath
isEditingPath.value = true
nextTick(() => {
pathInputRef.value?.focus()
pathInputRef.value?.select()
})
}
function confirmEditPath() {
const newPath = editingPath.value.trim()
if (newPath && newPath !== props.rootPath) {
// 确保路径以 / 开头
const normalizedPath = newPath.startsWith('/') ? newPath : '/' + newPath
emit('update:rootPath', normalizedPath)
}
isEditingPath.value = false
}
function cancelEditPath() {
isEditingPath.value = false
editingPath.value = ''
}
// 监听根目录变化
watch(
() => props.rootPath,
() => {
initTree()
}
)
onMounted(() => {
initTree()
})
defineExpose({
refresh: handleRefresh
})
</script>
<template>
<div class="file-tree">
<!-- 工具栏 -->
<div class="file-tree-toolbar">
<n-button-group size="small">
<n-button quaternary @click="handleGoUp" :title="$gettext('Go Up')">
<template #icon>
<i-mdi-arrow-up />
</template>
</n-button>
<n-button quaternary @click="handleRefresh" :title="$gettext('Refresh')">
<template #icon>
<i-mdi-refresh />
</template>
</n-button>
<n-popselect
:options="[
{ label: $gettext('New File'), value: 'file' },
{ label: $gettext('New Folder'), value: 'dir' }
]"
@update:value="showCreate"
>
<n-button quaternary :title="$gettext('New')">
<template #icon>
<i-mdi-plus />
</template>
</n-button>
</n-popselect>
</n-button-group>
<n-input
v-model:value="searchKeyword"
size="small"
:placeholder="$gettext('Search')"
clearable
class="search-input"
>
<template #prefix>
<i-mdi-magnify />
</template>
</n-input>
</div>
<!-- 当前目录 -->
<div class="current-path" @click="startEditPath" v-if="!isEditingPath">
<the-icon icon="mdi:folder-open" :size="14" class="path-icon" />
<n-ellipsis :tooltip="{ width: 300 }">
{{ rootPath }}
</n-ellipsis>
<the-icon icon="mdi:pencil" :size="12" class="edit-icon" />
</div>
<div class="current-path editing" v-else>
<n-input
ref="pathInputRef"
v-model:value="editingPath"
size="tiny"
:placeholder="$gettext('Enter path')"
@keyup.enter="confirmEditPath"
@keyup.escape="cancelEditPath"
@blur="confirmEditPath"
>
<template #prefix>
<the-icon icon="mdi:folder-open" :size="14" />
</template>
</n-input>
</div>
<!-- 文件树 -->
<div class="tree-container">
<n-spin :show="loading" class="tree-spin">
<n-tree
block-line
:data="treeData"
:expanded-keys="expandedKeys"
:selected-keys="selectedKeys"
:pattern="searchKeyword"
:filter="filterTree"
:on-load="handleLoad"
@update:expanded-keys="handleExpandedKeysUpdate"
@update:selected-keys="handleSelect"
selectable
expand-on-click
virtual-scroll
class="file-tree-content"
style="height: 100%"
/>
</n-spin>
</div>
<!-- 新建弹窗 -->
<n-modal
v-model:show="showCreateModal"
preset="dialog"
:title="createType === 'file' ? $gettext('New File') : $gettext('New Folder')"
:positive-text="$gettext('Create')"
:negative-text="$gettext('Cancel')"
:loading="createLoading"
@positive-click="handleCreate"
>
<n-form>
<n-form-item :label="$gettext('Parent Directory')">
<n-input :value="createParentPath" disabled />
</n-form-item>
<n-form-item :label="$gettext('Name')">
<n-input
v-model:value="createName"
:placeholder="
createType === 'file' ? $gettext('Enter file name') : $gettext('Enter folder name')
"
@keyup.enter="handleCreate"
/>
</n-form-item>
</n-form>
</n-modal>
</div>
</template>
<style scoped lang="scss">
.file-tree {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-tree-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid v-bind('themeVars.borderColor');
flex-shrink: 0;
}
.search-input {
flex: 1;
min-width: 0;
}
.current-path {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
font-size: 12px;
color: v-bind('themeVars.textColor3');
border-bottom: 1px solid v-bind('themeVars.borderColor');
flex-shrink: 0;
cursor: pointer;
transition: background-color 0.2s;
&:hover:not(.editing) {
background-color: v-bind('themeVars.buttonColor2Hover');
.edit-icon {
opacity: 1;
}
}
&.editing {
cursor: default;
padding: 4px 8px;
}
.path-icon {
flex-shrink: 0;
color: #f59e0b;
}
.edit-icon {
flex-shrink: 0;
margin-left: auto;
opacity: 0;
transition: opacity 0.2s;
}
:deep(.n-input) {
flex: 1;
}
}
.tree-container {
flex: 1;
overflow: hidden;
min-height: 0;
}
.tree-spin {
height: 100%;
:deep(.n-spin-content) {
height: 100%;
}
}
.file-tree-content {
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as FileEditorView } from './FileEditorView.vue'
export { default as FileTree } from './FileTree.vue'
export { default as EditorPane } from './EditorPane.vue'
export { default as EditorToolbar } from './EditorToolbar.vue'
export { default as EditorStatusBar } from './EditorStatusBar.vue'

View File

@@ -0,0 +1,268 @@
import { languageByPath } from '@/utils/file'
// 打开的文件标签页
export interface EditorTab {
path: string // 文件完整路径
name: string // 文件名
content: string // 文件内容
originalContent: string // 原始内容(用于判断是否修改)
language: string // 语言类型
modified: boolean // 是否已修改
loading: boolean // 是否正在加载
encoding: string // 文件编码
lineEnding: 'LF' | 'CRLF' // 行分隔符
cursorLine: number // 光标行
cursorColumn: number // 光标列
}
// 编辑器设置
export interface EditorSettings {
tabSize: number // 缩进大小
insertSpaces: boolean // 使用空格缩进
wordWrap: 'on' | 'off' | 'wordWrapColumn' | 'bounded' // 自动换行
fontSize: number // 字体大小
minimap: boolean // 是否显示小地图
// 高级设置
lineNumbers: 'on' | 'off' | 'relative' | 'interval' // 行号显示
renderWhitespace: 'none' | 'boundary' | 'selection' | 'trailing' | 'all' // 空白字符显示
cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid' // 光标闪烁
cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin' // 光标样式
smoothScrolling: boolean // 平滑滚动
mouseWheelZoom: boolean // 鼠标滚轮缩放
bracketPairColorization: boolean // 括号配对着色
guides: boolean // 缩进参考线
folding: boolean // 代码折叠
formatOnPaste: boolean // 粘贴时格式化
formatOnType: boolean // 输入时格式化
}
export interface EditorState {
tabs: EditorTab[] // 打开的标签页
activeTabPath: string | null // 当前激活的标签页路径
settings: EditorSettings // 编辑器设置
rootPath: string // 文件树根目录
}
const defaultSettings: EditorSettings = {
tabSize: 4,
insertSpaces: true,
wordWrap: 'on',
fontSize: 14,
minimap: true
}
export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({
tabs: [],
activeTabPath: null,
settings: { ...defaultSettings },
rootPath: '/'
}),
getters: {
// 获取当前激活的标签页
activeTab(): EditorTab | null {
if (!this.activeTabPath) return null
return this.tabs.find((tab) => tab.path === this.activeTabPath) || null
},
// 是否有未保存的文件
hasUnsavedFiles(): boolean {
return this.tabs.some((tab) => tab.modified)
},
// 获取未保存的文件列表
unsavedTabs(): EditorTab[] {
return this.tabs.filter((tab) => tab.modified)
},
// 获取标签页索引
activeTabIndex(): number {
if (!this.activeTabPath) return -1
return this.tabs.findIndex((tab) => tab.path === this.activeTabPath)
}
},
actions: {
// 打开文件(添加标签页)
openFile(path: string, content: string = '', encoding: string = 'utf-8') {
const existingTab = this.tabs.find((tab) => tab.path === path)
if (existingTab) {
// 文件已打开,切换到该标签页
this.activeTabPath = path
return existingTab
}
// 检测行分隔符
const lineEnding = content.includes('\r\n') ? 'CRLF' : 'LF'
// 创建新标签页
const newTab: EditorTab = {
path,
name: path.split('/').pop() || path,
content,
originalContent: content,
language: languageByPath(path),
modified: false,
loading: false,
encoding,
lineEnding,
cursorLine: 1,
cursorColumn: 1
}
this.tabs.push(newTab)
this.activeTabPath = path
return newTab
},
// 关闭标签页
closeTab(path: string) {
const index = this.tabs.findIndex((tab) => tab.path === path)
if (index === -1) return
this.tabs.splice(index, 1)
// 如果关闭的是当前激活的标签页,切换到相邻标签页
if (this.activeTabPath === path) {
if (this.tabs.length === 0) {
this.activeTabPath = null
} else if (index >= this.tabs.length) {
this.activeTabPath = this.tabs[this.tabs.length - 1].path
} else {
this.activeTabPath = this.tabs[index].path
}
}
},
// 关闭所有标签页
closeAllTabs() {
this.tabs = []
this.activeTabPath = null
},
// 关闭其他标签页
closeOtherTabs(path: string) {
this.tabs = this.tabs.filter((tab) => tab.path === path)
this.activeTabPath = path
},
// 关闭已保存的标签页
closeSavedTabs() {
this.tabs = this.tabs.filter((tab) => tab.modified)
if (this.tabs.length === 0) {
this.activeTabPath = null
} else if (!this.tabs.find((tab) => tab.path === this.activeTabPath)) {
this.activeTabPath = this.tabs[0].path
}
},
// 切换标签页
switchTab(path: string) {
if (this.tabs.find((tab) => tab.path === path)) {
this.activeTabPath = path
}
},
// 更新文件内容
updateContent(path: string, content: string) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.content = content
tab.modified = content !== tab.originalContent
}
},
// 标记文件已保存
markSaved(path: string) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.originalContent = tab.content
tab.modified = false
}
},
// 更新光标位置
updateCursor(path: string, line: number, column: number) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.cursorLine = line
tab.cursorColumn = column
}
},
// 更新行分隔符
updateLineEnding(path: string, lineEnding: 'LF' | 'CRLF') {
const tab = this.tabs.find((t) => t.path === path)
if (tab && tab.lineEnding !== lineEnding) {
tab.lineEnding = lineEnding
// 转换内容中的行分隔符
if (lineEnding === 'CRLF') {
tab.content = tab.content.replace(/(?<!\r)\n/g, '\r\n')
} else {
tab.content = tab.content.replace(/\r\n/g, '\n')
}
tab.modified = tab.content !== tab.originalContent
}
},
// 更新编码
updateEncoding(path: string, encoding: string) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.encoding = encoding
}
},
// 更新语言
updateLanguage(path: string, language: string) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.language = language
}
},
// 设置加载状态
setLoading(path: string, loading: boolean) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.loading = loading
}
},
// 更新编辑器设置
updateSettings(settings: Partial<EditorSettings>) {
this.settings = { ...this.settings, ...settings }
},
// 设置根目录
setRootPath(path: string) {
this.rootPath = path
},
// 重新加载文件内容
reloadFile(path: string, content: string) {
const tab = this.tabs.find((t) => t.path === path)
if (tab) {
tab.content = content
tab.originalContent = content
tab.modified = false
tab.lineEnding = content.includes('\r\n') ? 'CRLF' : 'LF'
}
},
// 重新排序标签页
reorderTabs(fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) return
if (fromIndex < 0 || fromIndex >= this.tabs.length) return
if (toIndex < 0 || toIndex >= this.tabs.length) return
const [movedTab] = this.tabs.splice(fromIndex, 1)
this.tabs.splice(toIndex, 0, movedTab)
}
},
persist: {
pick: ['settings', 'rootPath']
}
})

View File

@@ -1,3 +1,4 @@
export * from './editor'
export * from './file'
export * from './permission'
export * from './tab'

View File

@@ -1,38 +1,70 @@
<script setup lang="ts">
import FileEditor from '@/components/common/FileEditor.vue'
import file from '@/api/panel/file'
import { FileEditorView } from '@/components/file-editor'
import { useEditorStore } from '@/store'
import { decodeBase64 } from '@/utils'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const editorStore = useEditorStore()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const file = defineModel<string>('file', { type: String, required: true })
const editor = ref<any>(null)
const filePath = defineModel<string>('file', { type: String, required: true })
const handleRefresh = () => {
editor.value.get()
}
const editorRef = ref<InstanceType<typeof FileEditorView>>()
const handleSave = () => {
editor.value.save()
}
// 获取文件所在目录作为初始路径
const initialPath = computed(() => {
if (!filePath.value) return '/'
const parts = filePath.value.split('/')
parts.pop()
return parts.join('/') || '/'
})
// 打开时自动加载文件
watch(show, (newShow) => {
if (newShow && filePath.value) {
// 暂停文件管理的键盘快捷键
window.$bus.emit('file:keyboard-pause')
// 清空之前的标签页
editorStore.closeAllTabs()
// 设置根目录
editorStore.setRootPath(initialPath.value)
// 打开指定文件
editorStore.openFile(filePath.value, '', 'utf-8')
editorStore.setLoading(filePath.value, true)
useRequest(file.content(encodeURIComponent(filePath.value)))
.onSuccess(({ data }) => {
const content = decodeBase64(data.content)
editorStore.reloadFile(filePath.value, content)
})
.onError(() => {
window.$message.error($gettext('Failed to load file'))
editorStore.closeTab(filePath.value)
})
.onComplete(() => {
editorStore.setLoading(filePath.value, false)
})
} else if (!newShow) {
// 恢复文件管理的键盘快捷键
window.$bus.emit('file:keyboard-resume')
}
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="$gettext('Edit - %{ file }', { file })"
style="width: 60vw"
size="huge"
:title="$gettext('File Editor')"
style="width: 90vw; height: 85vh"
content-style="padding: 0; height: calc(85vh - 60px); display: flex; flex-direction: column;"
:bordered="false"
:segmented="false"
>
<template #header-extra>
<n-flex>
<n-button @click="handleRefresh"> {{ $gettext('Refresh') }} </n-button>
<n-button type="primary" @click="handleSave"> {{ $gettext('Save') }} </n-button>
</n-flex>
</template>
<file-editor ref="editor" :path="file" :read-only="false" />
<FileEditorView ref="editorRef" :initial-path="initialPath" />
</n-modal>
</template>

View File

@@ -906,8 +906,24 @@ const goToParentDir = () => {
path.value = parentPath
}
// 键盘快捷键暂停状态
const keyboardPaused = ref(false)
const pauseKeyboard = () => {
keyboardPaused.value = true
}
const resumeKeyboard = () => {
keyboardPaused.value = false
}
// 键盘快捷键处理
const handleKeyDown = (event: KeyboardEvent) => {
// 如果键盘监听被暂停(如编辑器打开时),不处理快捷键
if (keyboardPaused.value) {
return
}
// 如果焦点在输入框中,不处理快捷键
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
@@ -1116,6 +1132,8 @@ onMounted(() => {
window.$bus.on('file:search', handleFileSearch)
window.$bus.on('file:refresh', refresh)
window.$bus.on('file:keyboard-pause', pauseKeyboard)
window.$bus.on('file:keyboard-resume', resumeKeyboard)
// 添加全局鼠标事件监听
document.addEventListener('mousemove', onSelectionMove)
@@ -1135,6 +1153,8 @@ onUnmounted(() => {
// 移除事件监听
window.$bus.off('file:search', handleFileSearch)
window.$bus.off('file:refresh', refresh)
window.$bus.off('file:keyboard-pause', pauseKeyboard)
window.$bus.off('file:keyboard-resume', resumeKeyboard)
document.removeEventListener('mousemove', onSelectionMove)
document.removeEventListener('mouseup', onSelectionEnd)
document.removeEventListener('keydown', handleKeyDown)