This commit is contained in:
wfz
2026-01-20 19:43:40 +08:00
parent 378fb65c76
commit ad2322b553
9 changed files with 1650 additions and 9 deletions

20
vue-template-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Logs
logs
*.log
npm-debug.log*
# Dependencies
node_modules
# Build output
dist
# Editor directories and files
.vscode
.idea
.DS_Store
*.suo
*.sw?
# OS files
Thumbs.db

1067
vue-template-service/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "vue-template-service",
"version": "1.0.0",
"description": "Vue模板源码修改服务",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"@vue/compiler-sfc": "^3.4.0"
},
"devDependencies": {
"@types/node": "^20.10.0"
}
}

View File

@@ -0,0 +1,134 @@
/**
* Vue模板源码修改服务
*
* 接收前端拖放请求修改Vue文件中的template结构
*/
import express from 'express'
import cors from 'cors'
import { moveElement } from './services/templateService.js'
import { readFile, writeFile } from 'fs/promises'
import path from 'path'
const app = express()
const PORT = 3001
// 中间件
app.use(cors())
app.use(express.json())
// Vue源码目录相对于服务根目录
const VUE_SOURCE_DIR = path.resolve('../draggable-panels/src/views')
/**
* API: 执行元素移动操作
*
* POST /api/move-element
* Body: {
* pagePath: string, // Vue文件路径相对于views目录
* source: { type, path, elementType },
* targetPath: string,
* targetType: string,
* direction: 'top' | 'bottom' | 'left' | 'right'
* }
*/
app.post('/api/move-element', async (req, res) => {
try {
const { pagePath, source, targetPath, targetType, direction } = req.body
console.log('[API] 收到移动请求:', {
pagePath,
source: source.path,
target: targetPath,
direction
})
// 验证参数
if (!pagePath || !source?.path || !targetPath || !direction) {
return res.status(400).json({
success: false,
error: '缺少必要参数'
})
}
// 构建完整文件路径
// 前端传来的路径可能是多种格式:
// - "../../../views/TestPage1.vue"
// - "../views/xxx.vue"
// - "./views/xxx.vue"
// - "TestPage1.vue"
// 需要提取 views/ 后面的部分
let normalizedPath = pagePath
// 查找 views/ 的位置,取其后面的内容
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6) // 6 = 'views/'.length
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
console.log('[API] 原始路径:', pagePath)
console.log('[API] 规范化路径:', normalizedPath)
console.log('[API] 完整路径:', filePath)
// 读取Vue文件
let vueContent
try {
vueContent = await readFile(filePath, 'utf-8')
} catch (err) {
return res.status(404).json({
success: false,
error: `文件不存在: ${pagePath}`
})
}
// 执行移动操作
const result = moveElement(vueContent, {
sourcePath: source.path,
targetPath,
direction
})
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
})
}
// 写回文件
await writeFile(filePath, result.content, 'utf-8')
console.log('[API] 移动成功')
res.json({
success: true,
message: `已将 ${source.path} 移动到 ${targetPath}${direction}方向`
})
} catch (error) {
console.error('[API] 错误:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
/**
* API: 获取服务状态
*/
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'vue-template-service',
sourceDir: VUE_SOURCE_DIR
})
})
// 启动服务
app.listen(PORT, () => {
console.log(`🚀 Vue模板服务启动: http://localhost:${PORT}`)
console.log(`📁 源码目录: ${VUE_SOURCE_DIR}`)
})

View File

