修改编辑器和树的显示

This commit is contained in:
Qiubo Huang 2026-02-25 14:43:32 +08:00
parent 394cb3cc18
commit bbcb734f72
3 changed files with 710 additions and 78 deletions

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { asBlob } from 'html-docx-js-typescript'
import { saveAs } from 'file-saver'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
// Extensions used in content.vue
@ -275,6 +277,296 @@ const convertTableToSQL = () => {
sqlContent.value = sqlText
}
// ========== Word ==========
const exportToWord = async () => {
if (!editor.value) return
const htmlContent = editor.value.getHTML()
// Word (High Fidelity for Word)
// 使 pt Word
const exportCSS = `
body {
font-family: 'SimSun', 'Times New Roman', serif;
font-size: 10.5pt; /* 五号字 */
line-height: 1.5; /* 1.5倍行距 */
}
h1, h2, h3, h4, h5, h6 {
font-family: 'SimHei', 'Arial', sans-serif; /* 标题用黑体 */
font-weight: bold;
margin-top: 12pt;
margin-bottom: 6pt;
}
h1 { font-size: 16pt; } /* 三号 */
h2 { font-size: 15pt; } /* 小三 */
h3 { font-size: 14pt; } /* 四号 */
p {
margin: 0 0 6pt 0;
}
table {
border-collapse: collapse;
/* width: 100%; REMOVED: Allow absolute pixel widths */
table-layout: fixed; /* 强制固定布局,防止 Word 自动缩列宽 */
word-wrap: break-word; /* 防止长单词撑开 */
margin-bottom: 12pt;
}
th, td {
border: 1px solid #000000; /* 纯黑边框 */
padding: 5pt;
vertical-align: top;
font-size: 10.5pt;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
img {
max-width: 100%;
height: auto;
}
ul, ol {
margin: 0 0 6pt 0;
padding-left: 18pt;
}
`
// HTML
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
${exportCSS}
</style>
</head>
<body>
<body>
${(() => {
// 使 DOMParser HTML
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
// 1.
doc.querySelectorAll('p').forEach(p => {
if (p.textContent?.trim() === '' && p.children.length === 0) {
p.innerHTML = '&nbsp;'
}
})
// 2. (pt -> px for export lib)
// Input 12pt -> /0.75 -> 16px. Library sees 16px -> converts to 12pt for Word.
doc.querySelectorAll('*[style]').forEach(el => {
const style = el.getAttribute('style')
if (style?.includes('font-size')) {
const newStyle = style.replace(/font-size:\s*(\d+(\.\d+)?)pt/gi, (match, val) => {
const px = parseFloat(val) / 0.75
return `font-size: ${px}px`
})
el.setAttribute('style', newStyle)
}
})
// 3. (Scaling Factor 0.75)
// Editor (96dpi) vs Word (72dpi)
// 400px in Editor = 4.16in.
// 4.16in in Word (72pt/in) = 300pt.
// So convert 400 -> 300. Factor = 0.75.
doc.querySelectorAll('img').forEach(img => {
// style width
let widthVal = 0
// style img.style DOMParser style
const styleStr = img.getAttribute('style') || ''
const widthMatch = styleStr.match(/width:\s*(\d+)px/)
if (widthMatch) {
widthVal = parseInt(widthMatch[1])
} else if (img.getAttribute('width')) {
widthVal = parseInt(img.getAttribute('width') || '0')
}
if (widthVal > 0) {
// 0.75 1.0 () html-docx-js Word 1px = 1px (96dpi)
// 1px (editor) = 1pt (word)
const scaleFactor = 1.0
const scaledWidth = Math.round(widthVal * scaleFactor)
img.setAttribute('width', scaledWidth.toString())
img.style.width = `${scaledWidth}px`
//
let heightVal = 0
const heightMatch = styleStr.match(/height:\s*(\d+)px/)
if (heightMatch) {
heightVal = parseInt(heightMatch[1])
} else if (img.getAttribute('height')) {
const h = img.getAttribute('height') || ''
if (h && h !== 'auto') {
heightVal = parseInt(h)
}
}
if (heightVal > 0) {
const scaledHeight = Math.round(heightVal * scaleFactor)
img.setAttribute('height', scaledHeight.toString())
img.style.height = `${scaledHeight}px`
} else {
// auto Word
img.style.height = 'auto'
img.removeAttribute('height')
}
}
})
// 4.
// Word style="width: 600px" width="600"
doc.querySelectorAll('table').forEach(table => {
// Table
let tableWidth = 0
const styleStr = table.getAttribute('style') || ''
const widthMatch = styleStr.match(/width:\s*(\d+)px/)
if (widthMatch) {
tableWidth = parseInt(widthMatch[1])
}
if (tableWidth > 0) {
table.setAttribute('width', tableWidth.toString())
// style width ?
// table.style.width = ''
} else {
// pixel width 600 (A4 content width)?
// "width greatly reduced" auto
// insertTable style attribute
}
// TD/TH
table.querySelectorAll('td, th').forEach(cell => {
let cellWidth = 0
const cellStyle = cell.getAttribute('style') || ''
const cellWidthMatch = cellStyle.match(/width:\s*(\d+)px/)
if (cellWidthMatch) {
cellWidth = parseInt(cellWidthMatch[1])
}
// Tiptap resizable columns use data-colwidth
if (cellWidth === 0) {
const colwidth = cell.getAttribute('data-colwidth')
if (colwidth) {
// data-colwidth="100" or "[100]"
const match = colwidth.match(/(\d+)/)
if (match) cellWidth = parseInt(match[1])
}
}
if (cellWidth > 0) {
cell.setAttribute('width', cellWidth.toString())
// IMPORTANT: clean up style width if it exists to avoid conflicts?
// actually keeping style width is fine, but attribute width is key for Word table-layout:fixed
}
})
// 5. colgroup Word
// Tiptap colgroup cell
const firstRow = table.querySelector('tr')
if (firstRow) {
const cells = firstRow.querySelectorAll('td, th')
let colgroupHtml = '<colgroup>'
let hasExplicitWidth = false
cells.forEach(c => {
const cell = c as HTMLElement
let width = 0
const colwidth = cell.getAttribute('data-colwidth')
if (colwidth && colwidth.match(/(\d+)/)) {
width = parseInt(colwidth.match(/(\d+)/)![1])
} else {
// [NEW] Check for 'colwidth' attribute
const rawColwidth = cell.getAttribute('colwidth')
if (rawColwidth) {
const match = rawColwidth.match(/(\d+)/)
if (match) width = parseInt(match[1])
} else if (cell.style.width) {
width = parseInt(cell.style.width) || 0
}
}
if (width > 0) {
// Word HTML behavior:
// width="120" might be interpreted as "120/50 inch" (twips) or similar?
// User feedback: "px became too wide". Reverting to 0.75 scaling (pt).
// 43px * 0.75 = 32.25pt.
const ptWidth = width * 0.75
colgroupHtml += `<col width="${ptWidth}pt">`
hasExplicitWidth = true
} else {
colgroupHtml += '<col>'
}
})
colgroupHtml += '</colgroup>'
// colgroup
if (hasExplicitWidth) {
// colgroup
const oldColgroup = table.querySelector('colgroup')
if (oldColgroup) {
oldColgroup.remove()
}
//
table.insertAdjacentHTML('afterbegin', colgroupHtml)
}
// [NEW] Force apply width to first row cells (TD/TH) because Word/html-docx-js might ignore colgroup
// Apply both width attribute (pt) and style width (pt)
// Apply both width attribute (pt) and style width (pt)
cells.forEach((c) => {
const cell = c as HTMLElement
let width = 0
const colwidth = cell.getAttribute('data-colwidth')
if (colwidth && colwidth.match(/(\d+)/)) {
width = parseInt(colwidth.match(/(\d+)/)![1])
} else {
// [NEW] Check for 'colwidth' attribute
const rawColwidth = cell.getAttribute('colwidth')
if (rawColwidth) {
const match = rawColwidth.match(/(\d+)/)
if (match) width = parseInt(match[1])
} else if (cell.style.width) {
width = parseInt(cell.style.width) || 0
}
}
if (width > 0) {
// Reverting to pt (0.75 scaling)
const ptWidth = width * 0.75
cell.style.width = `${ptWidth}pt`
cell.setAttribute('width', `${ptWidth}pt`)
}
})
}
})
return doc.body.innerHTML
})()}
</body>
</html>
`
try {
const buffer = await asBlob(fullHtml, {
orientation: 'portrait',
margins: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // TWIPS units (1440 = 1 inch)
})
saveAs(buffer, `export_${new Date().toISOString().slice(0, 10)}.docx`)
} catch (error) {
console.error('Export failed:', error)
alert('导出失败,请检查控制台错误信息。')
}
}
const currentDisplayContent = computed(() => {
if (displayMode.value === 'json') return jsonContent.value
if (displayMode.value === 'html') return htmlContent.value
@ -462,16 +754,30 @@ const insertTable = () => {
const inputRows = tableRows.value
// HTML
let tableHtml = '<table><thead><tr>'
// A4 Content Width: ~600px (15.92cm)
// Use explicit pixel widths for table and columns
const tableWidth = 600
const colWidth = Math.floor(tableWidth / 5) // 120px each
let tableHtml = `<table style="width: ${tableWidth}px">`
// Add colgroup for better Tiptap resizing support
tableHtml += '<colgroup>'
for(let k=0; k<5; k++) {
tableHtml += `<col style="width: ${colWidth}px">`
}
tableHtml += '</colgroup>'
tableHtml += '<thead><tr>'
fixedHeaders.forEach(header => {
tableHtml += `<th><p>${header}</p></th>`
tableHtml += `<th style="width: ${colWidth}px"><p>${header}</p></th>`
})
tableHtml += '</tr></thead><tbody>'
for (let i = 0; i < inputRows; i++) {
tableHtml += '<tr>'
for (let j = 0; j < 5; j++) {
tableHtml += '<td><p></p></td>'
tableHtml += `<td style="width: ${colWidth}px"><p></p></td>`
}
tableHtml += '</tr>'
}
@ -501,6 +807,9 @@ const insertTable = () => {
<button class="action-btn convert-btn" @click="convertTableToSQL">
表格转 SQL
</button>
<button class="action-btn export-btn" @click="exportToWord">
导出 Word
</button>
</div>
<!-- 选中节点操作栏 -->
@ -547,15 +856,20 @@ const insertTable = () => {
</select>
<select class="toolbar-select" @change="setFontSize" title="字号" style="width: 70px;">
<option value="" selected>默认</option>
<option value="12px">12px</option>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="30px">30px</option>
<option value="36px">36px</option>
<option value="48px">48px</option>
<option value="9pt">9pt</option>
<option value="10pt">10pt</option>
<option value="10.5pt">10.5pt (五号)</option>
<option value="11pt">11pt</option>
<option value="12pt">12pt (小四)</option>
<option value="14pt">14pt (四号)</option>
<option value="15pt">15pt (小三)</option>
<option value="16pt">16pt (三号)</option>
<option value="18pt">18pt (小二)</option>
<option value="22pt">22pt (二号)</option>
<option value="24pt">24pt (小一)</option>
<option value="26pt">26pt (一号)</option>
<option value="36pt">36pt</option>
<option value="42pt">42pt</option>
</select>
<button class="toolbar-btn" @click="clearFormat" title="清除格式">
<Eraser :size="16" />
@ -743,7 +1057,14 @@ const insertTable = () => {
flex-direction: column;
}
.tiptap-editor {
overflow-y: auto;
flex: 1; /* Occupy remaining vertical space */
overflow: auto; /* Scroll the "desk" */
display: flex;
justify-content: center; /* Center the page horizontally */
align-items: flex-start; /* Don't stretch page to full height - let it grow naturally */
padding: 32px 0; /* Vertical breathing room */
background-color: #f0f0f0; /* Desk color */
cursor: text;
}
.button-group {
display: flex;
@ -817,7 +1138,7 @@ const insertTable = () => {
.editor-container {
border: 1px solid #d0d0d0;
border-radius: 6px;
overflow: hidden;
overflow: hidden; /* Allow content to flow, removed hidden */
display: flex;
flex-direction: column;
flex: 1;
@ -828,27 +1149,56 @@ const insertTable = () => {
border: none;
}
/* TipTap 编辑器样式 */
/* TipTap 编辑器样式 - A4 Paper Simulation */
:deep(.tiptap) {
flex: 1;
padding: 16px;
/* Paper Dimensions (A4) */
width: 21cm;
min-height: 29.7cm;
/* Paper Appearance */
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #d0d0d0;
/* Layout & Spacing */
margin: 0 auto; /* Centered in flex container */
padding: 2.54cm; /* 1 inch margins */
/* Behavior */
outline: none;
overflow-y: auto;
flex: none; /* Don't stretch */
overflow: visible; /* Let content grow */
/* WYSIWYG Styling - Matching Export CSS */
font-family: 'SimSun', 'Times New Roman', serif;
font-size: 14px; /* Approx 10.5pt */
line-height: 1.5;
}
:deep(.tiptap p) {
margin: 0 0 12px 0;
line-height: 1.6;
}
:deep(.tiptap p:last-child) {
margin-bottom: 0;
margin: 0 0 8px 0; /* Approx 6pt */
line-height: 1.5;
}
:deep(.tiptap h1) {
font-size: 28px;
font-family: 'SimHei', 'Arial', sans-serif;
font-size: 21px; /* Approx 16pt */
font-weight: 600;
margin: 0 0 16px 0;
margin: 16px 0 8px 0; /* Approx 12pt top, 6pt bottom */
}
:deep(.tiptap h2) {
font-family: 'SimHei', 'Arial', sans-serif;
font-size: 20px; /* Approx 15pt */
font-weight: 600;
margin: 16px 0 8px 0;
}
:deep(.tiptap h3) {
font-family: 'SimHei', 'Arial', sans-serif;
font-size: 18.7px; /* Approx 14pt */
font-weight: 600;
margin: 16px 0 8px 0;
}
:deep(.tiptap h2) {
@ -924,22 +1274,20 @@ const insertTable = () => {
:deep(.tiptap table) {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
margin: 12px 0; /* Approx 12pt */
table-layout: fixed;
/* 强制表格宽度固定,不随内容撑开 */
}
:deep(.tiptap table th),
:deep(.tiptap table td) {
border: 1px solid #d0d0d0;
padding: 8px 12px;
border: 1px solid #000000; /* Black border for fidelity */
padding: 6.7px 8px; /* Approx 5pt vertical padding */
text-align: left;
word-wrap: break-word;
/* 允许长单词换行 */
overflow-wrap: break-word;
/* 允许长单词换行 */
word-break: break-all;
/* 强制数字/字母换行防止撑开 */
font-size: 14px; /* Approx 10.5pt */
vertical-align: top;
}
:deep(.tiptap table th) {
@ -947,6 +1295,21 @@ const insertTable = () => {
font-weight: 600;
}
/* Table Column Resizing */
:deep(.tiptap .column-resize-handle) {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
:deep(.tiptap.resize-cursor) {
cursor: col-resize;
}
/* ProseMirror 选中样式 */
:deep(.ProseMirror-selectednode) {
outline: 2px solid #0078d4;
@ -1326,4 +1689,14 @@ const insertTable = () => {
.convert-btn:hover {
background: #218838 !important;
}
.export-btn {
background: #0078d4 !important;
border-color: #0078d4 !important;
color: white !important;
}
.export-btn:hover {
background: #106ebe !important;
}
</style>

View File

@ -1323,34 +1323,64 @@ defineExpose({
<span class="tree-col-status">Status</span>
<span class="tree-col-version">Version</span>
</div>
<div class="tree-nodes">
<div v-for="{ node, level } in flattenedNodes" :key="node.id"
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]">
<div :class="['tree-node', { selected: isSelected(node.id) }]" :style="{ paddingLeft: `${level * 16 + 4}px` }"
@click="handleSelect(node.id, node)" @dblclick="handleDoubleClick(node.id, node)"
@contextmenu="handleContextMenu($event, node.id)">
<span class="tree-node-toggle" @click.stop="handleToggle(node.id)">
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
<Plus v-else-if="hasChildren(node)" :size="12" />
<span v-else class="tree-node-spacer" />
</span>
<span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
<FolderOpen v-else-if="isExpanded(node.id)" :size="14" class="icon-folder-open" />
<Folder v-else :size="14" class="icon-folder" />
</span>
<span class="tree-node-label" :title="node.label">
{{ node.label }}
<span v-if="node.department" class="dept-badge">[{{ node.department }}]</span>
<span v-if="node.label === 'Inbox' && inboxTotalCount > 0" class="inbox-count">({{ inboxTotalCount }})</span>
<span v-if="node.label === 'Map' && mappedCount > 0" class="inbox-count">({{ mappedCount }})</span>
<span v-if="node.label === 'Review' && pendingReviewCount > 0" class="inbox-count">({{ pendingReviewCount }})</span>
<span v-if="node.isClone" class="clone-badge">(Clone)</span>
</span>
<transition-group name="tree-row" tag="div" class="tree-nodes">
<div
v-for="{ node, level } in flattenedNodes"
:key="node.id"
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]"
>
<div
:class="['tree-node', { selected: isSelected(node.id) }]"
@click="handleSelect(node.id, node)"
@dblclick="handleDoubleClick(node.id, node)"
@contextmenu="handleContextMenu($event, node.id)"
>
<div
class="tree-node-main"
:style="{ paddingLeft: `${level * 16 + 4}px` }"
>
<span class="tree-node-toggle" @click.stop="handleToggle(node.id)">
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
<Plus v-else-if="hasChildren(node)" :size="12" />
<span v-else class="tree-node-spacer" />
</span>
<span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
<FolderOpen v-else-if="isExpanded(node.id)" :size="14" class="icon-folder-open" />
<Folder v-else :size="14" class="icon-folder" />
</span>
<span class="tree-node-label" :title="node.label">
{{ node.label }}
<span v-if="node.department" class="dept-badge">[{{ node.department }}]</span>
<span v-if="node.label === 'Inbox' && inboxTotalCount > 0" class="inbox-count">
({{ inboxTotalCount }})
</span>
<span v-if="node.label === 'Map' && mappedCount > 0" class="inbox-count">
({{ mappedCount }})
</span>
<span v-if="node.label === 'Review' && pendingReviewCount > 0" class="inbox-count">
({{ pendingReviewCount }})
</span>
<span v-if="node.isClone" class="clone-badge">(Clone)</span>
</span>
</div>
<div class="tree-node-status-col">
<span
v-if="node.status"
:class="['status-badge', `status-${(node.status || '').replace(/\\s+/g, '').toLowerCase()}`]"
>
{{ node.status }}
</span>
</div>
<div class="tree-node-version-col">
<span class="version-text">
{{ node.version }}
</span>
</div>
</div>
</div>
</div>
</transition-group>
</div>
<!-- 右键菜单 -->
@ -1539,6 +1569,15 @@ defineExpose({
</div>
</template>
<style scoped>
.tree-node {
transition: background-color 0.15s ease;
}
.tree-node:hover {
/* Darken the background on hover for better focus on hovered node */
background-color: rgba(0, 0, 0, 0.08);
}
</style>
<style scoped>
.tree-view {
display: flex;
@ -1546,6 +1585,12 @@ defineExpose({
height: 100%;
overflow: hidden;
position: relative;
background: linear-gradient(180deg, #f5f5f8, #e1e1eb);
border: 1px solid #b5b5c5;
border-radius: 6px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.8) inset,
0 10px 24px rgba(15, 23, 42, 0.18);
}
.tree-header {
@ -1553,8 +1598,11 @@ defineExpose({
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-bottom: 1px solid #a0a0a0;
background: linear-gradient(180deg, #fdfdfd, #e9e9f2);
border-bottom: 1px solid #b0b0c0;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.8) inset,
0 1px 0 rgba(0, 0, 0, 0.06);
}
.tree-header-title {
@ -1595,13 +1643,32 @@ defineExpose({
.tree-content {
flex: 1;
overflow: auto;
background: radial-gradient(circle at top left, #ffffff 0, #f5f5f8 45%, #ebebf2 100%);
}
.tree-content::-webkit-scrollbar {
width: 10px;
}
.tree-content::-webkit-scrollbar-track {
background: #f0f0f5;
}
.tree-content::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #c1c1cf, #9d9db3);
border-radius: 5px;
border: 2px solid #f0f0f5;
}
.tree-content::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #a3a3ba, #7f7fa0);
}
.tree-column-headers {
display: flex;
padding: 4px 8px;
background: #e8e8e8;
border-bottom: 1px solid #a0a0a0;
background: linear-gradient(180deg, #f4f4f8, #e3e3ee);
border-bottom: 1px solid #c0c0d0;
font-size: 11px;
font-weight: 600;
color: #666;
@ -1633,20 +1700,45 @@ defineExpose({
.tree-node {
display: flex;
align-items: center;
padding: 4px 8px;
padding: 2px 8px;
cursor: pointer;
transition: all 0.1s;
transition:
background-color 220ms ease-out,
box-shadow 220ms ease-out,
color 220ms ease-out;
border-bottom: 1px solid transparent;
user-select: none;
}
.tree-node:hover {
background: #d0d0d0;
font-size: 12px;
will-change: background-color, box-shadow, color;
}
.tree-node.selected {
background: #cde8ff;
background: linear-gradient(180deg, #c9e1ff, #b3d6ff);
border-bottom-color: #0078d4;
box-shadow:
0 0 0 1px rgba(0, 120, 212, 0.25),
0 0 0 2px rgba(255, 255, 255, 0.6) inset;
}
.tree-node-wrapper:nth-child(odd) .tree-node:not(.selected) {
background: rgba(255, 255, 255, 0.7);
}
.tree-node-wrapper:nth-child(even) .tree-node:not(.selected) {
background: rgba(244, 244, 248, 0.8);
}
.tree-node:hover {
/* 基础 hover主要由下面更高优先级规则覆盖 */
}
.tree-node-wrapper:nth-child(odd) .tree-node:hover,
.tree-node-wrapper:nth-child(even) .tree-node:hover {
background:rgb(226, 234, 246);
color: #123a6b;
box-shadow:
0 0 0 1px rgba(0, 120, 212, 0.18),
0 0 0 3px rgba(0, 120, 212, 0.06);
}
.tree-node-toggle {
@ -1658,6 +1750,35 @@ defineExpose({
margin-right: 2px;
cursor: pointer;
color: #666;
transition:
background-color 160ms ease-out,
color 160ms ease-out,
transform 220ms ease-out;
}
.tree-node:hover .tree-node-toggle {
color: #005a9e;
transform: scale(1.18);
}
.tree-node-main {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.tree-node-status-col,
.tree-node-version-col {
width: 70px;
flex: 0 0 70px;
text-align: center;
font-size: 11px;
color: #555;
}
.tree-node-version-col {
padding-left: 4px;
}
.tree-node-spacer,
@ -1676,6 +1797,11 @@ defineExpose({
width: 16px;
height: 16px;
margin-right: 4px;
transition: transform 220ms ease-out;
}
.tree-node:hover .tree-node-icon {
transform: scale(1.15);
}
.icon-folder,
@ -1739,6 +1865,79 @@ defineExpose({
margin-left: 4px;
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
padding: 0 6px;
height: 18px;
border-radius: 9px;
border: 1px solid rgba(0, 0, 0, 0.08);
font-size: 10px;
font-weight: 500;
background: #f3f3f7;
color: #444;
transition:
background-color 220ms ease-out,
color 220ms ease-out,
box-shadow 220ms ease-out,
transform 220ms ease-out;
}
.status-work {
background: #e6f2ff;
color: #004578;
border-color: rgba(0, 120, 212, 0.35);
}
.status-released {
background: #e9f7ec;
color: #0b6a2b;
border-color: rgba(15, 123, 52, 0.4);
}
.status-approved {
background: #e9f7ec;
color: #0b6a2b;
border-color: rgba(15, 123, 52, 0.4);
}
.status-rejected {
background: #fdecea;
color: #a4262c;
border-color: rgba(164, 38, 44, 0.4);
}
.status-pending {
background: #fff8e5;
color: #8a5a00;
border-color: rgba(138, 90, 0, 0.4);
}
.status-csreleased {
background: #efe9ff;
color: #4b0082;
border-color: rgba(64, 34, 117, 0.4);
}
.status-mapped {
background: #e6f4ff;
color: #005a9e;
border-color: rgba(0, 90, 158, 0.4);
}
.tree-node:hover .status-badge {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
transform: translateY(-1px);
}
.version-text {
font-size: 11px;
color: #444;
letter-spacing: 0.01em;
}
.inbox-count {
color: #dc3545;
font-weight: bold;
@ -1761,10 +1960,14 @@ defineExpose({
background: #fff;
border: 1px solid #c0c0c0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.9) inset,
0 8px 20px rgba(0, 0, 0, 0.2);
z-index: 1000;
min-width: 160px;
padding: 4px 0;
animation: contextMenuIn 140ms ease-out;
transform-origin: top left;
}
.menu-group {
@ -1779,11 +1982,14 @@ defineExpose({
cursor: pointer;
font-size: 13px;
color: #333;
transition: background 0.15s;
transition:
background-color 140ms ease-out,
transform 120ms ease-out;
}
.menu-item:hover:not(.disabled) {
background: #f0f8ff;
transform: translateX(2px);
}
.menu-item.disabled {
@ -1814,6 +2020,7 @@ defineExpose({
align-items: center;
justify-content: center;
z-index: 2000;
animation: dialogFadeIn 160ms ease-out;
}
.operation-dialog {
@ -1824,6 +2031,7 @@ defineExpose({
max-height: 85vh;
display: flex;
flex-direction: column;
animation: dialogScaleIn 160ms ease-out;
}
.dialog-header {
@ -2045,6 +2253,52 @@ defineExpose({
cursor: not-allowed;
}
/* 行进出动效(展开/收起) */
.tree-row-enter-active,
.tree-row-leave-active {
transition:
opacity 240ms ease-out,
transform 240ms ease-out;
}
.tree-row-enter-from,
.tree-row-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* 右键菜单与对话框动效 */
@keyframes contextMenuIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes dialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from {
opacity: 0;
transform: translateY(6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 添加节点对话框 */
.add-node-dialog {
background: #fff;
@ -2054,6 +2308,7 @@ defineExpose({
max-height: 85vh;
display: flex;
flex-direction: column;
animation: dialogScaleIn 160ms ease-out;
}
.input-group {

View File

@ -69,7 +69,7 @@ export const CustomVideo = Node.create({
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: { default: null },
@ -78,7 +78,7 @@ export const CustomVideo = Node.create({
controls: { default: true },
}
},
parseHTML() {
return [
{
@ -86,11 +86,11 @@ export const CustomVideo = Node.create({
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-custom-video': '', ...HTMLAttributes }]
},
addNodeView() {
return VueNodeViewRenderer(VideoPlayerComponent)
},
@ -99,7 +99,7 @@ export const CustomVideo = Node.create({
// ========== 自定义图片扩展(支持缩放) ==========
export const ResizableImage = Image.extend({
name: 'resizableImage',
addAttributes() {
return {
...this.parent?.(),
@ -108,9 +108,13 @@ export const ResizableImage = Image.extend({
style: { default: null },
}
},
addNodeView() {
return VueNodeViewRenderer(ImageResizeComponent)
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})