Files
gdt-tool/src/renderer/screens/CreateTemplateScreen.vue
T

802 lines
22 KiB
Vue

<template>
<v-container
fluid
class="pa-0 fill-height"
>
<v-row class="fill-height">
<!-- Left Panel - Template Info -->
<v-col
cols="12"
md="4"
class="pa-4 border-e"
>
<div class="d-flex align-center mb-4">
<h2 class="text-h5 mr-2">
{{ isEditMode ? '编辑模板' : '创建模板' }}
</h2>
<v-spacer />
<v-btn
icon
variant="text"
@click="goBack"
>
<v-icon>mdi-close</v-icon>
<v-tooltip
activator="parent"
location="bottom"
>
关闭
</v-tooltip>
</v-btn>
</div>
<v-form
v-model="formValid"
class="mb-4"
>
<v-text-field
v-model="templateName"
label="模板名称 *"
placeholder="输入模板名称"
:rules="[requiredRule]"
required
clearable
density="comfortable"
/>
<v-textarea
v-model="templateDescription"
label="模板描述"
placeholder="描述这个模板的用途..."
rows="2"
auto-grow
clearable
density="comfortable"
/>
</v-form>
<v-divider class="my-4" />
<!-- Template Actions -->
<div class="d-flex ga-2">
<v-btn
color="primary"
@click="saveTemplate"
:disabled="!formValid || !hasChanges"
prepend-icon="mdi-content-save"
block
>
保存模板
</v-btn>
</div>
</v-col>
<!-- Right Panel - Tree Structure -->
<v-col
cols="12"
md="8"
class="pa-4"
>
<div class="d-flex align-center mb-4">
<h3 class="text-h6 mr-2">模板结构</h3>
<v-spacer />
<v-btn
icon
variant="text"
@click="addRootDirectory"
size="small"
>
<v-icon>mdi-folder-plus</v-icon>
<v-tooltip
activator="parent"
location="bottom"
>
添加根目录
</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
@click="addRootFile"
size="small"
>
<v-icon>mdi-file-plus</v-icon>
<v-tooltip
activator="parent"
location="bottom"
>
添加根文件
</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
@click="expandAll"
size="small"
>
<v-icon>mdi-arrow-expand-down</v-icon>
<v-tooltip
activator="parent"
location="bottom"
>
展开全部
</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
@click="collapseAll"
size="small"
>
<v-icon>mdi-arrow-collapse-up</v-icon>
<v-tooltip
activator="parent"
location="bottom"
>
收起全部
</v-tooltip>
</v-btn>
</div>
<TemplateTree
:items="treeItems"
:height="treeHeight"
v-model:expanded="expandedNodes"
v-model:showContextMenuVar="showContextMenuVar"
:context-menu-x="contextMenuX"
:context-menu-y="contextMenuY"
:current-context-item="currentContextItem"
:empty-text="'暂无文件或目录'"
:read-only="false"
@showContextMenu="showContextMenu"
@add-new-item-to-current-directory="addNewItemToCurrentDirectory"
@edit-file="editFile"
@delete-item="deleteItem"
@add-directory="addDirectory"
@add-file="addFile"
/>
</v-col>
</v-row>
<!-- Add Directory Dialog -->
<v-dialog
v-model="showDirectoryDialog"
max-width="500"
>
<v-card>
<v-card-title>添加目录</v-card-title>
<v-card-text>
<v-form v-model="directoryFormValid">
<v-text-field
v-model="newDirectoryPath"
label="目录路径 *"
placeholder="例如: src/components"
:rules="[requiredRule]"
required
density="comfortable"
ref="directoryInputRef"
@keydown.enter="confirmAddDirectory"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDirectoryDialog = false"> 取消 </v-btn>
<v-btn
color="primary"
@click="confirmAddDirectory"
:disabled="!directoryFormValid"
>
添加
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Add File Dialog -->
<v-dialog
v-model="showFileDialog"
max-width="600"
>
<v-card>
<v-card-title>添加文件</v-card-title>
<v-card-text>
<v-form v-model="fileFormValid">
<v-text-field
v-model="newFilePath"
label="目标文件路径 *"
placeholder="例如: src/index.js"
:rules="[requiredRule]"
required
density="comfortable"
ref="fileInputRef"
@keydown.enter="selectAndAddFile"
/>
<div class="d-flex align-center my-4">
<v-btn
@click="selectSourceFile"
variant="outlined"
class="mr-2"
>
选择文件
</v-btn>
<v-btn
@click="selectSourceFolder"
variant="outlined"
class="mr-2"
>
选择文件夹
</v-btn>
</div>
<!-- Display selected source -->
<div v-if="selectedSourcePath" class="d-flex align-center">
<v-icon :color="isAutoFindMode ? 'warning' : 'info'" class="mr-2">
{{ isAutoFindMode ? 'mdi-folder' : 'mdi-file' }}
</v-icon>
<span class="text-truncate">{{ selectedSourcePath }}</span>
</div>
<!-- Auto-find indicator -->
<v-alert
v-if="isAutoFindMode"
type="info"
variant="tonal"
class="mt-2"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-auto-fix</v-icon>
<span>自动寻找文件夹内的文件</span>
</div>
</v-alert>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showFileDialog = false"> 取消 </v-btn>
<v-btn
color="primary"
@click="selectAndAddFile"
:disabled="!fileFormValid || !selectedSourcePath"
>
添加
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- File Content Editor Dialog -->
<v-dialog
v-model="showEditorDialog"
fullscreen
>
<v-card>
<v-toolbar
dark
color="primary"
>
<v-btn
icon
@click="showEditorDialog = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title> 编辑文件: {{ editingFile?.path }} </v-toolbar-title>
<v-spacer />
<v-btn
variant="text"
@click="showEditorDialog = false"
>
关闭
</v-btn>
</v-toolbar>
<v-card-text class="pa-0 fill-height">
<v-textarea
v-model="editingFileContent"
class="fill-height"
style="height: calc(100vh - 120px)"
no-resize
spellcheck="false"
placeholder="在此输入文件内容..."
variant="solo-filled"
density="comfortable"
/>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar for error messages -->
<v-snackbar
v-model="snackbarVisible"
:color="snackbarColor"
:timeout="2000"
location="top"
>
{{ snackbarMessage }}
<template #actions>
<v-btn
icon
@click="snackbarVisible = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTemplateStore } from '@/renderer/store/template'
import { Template } from '@/renderer/store/template'
import TemplateTree from '@/renderer/components/TemplateTree.vue'
const router = useRouter()
const route = useRoute()
const templateStore = useTemplateStore()
// Extract methods from store
const { getTemplateById } = templateStore
// Form state
const formValid = ref(false)
const templateName = ref('')
const templateDescription = ref('')
const hasChanges = ref(false)
// Dialogs
const showDirectoryDialog = ref(false)
const showFileDialog = ref(false)
const showEditorDialog = ref(false)
const directoryFormValid = ref(false)
const fileFormValid = ref(false)
// Input refs for auto-focus
const directoryInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
// Context menu
const showContextMenuVar = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const currentContextItem = ref<TreeItem | null>(null)
// Snackbar variables
const snackbarVisible = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('error') // Can be 'error', 'success', 'warning', etc.
// New item state
const newDirectoryPath = ref('')
const newFilePath = ref('')
const editingFile = ref<{ path: string; content: string } | null>(null)
const editingFileContent = ref('')
const parentDirectoryForNewItems = ref<string | null>(null) // Track parent context
const selectedSourcePath = ref<string>('') // Track the selected source file or folder
const isAutoFindMode = ref(false) // Track if auto-find mode is enabled
// Tree state
const expandedNodes = ref<string[]>([])
const treeHeight = ref(600)
// Define type for tree items
interface TreeItem {
id: string
name: string
type: 'directory' | 'file'
children?: TreeItem[]
}
// Item state
const directories = ref<{ path: string }[]>([])
const files = ref<{ path: string; content?: string; sourceFilePath?: string }[]>([])
// Computed
const isEditMode = computed(() => !!route.params.id)
const treeItems = computed<TreeItem[]>(() => {
// Create root structure
const root: TreeItem = {
id: 'root',
name: 'root',
type: 'directory',
children: []
}
// Process each directory
directories.value.forEach((dir) => {
const pathParts = dir.path.split('/').filter(part => part !== '') // Split path and remove empty parts
let currentLevel = root.children || []
// Navigate/create the directory structure
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]
const isLastPart = i === pathParts.length - 1
// Check if this part already exists in the current level
let existingItem = currentLevel.find(item => item.name === part)
if (!existingItem) {
// Create new directory item
const newDir: TreeItem = {
id: `dir-${part}-${Date.now()}-${Math.random()}`, // Unique ID
name: part,
type: 'directory',
children: []
}
currentLevel.push(newDir)
existingItem = newDir
}
// Move to the next level if not the last part
if (!isLastPart && existingItem.children) {
currentLevel = existingItem.children
}
}
})
// Process each file and add to the appropriate directory
files.value.forEach((file) => {
const pathParts = file.path.split('/').filter(part => part !== '')
const fileName = pathParts.pop() // Get the last part as filename
if (!fileName) return // Skip if no filename
let currentLevel = root.children || []
// Navigate to the appropriate directory for the file
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]
const dirItem = currentLevel.find(item => item.name === part && item.type === 'directory')
if (dirItem && dirItem.children) {
currentLevel = dirItem.children
} else {
// If the directory doesn't exist, create it
const newDir: TreeItem = {
id: `dir-${part}-${Date.now()}-${Math.random()}`,
name: part,
type: 'directory',
children: []
}
currentLevel.push(newDir)
currentLevel = newDir.children || []
}
}
// Add the file to the current level
currentLevel.push({
id: `file-${fileName}-${Date.now()}-${Math.random()}`,
name: fileName,
type: 'file'
})
})
return root.children || []
})
// Validation rules
const requiredRule = (v: string | null | undefined) => !!v || '此字段为必填项'
// Watch for changes to detect unsaved changes
watch(
[templateName, templateDescription, directories, files],
() => {
hasChanges.value = true
},
{ deep: true }
)
// Lifecycle
onMounted(async () => {
await templateStore.loadTemplates()
// Set tree height based on window height
updateTreeHeight()
// Add resize listener
window.addEventListener('resize', updateTreeHeight)
// Handle edit mode
if (isEditMode.value) {
const templateId = route.params.id as string
const template = getTemplateById(templateId)
if (template) {
templateName.value = template.name
templateDescription.value = template.description
directories.value = [...template.directories]
files.value = [...template.files]
hasChanges.value = false
} else {
router.push('/templates')
}
}
// Expand all nodes by default
expandAll()
})
onUnmounted(() => {
window.removeEventListener('resize', updateTreeHeight)
})
// Methods
const updateTreeHeight = () => {
treeHeight.value = window.innerHeight - 200
}
const showContextMenu = (event: MouseEvent, item: TreeItem | null) => {
event.preventDefault()
contextMenuX.value = event.clientX
contextMenuY.value = event.clientY
currentContextItem.value = item
showContextMenuVar.value = false // Close first to reset position
nextTick(() => {
contextMenuX.value = event.clientX
contextMenuY.value = event.clientY
showContextMenuVar.value = true
})
}
const addRootDirectory = () => {
newDirectoryPath.value = ''
showDirectoryDialog.value = true
autoFocusDirectoryInput()
}
const addRootFile = () => {
newFilePath.value = ''
showFileDialog.value = true
autoFocusFileInput()
}
const addDirectory = (parentItem: TreeItem | null = null) => {
showContextMenuVar.value = false
currentContextItem.value = parentItem
parentDirectoryForNewItems.value = parentItem ? parentItem.name : null // Store parent context
newDirectoryPath.value = '' // Don't pre-fill the path
showDirectoryDialog.value = true
autoFocusDirectoryInput()
}
const addFile = (parentItem: TreeItem | null = null) => {
showContextMenuVar.value = false
parentDirectoryForNewItems.value = parentItem ? parentItem.name : null // Store parent context
newFilePath.value = '' // Don't pre-fill the path
showFileDialog.value = true
autoFocusFileInput()
}
const addNewItemToCurrentDirectory = (directoryItem: TreeItem) => {
currentContextItem.value = directoryItem
parentDirectoryForNewItems.value = directoryItem.name // Store parent context
newDirectoryPath.value = '' // Don't pre-fill the path
showDirectoryDialog.value = true
autoFocusDirectoryInput()
}
const confirmAddDirectory = () => {
if (!newDirectoryPath.value.trim()) return
// Construct the full path if there's a parent directory
let fullPath = newDirectoryPath.value
if (parentDirectoryForNewItems.value) {
// Make sure parent directory ends with a slash before appending
const parentPath = parentDirectoryForNewItems.value.endsWith('/')
? parentDirectoryForNewItems.value
: parentDirectoryForNewItems.value + '/'
fullPath = parentPath + newDirectoryPath.value
}
// Check for duplicates
const exists = directories.value.some(
(d) => d.path === fullPath
)
if (exists) {
showSnackbar('该目录路径已存在!')
return
}
directories.value.push({
path: fullPath
})
showDirectoryDialog.value = false
newDirectoryPath.value = ''
parentDirectoryForNewItems.value = null // Clear parent context after creation
currentContextItem.value = null
}
const selectAndAddFile = async () => {
if (!newFilePath.value.trim()) return
// Construct the full path if there's a parent directory
let fullPath = newFilePath.value
if (parentDirectoryForNewItems.value) {
// Make sure parent directory ends with a slash before appending
const parentPath = parentDirectoryForNewItems.value.endsWith('/')
? parentDirectoryForNewItems.value
: parentDirectoryForNewItems.value + '/'
fullPath = parentPath + newFilePath.value
}
// Check for duplicates
const exists = files.value.some((f) => f.path === fullPath)
if (exists) {
showSnackbar('该文件路径已存在!')
return
}
// Check if we're in auto-find mode and have a selected folder
if (isAutoFindMode.value && selectedSourcePath.value) {
// Auto-find files in the selected folder
const foundFiles = await window.mainApi.invoke('msgFindFilesInFolder', selectedSourcePath.value)
// Add all found files as template files
for (const foundFile of foundFiles) {
// Calculate relative path from selected folder to the found file
const relativePath = foundFile.replace(selectedSourcePath.value, '').substring(1) // Remove leading slash
const fullTargetPath = fullPath ? `${fullPath}/${relativePath}` : relativePath
// Check for duplicates
const duplicate = files.value.some(f => f.path === fullTargetPath)
if (!duplicate) {
const newFile = {
path: fullTargetPath,
sourceFilePath: foundFile
}
files.value.push(newFile)
}
}
// Reset selection state
selectedSourcePath.value = ''
isAutoFindMode.value = false
showFileDialog.value = false
newFilePath.value = ''
parentDirectoryForNewItems.value = null // Clear parent context after creation
currentContextItem.value = null
return
}
// If we have a selected file, just add it
if (selectedSourcePath.value) {
const newFile = {
path: fullPath,
sourceFilePath: selectedSourcePath.value
}
files.value.push(newFile)
// Reset selection state
selectedSourcePath.value = ''
isAutoFindMode.value = false
showFileDialog.value = false
newFilePath.value = ''
parentDirectoryForNewItems.value = null // Clear parent context after creation
currentContextItem.value = null
return
}
// Otherwise, show file selection dialog
showSnackbar('请先选择一个文件或文件夹', 'warning')
}
const goBack = () => {
if (hasChanges.value) {
if (confirm('您有未保存的更改,确定要离开吗?')) {
router.push('/templates')
}
} else {
router.push('/templates')
}
}
const expandAll = () => {
expandedNodes.value = treeItems.value.map((item) => item.id)
}
const collapseAll = () => {
expandedNodes.value = []
}
const editFile = (item: TreeItem) => {
// For now, just show a message that files are selected, not edited
showSnackbar('该文件是从源文件复制的,无法直接编辑', 'info')
}
const deleteItem = (item: TreeItem) => {
if (!confirm('确定要删除此项吗?')) return
if (item.type === 'directory') {
directories.value = directories.value.filter(
(d) => `dir-${d.path}` !== item.id
)
} else {
files.value = files.value.filter((f) => `file-${f.path}` !== item.id)
}
}
// Show snackbar message
const showSnackbar = (message: string, color: string = 'error') => {
snackbarMessage.value = message
snackbarColor.value = color
snackbarVisible.value = true
}
// Methods for file/folder selection
const selectSourceFile = async () => {
const result = await window.mainApi.invoke('msgOpenFile', 'any')
if (!result.canceled && result.filePaths && result.filePaths.length > 0) {
selectedSourcePath.value = result.filePaths[0]
isAutoFindMode.value = false // Not in auto-find mode when selecting a specific file
}
}
const selectSourceFolder = async () => {
const result = await window.mainApi.invoke('msgSelectDirectory')
if (!result.canceled && result.filePath) {
selectedSourcePath.value = result.filePath
isAutoFindMode.value = true // In auto-find mode when selecting a folder
}
}
// Auto focus methods
const autoFocusDirectoryInput = async () => {
await nextTick()
if (directoryInputRef.value) {
;(directoryInputRef.value as HTMLInputElement).focus()
}
}
const autoFocusFileInput = async () => {
await nextTick()
if (fileInputRef.value) {
;(fileInputRef.value as HTMLInputElement).focus()
}
}
const saveTemplate = () => {
if (!formValid.value) return
const templateData = {
name: templateName.value,
description: templateDescription.value,
files: files.value,
directories: directories.value
}
if (isEditMode.value) {
const templateId = route.params.id as string
templateStore.updateTemplate({
id: templateId,
...templateData
})
} else {
templateStore.addTemplate(templateData)
}
hasChanges.value = false
router.push('/templates')
}
</script>
<style scoped>
.border-e {
border-right: 1px solid rgba(0, 0, 0, 0.12);
}
.fill-height {
height: 100%;
}
.cursor-pointer {
cursor: pointer;
}
</style>