@@ -0,0 +1,305 @@
/**
* Vue模板解析和修改服务
*
* 使用 @vue/compiler-sfc 解析Vue文件
* 使用 @vue/compiler-dom 解析template获取AST
* 通过AST的位置信息定位元素使用字符串操作移动元素保持原始格式
*/
import { parse as parseSFC } from '@vue/compiler-sfc'
import { parse as parseTemplate } from '@vue/compiler-dom'
/**
* 解析路径字符串为路径节点数组
* 例如: "r1c2r1" => [{type: 'r', index: 1}, {type: 'c', index: 2}, {type: 'r', index: 1}]
*/
function parsePath(pathStr) {
const nodes = []
const regex = /([rc])(\d+)/g
let match
while ((match = regex.exec(pathStr)) !== null) {
nodes.push({
type: match[1], // 'r' 或 'c'
index: parseInt(match[2], 10)
})
}
return nodes
}
/**
* 判断节点是否为el-row
*/
function isElRow(node) {
return node.type === 1 && node.tag === 'el-row'
}
/**
* 判断节点是否为el-col
*/
function isElCol(node) {
return node.type === 1 && node.tag === 'el-col'
}
/**
* 根据路径在AST中查找元素
* @param {Object} ast - Vue template AST
* @param {string} pathStr - 路径字符串,如 "r1c2"
* @returns {Object|null} - 找到的AST节点
*/
function findElementByPath(ast, pathStr) {
const pathNodes = parsePath(pathStr)
if (pathNodes.length === 0) return null
// 从template的子节点开始查找
let currentChildren = ast.children
let currentNode = null
for (const pathNode of pathNodes) {
const targetType = pathNode.type === 'r' ? 'el-row' : 'el-col'
const targetIndex = pathNode.index
// 在当前层级查找第N个目标类型的元素
let count = 0
let found = false
for (const child of currentChildren) {
if (child.type !== 1) continue // 跳过非元素节点(文本、注释等)
if (child.tag === targetType) {
count++
if (count === targetIndex) {
currentNode = child
currentChildren = child.children || []
found = true
break
}
}
}
if (!found) {
console.log(`[findElementByPath] 未找到: ${targetType} #${targetIndex}`)
return null
}
}
return currentNode
}
/**
* 获取节点在源码中的完整文本
*/
function getNodeSourceText(source, node) {
if (!node.loc) return null
const start = node.loc.start.offset
const end = node.loc.end.offset
return source.substring(start, end)
}
/**
* 移动元素
*
* @param {string} vueContent - 完整的Vue文件内容
* @param {Object} options - 移动选项
* @param {string} options.sourcePath - 源元素路径,如 "r1c1"
* @param {string} options.targetPath - 目标元素路径,如 "r1c2"
* @param {string} options.direction - 移动方向: 'top' | 'bottom' | 'left' | 'right'
* @returns {Object} - { success: boolean, content?: string, error?: string }
*/
export function moveElement(vueContent, options) {
const { sourcePath, targetPath, direction } = options
try {
// 1. 解析Vue SFC文件
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return {
success: false,
error: `Vue文件解析错误: ${sfcResult.errors[0].message}`
}
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return {
success: false,
error: '未找到template块'
}
}
const templateContent = templateBlock.content
// 找到 <template> 内容在原文件中的实际位置
// templateBlock.loc 指向整个 <template>...</template> 块
// 我们需要找到内容的起始和结束位置
const templateTagStart = vueContent.indexOf('<template')
const templateTagEnd = vueContent.indexOf('>', templateTagStart) + 1
const templateCloseStart = vueContent.indexOf('</template>', templateTagEnd)
// template内容在原文件中的位置
const contentStart = templateTagEnd
const contentEnd = templateCloseStart
// 2. 解析template获取AST
const templateAST = parseTemplate(templateContent, {
// 保留注释
comments: true,
// 保留空白(用于保持格式)
whitespace: 'preserve'
})
// 3. 查找源元素和目标元素
const sourceNode = findElementByPath(templateAST, sourcePath)
const targetNode = findElementByPath(templateAST, targetPath)
if (!sourceNode) {
return {
success: false,
error: `未找到源元素: ${sourcePath}`
}
}
if (!targetNode) {
return {
success: false,
error: `未找到目标元素: ${targetPath}`
}
}
// 4. 获取元素在template中的位置
const sourceStart = sourceNode.loc.start.offset
const sourceEnd = sourceNode.loc.end.offset
const sourceText = templateContent.substring(sourceStart, sourceEnd)
const targetStart = targetNode.loc.start.offset
const targetEnd = targetNode.loc.end.offset
console.log(`[moveElement] 源: ${sourcePath} [${sourceStart}-${sourceEnd}]`)
console.log(`[moveElement] 目标: ${targetPath} [${targetStart}-${targetEnd}]`)
console.log(`[moveElement] 方向: ${direction}`)
// 5. 计算插入位置和新内容
let newTemplateContent
// 检测源元素前面的空白(用于保持缩进)
const sourceLineStart = templateContent.lastIndexOf('\n', sourceStart - 1) + 1
const sourceIndent = templateContent.substring(sourceLineStart, sourceStart)
// 检测目标元素的缩进
const targetLineStart = templateContent.lastIndexOf('\n', targetStart - 1) + 1
const targetIndent = templateContent.substring(targetLineStart, targetStart)
// 判断是否需要先删除源,再插入
// 关键:需要考虑源和目标的相对位置
if (sourceStart < targetStart) {
// 源在目标前面:先处理目标位置,再删除源
const insertPosition = (direction === 'bottom' || direction === 'right')
? targetEnd
: targetStart
// 构建新内容
let parts = []
// 删除源元素(包括前后的空白)
const beforeSource = templateContent.substring(0, sourceLineStart)
const afterSource = templateContent.substring(sourceEnd)
// 合并时跳过源元素后的换行
let afterSourceTrimmed = afterSource
if (afterSource.startsWith('\n')) {
afterSourceTrimmed = afterSource.substring(1)
}
const withoutSource = beforeSource + afterSourceTrimmed
// 计算新的目标位置(因为删除了源,位置会变化)
const removedLength = templateContent.length - withoutSource.length
const newInsertPosition = insertPosition - removedLength
// 在新位置插入源元素
const insertText = (direction === 'bottom' || direction === 'right')
? '\n' + targetIndent + sourceText
: sourceText + '\n' + targetIndent
newTemplateContent =
withoutSource.substring(0, newInsertPosition) +
insertText +
withoutSource.substring(newInsertPosition)
} else {
// 源在目标后面:先插入,再删除
const insertPosition = (direction === 'bottom' || direction === 'right')
? targetEnd
: targetStart
// 先在目标位置插入
const insertText = (direction === 'bottom' || direction === 'right')
? '\n' + targetIndent + sourceText
: sourceText + '\n' + targetIndent
const withInsert =
templateContent.substring(0, insertPosition) +
insertText +
templateContent.substring(insertPosition)
// 计算源元素新位置(因为插入了内容,位置会变化)
const insertedLength = insertText.length
const newSourceStart = sourceLineStart + insertedLength
const newSourceEnd = sourceEnd + insertedLength
// 删除源元素
const beforeNewSource = withInsert.substring(0, newSourceStart)
const afterNewSource = withInsert.substring(newSourceEnd)
// 跳过换行
let afterTrimmed = afterNewSource
if (afterTrimmed.startsWith('\n')) {
afterTrimmed = afterTrimmed.substring(1)
}
newTemplateContent = beforeNewSource + afterTrimmed
}
// 6. 重建Vue文件保留 <template> 标签,只替换内容
const beforeContent = vueContent.substring(0, contentStart)
const afterContent = vueContent.substring(contentEnd)
const newVueContent = beforeContent + newTemplateContent + afterContent
console.log('[moveElement] 文件更新成功')
return {
success: true,
content: newVueContent
}
} catch (error) {
console.error('[moveElement] 错误:', error)
return {
success: false,
error: error.message
}
}
}
/**
* 获取template的AST用于调试
*/
export function getTemplateAST(vueContent) {
const sfcResult = parseSFC(vueContent)
if (!sfcResult.descriptor.template) {
return null
}
const templateContent = sfcResult.descriptor.template.content
return parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
}