Files
qhmes/jeecgboot-vue3/src/views/print/template/native/components/PropertiesPanel.vue

2295 lines
92 KiB
Vue
Raw Normal View History

<template>
<div ref="propertiesPanelRootRef" class="properties-panel">
<div class="properties-panel__header">
<span class="properties-panel__title">设计栏</span>
<div class="properties-panel__header-actions">
<button type="button" class="properties-panel__icon-btn" title="全部折叠" aria-label="全部折叠" @click="collapseAllSections">
<svg class="properties-panel__icon-svg" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M4 5h16v2H4V5zm0 6h16v2H4v-2zm0 6h9v2H4v-2z" />
</svg>
</button>
<button type="button" class="properties-panel__icon-btn" title="全部展开" aria-label="全部展开" @click="expandAllSections">
<svg class="properties-panel__icon-svg" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M4 5h16v2H4V5zm0 6h16v2H4v-2zm0 6h16v2H4v-2z" />
</svg>
</button>
</div>
</div>
<div class="properties-panel__body">
<template v-if="selectedElement">
<details class="section-card" open>
<summary class="panel-title">元素属性</summary>
<a-space direction="vertical" style="width: 100%">
<a-input :value="selectedElement.type" addon-before="类型" disabled />
<div
v-if="selectedElement.type !== 'table' && selectedElement.type !== 'detailTable' && selectedElement.type !== 'freeTable'"
class="bind-param-compact"
>
<span class="bind-param-compact__addon">绑定参数</span>
<a-select
:value="resolveParamBindSelectValue(selectedElement.bindField)"
:options="bindingParamOptions"
allow-clear
show-search
option-filter-prop="label"
placeholder="请先在左侧「参数」页维护"
class="bind-param-compact__select"
@update:value="handleElementBindParamChange"
/>
</div>
<a-input-number v-if="!isReportBand(selectedElement.type)" :value="selectedElement.x" addon-before="X(mm)" style="width: 100%" @update:value="updateRect('x', $event)" />
<a-input-number v-if="!isReportBand(selectedElement.type)" :value="selectedElement.y" addon-before="Y(mm)" style="width: 100%" @update:value="updateRect('y', $event)" />
<a-input-number v-if="!isReportBand(selectedElement.type)" :value="selectedElement.w" addon-before="宽(mm)" style="width: 100%" :min="6" @update:value="updateRect('w', $event)" />
<a-input-number v-if="!isReportBand(selectedElement.type)" :value="selectedElement.h" addon-before="高(mm)" style="width: 100%" :min="6" @update:value="updateRect('h', $event)" />
<a-input-number
:value="selectedElement.style?.fontSize || 12"
addon-before="字体(px)"
:min="8"
:max="72"
style="width: 100%"
@update:value="updateStyle('fontSize', $event)"
/>
<template v-if="showPlainTextStyleColors">
<div class="color-input-row">
<a-input
:value="selectedElement.style?.color || '#111111'"
addon-before="文字色"
placeholder="#111111 / rgb(...)"
@update:value="updateStyle('color', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择文字颜色"
:value="toColorHex(String(selectedElement.style?.color || '#111111'))"
@input="handleStyleColorInput('color', $event, '#111111')"
/>
</div>
<div class="color-input-row">
<a-input
:value="String(selectedElement.style?.backgroundColor ?? 'transparent')"
addon-before="背景色"
placeholder="transparent / #ffffff"
@update:value="updateStyle('backgroundColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择背景颜色"
:value="toColorHex(plainTextBackgroundForPicker)"
@input="handlePlainTextBackgroundColorInput($event)"
/>
</div>
</template>
</a-space>
</details>
<template v-if="isText(selectedElement.type)">
<a-divider />
<a-input :value="(selectedElement as any).text" addon-before="内容" @update:value="updateField('text', $event)" />
</template>
<template v-if="isReportBand(selectedElement.type)">
<a-divider />
<div class="panel-subtitle">布局</div>
<a-space direction="vertical" style="width: 100%">
<a-input-number :value="selectedElement.h" addon-before="高度" :min="4" :max="200" style="width: 100%" @update:value="updateRect('h', $event)" />
</a-space>
<div class="panel-subtitle" style="margin-top: 8px">外观</div>
<div class="color-input-row">
<a-input
:value="selectedElement.style?.backgroundColor || '#ffffff'"
addon-before="背景色"
placeholder="#ffffff / rgb(...)"
@update:value="updateStyle('backgroundColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择背景颜色"
:value="toColorHex(selectedElement.style?.backgroundColor || '#ffffff')"
@input="handleStyleColorInput('backgroundColor', $event, '#ffffff')"
/>
</div>
<div class="color-input-row">
<a-input
:value="selectedElement.style?.color || '#111111'"
addon-before="文字色"
placeholder="#111111 / rgb(...)"
@update:value="updateStyle('color', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择文字颜色"
:value="toColorHex(String(selectedElement.style?.color || '#111111'))"
@input="handleStyleColorInput('color', $event, '#111111')"
/>
</div>
<div class="panel-subtitle" style="margin-top: 8px">数据</div>
<a-space direction="vertical" style="width: 100%">
<a-input :value="(selectedElement as any).bookmarkText || ''" addon-before="书签文本" @update:value="updateField('bookmarkText', $event)" />
</a-space>
<div class="panel-subtitle" style="margin-top: 8px">行为</div>
<a-space direction="vertical" style="width: 100%">
<a-switch :checked="(selectedElement as any).keepTogether !== false" checked-children="保持同页" un-checked-children="允许拆分" @update:checked="updateField('keepTogether', $event)" />
<a-switch :checked="(selectedElement as any).centerWithDetail !== false" checked-children="跟随明细居中" un-checked-children="不居中" @update:checked="updateField('centerWithDetail', $event)" />
<a-select
:value="(selectedElement as any).refreshPage || 'none'"
:options="REFRESH_PAGE_OPTIONS"
style="width: 100%"
@update:value="updateField('refreshPage', $event)"
/>
<a-switch :checked="(selectedElement as any).visible !== false" checked-children="可见" un-checked-children="隐藏" @update:checked="updateField('visible', $event)" />
<a-switch :checked="(selectedElement as any).stretch === true" checked-children="可伸展" un-checked-children="不可伸展" @update:checked="updateField('stretch', $event)" />
<a-switch :checked="(selectedElement as any).shrink === true" checked-children="可收缩" un-checked-children="不可收缩" @update:checked="updateField('shrink', $event)" />
<a-switch :checked="(selectedElement as any).printRepeated === true" checked-children="每页重复打印" un-checked-children="不重复" @update:checked="updateField('printRepeated', $event)" />
<a-switch
v-if="isReportFooter(selectedElement.type)"
:checked="(selectedElement as any).printAtPageBottom === true"
checked-children="打印在页底"
un-checked-children="常规位置"
@update:checked="updateField('printAtPageBottom', $event)"
/>
<a-switch
v-if="isReportFooter(selectedElement.type)"
:checked="(selectedElement as any).removeBlankWhenNoData === true"
checked-children="空白行排除"
un-checked-children="保留空白"
@update:checked="updateField('removeBlankWhenNoData', $event)"
/>
</a-space>
</template>
<template v-if="selectedElement.type === 'image'">
<a-divider />
<a-input :value="(selectedElement as any).src" addon-before="图片URL" @update:value="updateField('src', $event)" />
</template>
<template v-if="selectedElement.type === 'qrcode' || selectedElement.type === 'barcode'">
<a-divider />
<a-input :value="(selectedElement as any).value" addon-before="编码值" @update:value="updateField('value', $event)" />
</template>
<template v-if="selectedElement.type === 'table' || selectedElement.type === 'detailTable'">
<a-divider />
<details class="section-card" open>
<summary class="panel-subtitle">表格配置</summary>
<a-space direction="vertical" style="width: 100%">
<div class="bind-param-compact">
<span class="bind-param-compact__addon">数据源</span>
<a-select
:value="resolveTableSourceSelectValue((selectedElement as any).source)"
:options="detailTableSourceOptions"
allow-clear
show-search
option-filter-prop="label"
placeholder="请选择左侧「字段」页登记的明细数据源"
class="bind-param-compact__select"
@update:value="handleTableSourceChange"
/>
</div>
<a-select
:value="(selectedElement as any).tableHeightMode || 'autoPage'"
style="width: 100%"
:options="TABLE_HEIGHT_MODE_OPTIONS"
@update:value="updateField('tableHeightMode', $event)"
/>
<a-input-number
v-if="((selectedElement as any).tableHeightMode || 'autoPage') === 'fixedRows'"
:value="(selectedElement as any).fixedRows || 5"
addon-before="分页行数"
:min="1"
:max="500"
style="width: 100%"
@update:value="updateField('fixedRows', Number($event || 5))"
/>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">分组合并</summary>
<a-space direction="vertical" style="width: 100%">
<a-select
mode="multiple"
:value="(selectedElement as any).mergeColumnKeys || []"
:options="mergeColumnOptions"
placeholder="选择分组列(顺序决定优先级)"
style="width: 100%"
@update:value="updateMergeColumns($event)"
/>
<a-switch
:checked="(selectedElement as any).strictGrouping !== false"
checked-children="强制分组"
un-checked-children="宽松分组"
@update:checked="updateField('strictGrouping', $event)"
/>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">表头</summary>
<a-space direction="vertical" style="width: 100%">
<a-switch
:checked="(selectedElement as any).showHeader"
checked-children="显示表头"
un-checked-children="隐藏表头"
@update:checked="updateField('showHeader', $event)"
/>
<a-input-number :value="(selectedElement as any).headerHeight" addon-before="表头高(mm)" style="width: 100%" @update:value="updateField('headerHeight', $event)" />
<a-input-number :value="(selectedElement as any).headerFontSize || 12" addon-before="表头字号(px)" :min="8" :max="72" style="width: 100%" @update:value="updateField('headerFontSize', Number($event || 12))" />
<div class="color-input-row">
<a-input
:value="(selectedElement as any).headerBgColor || '#f5f5f5'"
addon-before="表头背景"
placeholder="#f5f5f5 / rgb(...)"
@update:value="updateField('headerBgColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择表头背景色"
:value="toColorHex((selectedElement as any).headerBgColor || '#f5f5f5')"
@input="handleColorInput('headerBgColor', $event, '#f5f5f5')"
/>
</div>
<div class="color-input-row">
<a-input
:value="(selectedElement as any).headerTextColor || '#111111'"
addon-before="表头文字"
placeholder="#111111 / rgb(...)"
@update:value="updateField('headerTextColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择表头文字色"
:value="toColorHex((selectedElement as any).headerTextColor || '#111111')"
@input="handleColorInput('headerTextColor', $event, '#111111')"
/>
</div>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">表体</summary>
<a-space direction="vertical" style="width: 100%">
<a-input-number :value="(selectedElement as any).rowHeight" addon-before="表体行高(mm)" style="width: 100%" @update:value="updateField('rowHeight', $event)" />
<a-input-number :value="(selectedElement as any).bodyFontSize || 12" addon-before="表体字号(px)" :min="8" :max="72" style="width: 100%" @update:value="updateField('bodyFontSize', Number($event || 12))" />
<a-button type="primary" block class="column-width-entry-btn" @click="columnWidthModalOpen = true">打开绑定字段列宽设置</a-button>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">底部</summary>
<a-space direction="vertical" style="width: 100%">
<a-switch
:checked="(selectedElement as any).footerShowTotal !== false"
checked-children="显示底部合计"
un-checked-children="隐藏底部合计"
@update:checked="updateField('footerShowTotal', $event)"
/>
<a-select
:value="(selectedElement as any).footerTotalMode || 'overall'"
style="width: 100%"
:options="FOOTER_TOTAL_MODE_OPTIONS"
@update:value="updateField('footerTotalMode', $event)"
/>
<a-select
:value="resolveFooterLabelColumnKey()"
style="width: 100%"
:options="footerLabelColumnOptions"
@update:value="updateField('footerLabelColumnKey', $event)"
/>
<a-input
:value="(selectedElement as any).footerLabelText || '合计'"
addon-before="底部文字"
@update:value="updateField('footerLabelText', $event)"
/>
<a-switch
:checked="(selectedElement as any).footerLabelCenter !== false"
checked-children="底部居中"
un-checked-children="底部左对齐"
@update:checked="updateField('footerLabelCenter', $event)"
/>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">多级表头</summary>
<a-space direction="vertical" style="width: 100%">
<a-switch
:checked="(selectedElement as any).enableMultiHeader === true"
checked-children="已开启"
un-checked-children="未开启"
@update:checked="handleMultiHeaderToggle($event)"
/>
<a-button type="primary" ghost :disabled="(selectedElement as any).enableMultiHeader !== true" @click="headerConfigModalOpen = true">
打开多级表头设置
</a-button>
</a-space>
</details>
<a-modal
v-model:open="headerConfigModalOpen"
title="多级表头设置"
:width="900"
:footer="null"
destroy-on-close
>
<TableHeaderConfigEditor
:row-count="resolveHeaderConfigRowCount()"
:col-count="tableColumns.length || 1"
:column-titles="tableColumns.map((item) => item?.title || item?.key || '')"
:value="(selectedElement as any).headerConfig"
@update:value="handleHeaderConfigChange($event)"
/>
</a-modal>
<a-modal
v-model:open="columnWidthModalOpen"
title="绑定字段列宽设置"
:width="760"
:footer="null"
destroy-on-close
>
<div class="column-width-modal">
<div class="column-width-modal-tip">按绑定字段分别设置列宽修改后会实时同步到画布</div>
<div class="column-width-list">
<div v-for="(col, idx) in tableColumns" :key="`width_${col.key}`" class="column-width-item">
<div class="column-width-item-head">
<span class="column-width-index">{{ idx + 1 }}</span>
<span class="column-width-title">{{ col.title || col.key }}</span>
</div>
<a-input :value="col.bindField || col.field || col.key" addon-before="字段" disabled />
<a-input-number
:value="col.width"
addon-before="列宽"
addon-after="mm"
:min="10"
:max="500"
style="width: 100%"
@update:value="updateTableColumn(idx, 'width', Number($event || 10))"
/>
</div>
</div>
<div class="column-width-modal-footer">
<a-button @click="columnWidthModalOpen = false">关闭</a-button>
</div>
</div>
</a-modal>
<details class="section-card" open>
<summary class="panel-subtitle">当前列属性双击表头选择列</summary>
<div v-if="selectedTableColumnInfo" class="table-column-list">
<div class="table-column-item">
<a-space direction="vertical" style="width: 100%" size="small">
<a-input
:value="selectedTableColumnInfo.col.title"
addon-before="列标题"
@update:value="updateSelectedTableColumn('title', $event)"
/>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">绑定字段</span>
<a-select
:value="resolveDetailFieldBindSelectValue(selectedTableColumnInfo.col.bindField)"
:options="detailFieldBindOptionsForColumn"
allow-clear
show-search
option-filter-prop="label"
placeholder="请选择当前数据源下的字段"
class="bind-param-compact__select"
@update:value="handleTableColumnBindDetailFieldChange"
/>
</div>
<a-input-number
:value="selectedTableColumnInfo.col.width"
addon-before="列宽"
:min="10"
style="width: 100%"
@update:value="updateSelectedTableColumn('width', Number($event || 10))"
/>
<a-select
:value="selectedTableColumnInfo.col.contentType || 'text'"
style="width: 100%"
:options="TABLE_COLUMN_CONTENT_TYPE_OPTIONS"
@update:value="handleColumnContentTypeChange($event)"
/>
<template v-if="(selectedTableColumnInfo.col.contentType || 'text') === 'text'">
<a-select
:value="selectedTableColumnInfo.col.fontFamily || ''"
style="width: 100%"
:options="TABLE_FONT_OPTIONS"
@update:value="updateSelectedTableColumn('fontFamily', $event)"
/>
<a-switch
:checked="!!selectedTableColumnInfo.col.useCustomFontSize"
checked-children="单列字号"
un-checked-children="跟随表体字号"
@update:checked="updateSelectedTableColumn('useCustomFontSize', $event)"
/>
<a-input-number
:value="selectedTableColumnInfo.col.fontSize || 12"
addon-before="字体大小"
:min="8"
:max="72"
style="width: 100%"
:disabled="!selectedTableColumnInfo.col.useCustomFontSize"
@update:value="updateSelectedTableColumn('fontSize', Number($event || 12))"
/>
<div class="color-input-row">
<a-input
:value="selectedTableColumnInfo.col.fontColor || '#111111'"
addon-before="字体颜色"
placeholder="#111111 / rgb(...)"
@update:value="updateSelectedTableColumn('fontColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择字体颜色"
:value="toColorHex(selectedTableColumnInfo.col.fontColor || '#111111')"
@input="handleColumnColorInput($event)"
/>
</div>
</template>
<template v-else-if="(selectedTableColumnInfo.col.contentType || 'text') === 'image'">
<a-select
:value="selectedTableColumnInfo.col.imageFit || 'contain'"
style="width: 100%"
:options="IMAGE_FIT_OPTIONS"
@update:value="updateSelectedTableColumn('imageFit', $event)"
/>
<a-input-number
:value="selectedTableColumnInfo.col.contentScale || 100"
addon-before="图片缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedTableColumn('contentScale', Number($event || 100))"
/>
<a-switch
:checked="selectedTableColumnInfo.col.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedTableColumn('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedTableColumnInfo.col.contentType || 'text') === 'qrcode'">
<a-select
:value="selectedTableColumnInfo.col.qrLevel || 'M'"
style="width: 100%"
:options="QRCODE_LEVEL_OPTIONS"
@update:value="updateSelectedTableColumn('qrLevel', $event)"
/>
<a-select
:value="selectedTableColumnInfo.col.qrRenderType || 'image/png'"
style="width: 100%"
:options="QRCODE_RENDER_OPTIONS"
@update:value="updateSelectedTableColumn('qrRenderType', $event)"
/>
<a-input-number
:value="selectedTableColumnInfo.col.contentScale || 100"
addon-before="二维码缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedTableColumn('contentScale', Number($event || 100))"
/>
<a-switch
:checked="selectedTableColumnInfo.col.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedTableColumn('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedTableColumnInfo.col.contentType || 'text') === 'barcode'">
<a-select
:value="selectedTableColumnInfo.col.barcodeFormat || 'CODE128'"
style="width: 100%"
:options="BARCODE_FORMAT_OPTIONS"
@update:value="updateSelectedTableColumn('barcodeFormat', $event)"
/>
<a-input-number
:value="selectedTableColumnInfo.col.contentScale || 100"
addon-before="条码缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedTableColumn('contentScale', Number($event || 100))"
/>
<a-switch
:checked="selectedTableColumnInfo.col.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedTableColumn('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedTableColumnInfo.col.contentType || 'text') === 'number' || (selectedTableColumnInfo.col.contentType || 'text') === 'amount'">
<a-input-number
:value="selectedTableColumnInfo.col.decimalPlaces ?? 2"
addon-before="小数位数"
:min="0"
:max="6"
style="width: 100%"
@update:value="updateSelectedTableColumn('decimalPlaces', Number($event ?? 2))"
/>
<a-switch
:checked="selectedTableColumnInfo.col.roundHalfUp !== false"
checked-children="四舍五入"
un-checked-children="截断"
@update:checked="updateSelectedTableColumn('roundHalfUp', $event)"
/>
<a-select
v-if="(selectedTableColumnInfo.col.contentType || 'text') === 'amount'"
:value="selectedTableColumnInfo.col.amountType || 'CNY'"
style="width: 100%"
:options="AMOUNT_TYPE_OPTIONS"
@update:value="updateSelectedTableColumn('amountType', $event)"
/>
<a-switch
:checked="!!selectedTableColumnInfo.col.enableFooterTotal"
checked-children="参与底部合计"
un-checked-children="不参与合计"
@update:checked="updateSelectedTableColumn('enableFooterTotal', $event)"
/>
</template>
<a-select
:value="selectedTableColumnInfo.col.align || 'left'"
style="width: 100%"
@update:value="updateSelectedTableColumn('align', $event)"
>
<a-select-option value="left">左对齐</a-select-option>
<a-select-option value="center">居中</a-select-option>
<a-select-option value="right">右对齐</a-select-option>
</a-select>
<a-space v-if="(selectedTableColumnInfo.col.contentType || 'text') === 'text'">
<a-switch
:checked="selectedTableColumnInfo.col.autoWrap !== false"
checked-children="自动换行"
un-checked-children="不换行"
@update:checked="updateSelectedTableColumn('autoWrap', $event)"
/>
<a-switch
:checked="!!selectedTableColumnInfo.col.autoFitFont"
checked-children="自动适配字号"
un-checked-children="固定字号"
@update:checked="updateSelectedTableColumn('autoFitFont', $event)"
/>
</a-space>
<a-button danger size="small" @click="removeSelectedTableColumn">删除当前列</a-button>
</a-space>
</div>
<a-button block size="small" type="dashed" @click="addTableColumn">新增列</a-button>
</div>
<a-empty v-else description="请先在画布中双击表格表头选择列" />
</details>
</template>
<template v-if="selectedElement.type === 'freeTable'">
<a-divider />
<details class="section-card" open>
<summary class="panel-subtitle">自由表格</summary>
<a-space direction="vertical" style="width: 100%">
<div class="free-table-merge-tip">合并先点击起始格再按住 Shift 点击结束格然后点合并选中区域</div>
<div class="free-table-dim-row">
<div class="free-table-dim-item">
<span class="free-table-dim-label">行数</span>
<a-input-number
:value="(selectedElement as any).rowCount || 1"
:min="1"
:max="100"
size="small"
disabled
/>
</div>
<div class="free-table-dim-item">
<span class="free-table-dim-label">列数</span>
<a-input-number
:value="(selectedElement as any).colCount || 1"
:min="1"
:max="50"
size="small"
disabled
/>
</div>
</div>
<a-space>
<a-button size="small" @click="addFreeTableRow">新增行</a-button>
<a-button size="small" @click="removeFreeTableRow">删除行</a-button>
<a-button size="small" @click="addFreeTableCol">新增列</a-button>
<a-button size="small" @click="removeFreeTableCol">删除列</a-button>
</a-space>
<div class="free-table-track-head">单元格合并</div>
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">表格样式</summary>
<a-space direction="vertical" style="width: 100%">
<div class="free-table-merge-tip">外边框控制整张表最外一圈内边框控制行间横线列间竖线选中单元格后可在下方单独隐藏该格某一侧边框</div>
<div class="color-input-row">
<a-input
:value="(selectedElement as any).borderColor || '#d9d9d9'"
addon-before="边框色"
placeholder="#d9d9d9"
@update:value="updateField('borderColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择边框颜色"
:value="toColorHex((selectedElement as any).borderColor || '#d9d9d9')"
@input="handleColorInput('borderColor', $event, '#d9d9d9')"
/>
</div>
<a-input-number
:value="(selectedElement as any).borderWidth || 1"
addon-before="边框宽(px)"
:min="1"
:max="6"
style="width: 100%"
@update:value="updateField('borderWidth', Number($event || 1))"
/>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">外框线型</span>
<a-select
:value="(selectedElement as any).outerBorderLineStyle || 'solid'"
class="bind-param-compact__select"
@update:value="updateField('outerBorderLineStyle', $event)"
>
<a-select-option
v-for="opt in FREE_TABLE_LINE_STYLE_OPTIONS"
:key="`ft_os_${opt.value}`"
:value="opt.value"
:label="opt.label"
>
<div class="free-table-line-style-option">
<span :class="['free-table-line-preview', `free-table-line-preview--${opt.value}`]" />
<span>{{ opt.label }}</span>
</div>
</a-select-option>
</a-select>
</div>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">横线线型</span>
<a-select
:value="(selectedElement as any).innerBorderHorizontalLineStyle || 'solid'"
class="bind-param-compact__select"
@update:value="updateField('innerBorderHorizontalLineStyle', $event)"
>
<a-select-option
v-for="opt in FREE_TABLE_LINE_STYLE_OPTIONS"
:key="`ft_hs_${opt.value}`"
:value="opt.value"
:label="opt.label"
>
<div class="free-table-line-style-option">
<span :class="['free-table-line-preview', `free-table-line-preview--${opt.value}`]" />
<span>{{ opt.label }}</span>
</div>
</a-select-option>
</a-select>
</div>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">竖线线型</span>
<a-select
:value="(selectedElement as any).innerBorderVerticalLineStyle || 'solid'"
class="bind-param-compact__select"
@update:value="updateField('innerBorderVerticalLineStyle', $event)"
>
<a-select-option
v-for="opt in FREE_TABLE_LINE_STYLE_OPTIONS"
:key="`ft_vs_${opt.value}`"
:value="opt.value"
:label="opt.label"
>
<div class="free-table-line-style-option">
<span :class="['free-table-line-preview', `free-table-line-preview--${opt.value}`]" />
<span>{{ opt.label }}</span>
</div>
</a-select-option>
</a-select>
</div>
<div class="free-table-track-head">外边框显示</div>
<div class="free-table-border-switch-grid">
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label"></span>
<a-switch
size="small"
:checked="(selectedElement as any).outerBorder?.top !== false"
checked-children="开"
un-checked-children="关"
@update:checked="patchFreeTableOuterSide('top', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label"></span>
<a-switch
size="small"
:checked="(selectedElement as any).outerBorder?.right !== false"
checked-children="开"
un-checked-children="关"
@update:checked="patchFreeTableOuterSide('right', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label"></span>
<a-switch
size="small"
:checked="(selectedElement as any).outerBorder?.bottom !== false"
checked-children="开"
un-checked-children="关"
@update:checked="patchFreeTableOuterSide('bottom', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label"></span>
<a-switch
size="small"
:checked="(selectedElement as any).outerBorder?.left !== false"
checked-children="开"
un-checked-children="关"
@update:checked="patchFreeTableOuterSide('left', $event)"
/>
</div>
</div>
<div class="free-table-track-head">内边框显示</div>
<a-space wrap>
<a-switch
:checked="(selectedElement as any).innerBorder?.horizontal !== false"
checked-children="行间横线"
un-checked-children="行间横线关"
@update:checked="patchFreeTableInnerSide('horizontal', $event)"
/>
<a-switch
:checked="(selectedElement as any).innerBorder?.vertical !== false"
checked-children="列间竖线"
un-checked-children="列间竖线关"
@update:checked="patchFreeTableInnerSide('vertical', $event)"
/>
</a-space>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">表格设置</summary>
<a-space direction="vertical" style="width: 100%">
<div class="free-table-merge-tip">
列宽总和等于元素宽度行高总和等于元素高度单位 mm输入某一列/行时会与相邻列/行自动补偿也可在画布中拖动蓝色分隔线调整
</div>
<a-space wrap>
<a-button size="small" @click="evenFreeTableColWidths">均分列宽</a-button>
<a-button size="small" @click="evenFreeTableRowHeights">均分行高</a-button>
</a-space>
<div class="free-table-track-head">列宽mm</div>
<div v-for="ci in freeTableColIndices" :key="`ft_cw_${ci}`" class="free-table-track-row">
<span class="free-table-track-label">{{ ci + 1 }}</span>
<a-input-number
size="small"
:min="MIN_FREE_TABLE_TRACK_MM"
:max="freeTableMaxColWidthMm"
:value="freeTableColWidthsResolved[ci]"
style="width: 160px"
@update:value="handleFreeTableColWidthChange(ci, $event)"
/>
</div>
<div class="free-table-track-head">行高mm</div>
<div v-for="ri in freeTableRowIndices" :key="`ft_rh_${ri}`" class="free-table-track-row">
<span class="free-table-track-label">{{ ri + 1 }}</span>
<a-input-number
size="small"
:min="MIN_FREE_TABLE_TRACK_MM"
:max="freeTableMaxRowHeightMm"
:value="freeTableRowHeightsResolved[ri]"
style="width: 160px"
@update:value="handleFreeTableRowHeightChange(ri, $event)"
/>
</div>
</a-space>
</details>
<details class="section-card" open>
<summary class="panel-subtitle">当前单元格点击画布单元格后编辑</summary>
<a-space v-if="selectedFreeTableCellInfo" direction="vertical" style="width: 100%">
<a-input :value="`R${selectedFreeTableCellInfo.row + 1} C${selectedFreeTableCellInfo.col + 1}`" addon-before="位置" disabled />
<a-input
:value="selectedFreeTableCellInfo.cell.text || ''"
addon-before="文本"
@update:value="updateSelectedFreeTableCell('text', $event)"
/>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">绑定参数</span>
<a-select
:value="resolveParamBindSelectValue(selectedFreeTableCellInfo.cell.bindField)"
:options="bindingParamOptions"
allow-clear
show-search
option-filter-prop="label"
placeholder="请先在左侧「参数」页维护"
class="bind-param-compact__select"
@update:value="handleFreeTableCellBindParamChange"
/>
</div>
<a-select
:value="selectedFreeTableCellInfo.cell.align || 'left'"
:options="FREE_TABLE_ALIGN_OPTIONS"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('align', $event)"
/>
<a-select
:value="selectedFreeTableCellInfo.cell.verticalAlign || 'middle'"
:options="FREE_TABLE_VALIGN_OPTIONS"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('verticalAlign', $event)"
/>
<div class="bind-param-compact">
<span class="bind-param-compact__addon">数据类型</span>
<a-select
:value="selectedFreeTableCellInfo.cell.contentType || 'text'"
:options="TABLE_COLUMN_CONTENT_TYPE_OPTIONS"
class="bind-param-compact__select"
@update:value="updateSelectedFreeTableCell('contentType', $event)"
/>
</div>
<template v-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'image'">
<a-select
:value="selectedFreeTableCellInfo.cell.imageFit || 'contain'"
style="width: 100%"
:options="IMAGE_FIT_OPTIONS"
@update:value="updateSelectedFreeTableCell('imageFit', $event)"
/>
<a-input-number
:value="selectedFreeTableCellInfo.cell.contentScale ?? 100"
addon-before="图片缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('contentScale', Number($event ?? 100))"
/>
<a-switch
:checked="selectedFreeTableCellInfo.cell.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedFreeTableCell('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'qrcode'">
<a-select
:value="selectedFreeTableCellInfo.cell.qrLevel || 'M'"
style="width: 100%"
:options="QRCODE_LEVEL_OPTIONS"
@update:value="updateSelectedFreeTableCell('qrLevel', $event)"
/>
<a-select
:value="selectedFreeTableCellInfo.cell.qrRenderType || 'image/png'"
style="width: 100%"
:options="QRCODE_RENDER_OPTIONS"
@update:value="updateSelectedFreeTableCell('qrRenderType', $event)"
/>
<a-input-number
:value="selectedFreeTableCellInfo.cell.contentScale ?? 100"
addon-before="二维码缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('contentScale', Number($event ?? 100))"
/>
<a-switch
:checked="selectedFreeTableCellInfo.cell.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedFreeTableCell('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'barcode'">
<a-select
:value="selectedFreeTableCellInfo.cell.barcodeFormat || 'CODE128'"
style="width: 100%"
:options="BARCODE_FORMAT_OPTIONS"
@update:value="updateSelectedFreeTableCell('barcodeFormat', $event)"
/>
<a-input-number
:value="selectedFreeTableCellInfo.cell.contentScale ?? 100"
addon-before="条码缩放(%)"
:min="10"
:max="100"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('contentScale', Number($event ?? 100))"
/>
<a-switch
:checked="selectedFreeTableCellInfo.cell.fillCell !== false"
checked-children="填满单元格"
un-checked-children="按缩放显示"
@update:checked="updateSelectedFreeTableCell('fillCell', $event)"
/>
</template>
<template v-else-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'number' || (selectedFreeTableCellInfo.cell.contentType || 'text') === 'amount'">
<a-input-number
:value="selectedFreeTableCellInfo.cell.decimalPlaces ?? 2"
addon-before="小数位数"
:min="0"
:max="6"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('decimalPlaces', Number($event ?? 2))"
/>
<a-switch
:checked="selectedFreeTableCellInfo.cell.roundHalfUp !== false"
checked-children="四舍五入"
un-checked-children="截断"
@update:checked="updateSelectedFreeTableCell('roundHalfUp', $event)"
/>
<a-select
v-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'amount'"
:value="selectedFreeTableCellInfo.cell.amountType || 'CNY'"
style="width: 100%"
:options="AMOUNT_TYPE_OPTIONS"
@update:value="updateSelectedFreeTableCell('amountType', $event)"
/>
</template>
<a-space v-if="(selectedFreeTableCellInfo.cell.contentType || 'text') === 'text'">
<a-switch
:checked="selectedFreeTableCellInfo.cell.autoWrap !== false"
checked-children="自动换行"
un-checked-children="不换行"
@update:checked="updateSelectedFreeTableCell('autoWrap', $event)"
/>
<a-switch
:checked="!!selectedFreeTableCellInfo.cell.autoFitFont"
checked-children="自动适配字号"
un-checked-children="固定字号"
@update:checked="updateSelectedFreeTableCell('autoFitFont', $event)"
/>
</a-space>
<a-input-number
:value="selectedFreeTableCellInfo.cell.fontSize || 12"
addon-before="字号(px)"
:min="8"
:max="72"
style="width: 100%"
@update:value="updateSelectedFreeTableCell('fontSize', Number($event || 12))"
/>
<div class="color-input-row">
<a-input
:value="selectedFreeTableCellInfo.cell.color || '#111111'"
addon-before="文字色"
placeholder="#111111"
@update:value="updateSelectedFreeTableCell('color', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择文字颜色"
:value="toColorHex(selectedFreeTableCellInfo.cell.color || '#111111')"
@input="handleFreeTableCellColorInput('color', $event, '#111111')"
/>
</div>
<div class="color-input-row">
<a-input
:value="selectedFreeTableCellInfo.cell.backgroundColor || '#ffffff'"
addon-before="背景色"
placeholder="#ffffff"
@update:value="updateSelectedFreeTableCell('backgroundColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择背景颜色"
:value="toColorHex(selectedFreeTableCellInfo.cell.backgroundColor || '#ffffff')"
@input="handleFreeTableCellColorInput('backgroundColor', $event, '#ffffff')"
/>
</div>
<a-divider plain orientation="left" style="margin: 8px 0">单元格边线隐藏</a-divider>
<div class="free-table-merge-tip">以下为当前锚点格含合并区域单独隐藏某侧边框表格样式中的外框/内线叠加生效</div>
<div class="free-table-border-switch-grid">
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label">隐上</span>
<a-switch
size="small"
:checked="!!selectedFreeTableCellInfo.cell.hideBorderTop"
checked-children="隐"
un-checked-children="显"
@update:checked="updateSelectedFreeTableCell('hideBorderTop', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label">隐右</span>
<a-switch
size="small"
:checked="!!selectedFreeTableCellInfo.cell.hideBorderRight"
checked-children="隐"
un-checked-children="显"
@update:checked="updateSelectedFreeTableCell('hideBorderRight', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label">隐下</span>
<a-switch
size="small"
:checked="!!selectedFreeTableCellInfo.cell.hideBorderBottom"
checked-children="隐"
un-checked-children="显"
@update:checked="updateSelectedFreeTableCell('hideBorderBottom', $event)"
/>
</div>
<div class="free-table-border-switch-cell">
<span class="free-table-border-switch-label">隐左</span>
<a-switch
size="small"
:checked="!!selectedFreeTableCellInfo.cell.hideBorderLeft"
checked-children="隐"
un-checked-children="显"
@update:checked="updateSelectedFreeTableCell('hideBorderLeft', $event)"
/>
</div>
</div>
</a-space>
<a-empty v-else description="请先在画布中点击自由表格的单元格" />
</details>
</template>
</template>
<template v-else>
<a-empty description="请先选中元素" />
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { TABLE_FONT_OPTIONS } from '../core/fontOptions';
import TableHeaderConfigEditor from './TableHeaderConfigEditor.vue';
import type {
NativeDataBindingDetailTable,
NativeDataBindingParam,
NativeElement,
NativeTemplateSchema,
} from '../core/types';
import {
canMergeFreeTableRegion,
clipAnchorsToGrid,
getFreeTableOwnerAt,
mergeFreeTableRegion,
normalizeFreeTableAnchors,
splitFreeTableAt,
} from '../core/freeTableGrid';
import { FREE_TABLE_LINE_STYLE_OPTIONS } from '../core/freeTableLineStyles';
import {
MIN_FREE_TABLE_TRACK_MM,
buildEvenColWidths,
buildEvenRowHeights,
colWidthsAfterAddCol,
colWidthsAfterRemoveCol,
resolveFreeTableColWidthsMm,
resolveFreeTableRowHeightsMm,
rowHeightsAfterAddRow,
rowHeightsAfterRemoveRow,
setColWidthAt,
setRowHeightAt,
} from '../core/freeTableTracks';
const { createMessage } = useMessage();
const propertiesPanelRootRef = ref<HTMLElement | null>(null);
function getSectionDetails(): HTMLDetailsElement[] {
const root = propertiesPanelRootRef.value;
if (!root) return [];
return Array.from(root.querySelectorAll('details.section-card')) as HTMLDetailsElement[];
}
/** 折叠所有功能分区details.section-card */
function collapseAllSections() {
getSectionDetails().forEach((el) => {
el.open = false;
});
}
/** 展开所有功能分区 */
function expandAllSections() {
getSectionDetails().forEach((el) => {
el.open = true;
});
}
const props = defineProps<{
schema: NativeTemplateSchema;
selectedElement?: NativeElement;
selectedTableColumnKey?: string;
selectedFreeTableCell?: { row: number; col: number } | null;
/** 画布 Shift 选取的矩形(含首尾),用于合并 */
selectedFreeTableMergeRect?: { r0: number; r1: number; c0: number; c1: number } | null;
}>();
/** 标题/正文等:在「元素属性」中显示 style 文字色与背景色 */
const showPlainTextStyleColors = computed(() => {
const el = props.selectedElement;
if (!el) return false;
return ['title', 'subtitle', 'text', 'date', 'pageNo'].includes(el.type);
});
/** 透明/无色时取色器用白色占位 */
const plainTextBackgroundForPicker = computed(() => {
const raw = String(props.selectedElement?.style?.backgroundColor ?? '').trim().toLowerCase();
if (!raw || raw === 'transparent' || raw === 'none' || raw === 'rgba(0,0,0,0)' || raw === 'rgba(0, 0, 0, 0)') {
return '#ffffff';
}
return raw;
});
/** 左侧「参数」页维护的键,供下拉绑定 */
const bindingParamOptions = computed(() =>
(props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => ({
value: p.key,
label: p.label ? `${p.key}${p.label}` : p.key,
})),
);
/** 左侧「字段」页登记的明细数据源,供普通/明细表格 source 下拉 */
const detailTableSourceOptions = computed(() => {
const tables = (props.schema.dataBinding?.detailTables ?? []) as NativeDataBindingDetailTable[];
const opts = tables.map((t) => ({
value: t.tableKey,
label: t.label ? `${t.tableKey}${t.label}` : t.tableKey,
}));
const el = props.selectedElement;
if (!el || (el.type !== 'table' && el.type !== 'detailTable')) return opts;
const cur = String((el as any).source || '').trim();
if (!cur || opts.some((o) => o.value === cur)) return opts;
return [{ value: cur, label: `${cur}(未登记,请迁移或到「字段」页登记)` }, ...opts];
});
/** 当前表格元素 source 对应的明细字段列表 */
const detailFieldOptionsForCurrentSource = computed(() => {
const el = props.selectedElement;
if (!el || (el.type !== 'table' && el.type !== 'detailTable')) return [];
const src = String((el as any).source || '').trim();
const dt = (props.schema.dataBinding?.detailTables ?? []).find((t) => t.tableKey === src) as
| NativeDataBindingDetailTable
| undefined;
const fields = dt?.fields ?? [];
return fields.map((f) => ({
value: f.key,
label: f.label ? `${f.key}${f.label}` : f.key,
}));
});
function resolveParamBindSelectValue(raw: string | undefined | null) {
const k = String(raw || '').trim();
if (!k) return undefined;
const keys = new Set((props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => p.key));
return keys.has(k) ? k : undefined;
}
/** 表格数据源:已登记则正常;未登记的旧模板仍回显当前键 */
function resolveTableSourceSelectValue(raw: string | undefined | null) {
const k = String(raw || '').trim();
return k || undefined;
}
/** 列绑定字段:空为 undefined 便于 allow-clear */
function resolveDetailFieldBindSelectValue(raw: string | undefined | null) {
const k = String(raw || '').trim();
return k || undefined;
}
/** 参数显示名:有 label 用 label否则用键 */
function resolveParamDisplayName(paramKey: string) {
const k = String(paramKey || '').trim();
if (!k) return '';
const p = (props.schema.dataBinding?.params ?? []).find((x: NativeDataBindingParam) => x.key === k);
if (p?.label && String(p.label).trim()) return String(p.label).trim();
return k;
}
/** 元素绑定参数变更:标题/文本/日期类自动把「文本」设为参数显示名 */
function handleElementBindParamChange(value: string | undefined) {
if (!props.selectedElement) return;
const v = value ?? '';
const t = props.selectedElement.type;
const patch: Record<string, unknown> = { bindField: v };
if (v && ['title', 'subtitle', 'text', 'date'].includes(t)) {
patch.text = resolveParamDisplayName(v);
}
emit('update-element', { id: props.selectedElement.id, patch: patch as any });
}
/** 明细字段显示名:有 label 用 label否则用字段键 */
function resolveDetailFieldDisplayName(tableKey: string, fieldKey: string) {
const tk = String(tableKey || '').trim();
const fk = String(fieldKey || '').trim();
if (!tk || !fk) return '';
const dt = (props.schema.dataBinding?.detailTables ?? []).find((t) => t.tableKey === tk);
const f = dt?.fields?.find((x) => x.key === fk);
if (f?.label && String(f.label).trim()) return String(f.label).trim();
return fk;
}
/** 表格列绑定明细字段:同步列标题为字段显示名(或字段键) */
function handleTableColumnBindDetailFieldChange(value: string | undefined) {
const v = value ?? '';
if (!props.selectedElement || (props.selectedElement.type !== 'table' && props.selectedElement.type !== 'detailTable')) {
return;
}
if (!selectedTableColumnInfo.value) return;
const src = String((props.selectedElement as any).source || '').trim();
const idx = selectedTableColumnInfo.value.index;
const columns = tableColumns.value.map((item) => ({ ...item }));
const target = columns[idx];
if (!target) return;
target.bindField = v;
target.field = v;
if (v) {
const display = resolveDetailFieldDisplayName(src, v);
target.title = display || v;
}
emit('update-element', { id: props.selectedElement.id, patch: { columns } as any });
}
/** 切换数据源:清空不在新数据源中的列绑定 */
function handleTableSourceChange(value: string | undefined) {
if (!props.selectedElement || (props.selectedElement.type !== 'table' && props.selectedElement.type !== 'detailTable')) {
return;
}
const v = String(value || '').trim();
const fieldKeys = new Set(
(props.schema.dataBinding?.detailTables ?? []).find((t) => t.tableKey === v)?.fields?.map((f) => f.key) ?? [],
);
const columns = tableColumns.value.map((item) => {
const c = { ...item };
const bf = String(c.bindField || '').trim();
if (bf && !fieldKeys.has(bf)) {
c.bindField = '';
c.field = '';
}
return c;
});
emit('update-element', { id: props.selectedElement.id, patch: { source: v || undefined, columns } as any });
}
/** 自由表格单元格绑定参数:同步单元格文本为参数显示名(单次提交避免状态不同步) */
function handleFreeTableCellBindParamChange(value: string | undefined) {
const v = value ?? '';
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const info = selectedFreeTableCellInfo.value;
if (!info) return;
const rowCount = Math.max(1, Number((props.selectedElement as any).rowCount || 1));
const colCount = Math.max(1, Number((props.selectedElement as any).colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (props.selectedElement as any).cells || []);
const display = v ? resolveParamDisplayName(v) : undefined;
const next = anchors.map((item: any) => {
if (item.row === info.row && item.col === info.col) {
const row: any = { ...item, bindField: v };
if (v && display) row.text = display;
return row;
}
return { ...item };
});
updateFreeTableElement({ cells: next });
}
const emit = defineEmits(['update-element']);
const headerConfigModalOpen = ref(false);
const columnWidthModalOpen = ref(false);
const TABLE_COLUMN_CONTENT_TYPE_OPTIONS = [
{ label: '文本', value: 'text' },
{ label: '数字', value: 'number' },
{ label: '金额', value: 'amount' },
{ label: '图片', value: 'image' },
{ label: '二维码', value: 'qrcode' },
{ label: '条形码', value: 'barcode' },
];
const AMOUNT_TYPE_OPTIONS = [
{ label: '人民币(CNY)', value: 'CNY' },
{ label: '美元(USD)', value: 'USD' },
{ label: '欧元(EUR)', value: 'EUR' },
];
const TABLE_HEIGHT_MODE_OPTIONS = [
{ label: '按数据自动分页', value: 'autoPage' },
{ label: '固定行数分页', value: 'fixedRows' },
];
const FOOTER_TOTAL_MODE_OPTIONS = [
{ label: '合计模式:总合计', value: 'overall' },
{ label: '合计模式:按页合计', value: 'page' },
];
const IMAGE_FIT_OPTIONS = [
{ label: '拉伸填充', value: 'fill' },
{ label: '等比包含', value: 'contain' },
{ label: '等比裁切', value: 'cover' },
];
const QRCODE_LEVEL_OPTIONS = [
{ label: 'L低容错', value: 'L' },
{ label: 'M中容错', value: 'M' },
{ label: 'Q高容错', value: 'Q' },
{ label: 'H最高容错', value: 'H' },
];
const QRCODE_RENDER_OPTIONS = [
{ label: 'PNG', value: 'image/png' },
{ label: 'JPEG', value: 'image/jpeg' },
{ label: 'WEBP', value: 'image/webp' },
];
const BARCODE_FORMAT_OPTIONS = [
{ label: 'CODE128', value: 'CODE128' },
{ label: 'CODE39', value: 'CODE39' },
{ label: 'EAN13', value: 'EAN13' },
{ label: 'EAN8', value: 'EAN8' },
{ label: 'ITF14', value: 'ITF14' },
{ label: 'MSI', value: 'MSI' },
{ label: 'pharmacode', value: 'pharmacode' },
];
const REFRESH_PAGE_OPTIONS = [
{ label: '刷新页:不应用', value: 'none' },
{ label: '刷新页:每次', value: 'always' },
{ label: '刷新页:溢出时', value: 'onOverflow' },
];
const FREE_TABLE_ALIGN_OPTIONS = [
{ label: '单元格对齐:左', value: 'left' },
{ label: '单元格对齐:中', value: 'center' },
{ label: '单元格对齐:右', value: 'right' },
];
const FREE_TABLE_VALIGN_OPTIONS = [
{ label: '垂直对齐:上', value: 'top' },
{ label: '垂直对齐:中', value: 'middle' },
{ label: '垂直对齐:下', value: 'bottom' },
];
function updateRect(key: string, value: any) {
if (!props.selectedElement) return;
emit('update-element', { id: props.selectedElement.id, patch: { [key]: Number(value || 0) } as any });
}
function updateStyle(key: string, value: any) {
if (!props.selectedElement) return;
emit('update-element', {
id: props.selectedElement.id,
patch: {
style: {
...(props.selectedElement.style || {}),
[key]: value,
},
} as any,
});
}
function updateField(key: string, value: any) {
if (!props.selectedElement) return;
emit('update-element', { id: props.selectedElement.id, patch: { [key]: value } as any });
}
const tableColumns = computed<any[]>(() => {
if (!props.selectedElement || (props.selectedElement.type !== 'table' && props.selectedElement.type !== 'detailTable')) {
return [];
}
return Array.isArray((props.selectedElement as any).columns) ? (props.selectedElement as any).columns : [];
});
const selectedTableColumnInfo = computed<{ index: number; col: any } | null>(() => {
if (!tableColumns.value.length || !props.selectedTableColumnKey) {
return null;
}
const index = tableColumns.value.findIndex((item) => item?.key === props.selectedTableColumnKey);
if (index < 0) {
return null;
}
return { index, col: tableColumns.value[index] };
});
/** 当前列绑定字段下拉:含未登记的旧值,避免下拉空白 */
const detailFieldBindOptionsForColumn = computed(() => {
const base = detailFieldOptionsForCurrentSource.value;
const col = selectedTableColumnInfo.value?.col;
const bf = String(col?.bindField || col?.field || '').trim();
if (!bf || base.some((o) => o.value === bf)) return base;
return [{ value: bf, label: `${bf}(未登记)` }, ...base];
});
const selectedFreeTableCellInfo = computed<{ row: number; col: number; cell: any } | null>(() => {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') {
return null;
}
const row = Number(props.selectedFreeTableCell?.row ?? -1);
const col = Number(props.selectedFreeTableCell?.col ?? -1);
if (row < 0 || col < 0) {
return null;
}
const rowCount = Math.max(1, Number((props.selectedElement as any).rowCount || 1));
const colCount = Math.max(1, Number((props.selectedElement as any).colCount || 1));
if (row >= rowCount || col >= colCount) {
return null;
}
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (props.selectedElement as any).cells || []);
const owner = getFreeTableOwnerAt(anchors, row, col);
return { row: owner.row, col: owner.col, cell: owner };
});
const canMergeFreeTableSelection = computed(() => {
const rect = props.selectedFreeTableMergeRect;
if (!rect || !props.selectedElement || props.selectedElement.type !== 'freeTable') {
return false;
}
if (rect.r0 === rect.r1 && rect.c0 === rect.c1) {
return false;
}
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, el.cells || []);
return canMergeFreeTableRegion(anchors, rowCount, colCount, rect.r0, rect.c0, rect.r1, rect.c1);
});
const canSplitFreeTableMerged = computed(() => {
const info = selectedFreeTableCellInfo.value;
if (!info) return false;
const rs = Math.max(1, Number(info.cell.rowspan || 1));
const cs = Math.max(1, Number(info.cell.colspan || 1));
return rs > 1 || cs > 1;
});
const freeTableTrackEl = computed(() => {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') {
return null;
}
return props.selectedElement as any;
});
const freeTableColIndices = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return [];
}
const n = Math.max(1, Number(el.colCount || 1));
return Array.from({ length: n }, (_, i) => i);
});
const freeTableRowIndices = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return [];
}
const n = Math.max(1, Number(el.rowCount || 1));
return Array.from({ length: n }, (_, i) => i);
});
const freeTableColWidthsResolved = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return [];
}
return resolveFreeTableColWidthsMm(el);
});
const freeTableRowHeightsResolved = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return [];
}
return resolveFreeTableRowHeightsMm(el);
});
const freeTableMaxColWidthMm = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return 200;
}
const n = Math.max(1, Number(el.colCount || 1));
const w = Math.max(0.01, Number(el.w) || 0.01);
return Math.max(MIN_FREE_TABLE_TRACK_MM, w - MIN_FREE_TABLE_TRACK_MM * Math.max(0, n - 1));
});
const freeTableMaxRowHeightMm = computed(() => {
const el = freeTableTrackEl.value;
if (!el) {
return 200;
}
const n = Math.max(1, Number(el.rowCount || 1));
const h = Math.max(0.01, Number(el.h) || 0.01);
return Math.max(MIN_FREE_TABLE_TRACK_MM, h - MIN_FREE_TABLE_TRACK_MM * Math.max(0, n - 1));
});
const footerLabelColumnOptions = computed(() => {
const options = tableColumns.value.map((item) => ({
label: `标签列:${item?.title || item?.key}`,
value: item?.key,
}));
return options;
});
const mergeColumnOptions = computed(() => {
return tableColumns.value.map((item) => ({
label: `${item?.title || item?.key}${item?.bindField || item?.field || item?.key}`,
value: item?.key,
}));
});
function updateTableColumn(index: number, key: string, value: any) {
if (!props.selectedElement) return;
const columns = tableColumns.value.map((item) => ({ ...item }));
const target = columns[index];
if (!target) return;
target[key] = value;
if (key === 'bindField' && !target.field) {
target.field = value;
}
if (key === 'fontSize') {
target.useCustomFontSize = true;
}
emit('update-element', { id: props.selectedElement.id, patch: { columns } as any });
}
function updateMergeColumns(values: string[]) {
if (!props.selectedElement) return;
const selected = Array.isArray(values) ? values.map((item) => String(item || '')).filter(Boolean) : [];
const validKeys = new Set(tableColumns.value.map((item) => String(item?.key || '')));
const next = selected.filter((item) => validKeys.has(item));
updateField('mergeColumnKeys', next);
}
function addTableColumn() {
if (!props.selectedElement) return;
const columns = tableColumns.value.map((item) => ({ ...item }));
const nextIndex = columns.length + 1;
columns.push({
key: `col_${Date.now()}_${nextIndex}`,
title: `${nextIndex}`,
field: `field${nextIndex}`,
bindField: `field${nextIndex}`,
width: 30,
align: 'left',
contentType: 'text',
fontFamily: '',
fontSize: 12,
useCustomFontSize: false,
fontColor: '#111111',
autoFitFont: false,
autoWrap: true,
fillCell: true,
contentScale: 100,
imageFit: 'contain',
qrLevel: 'M',
qrRenderType: 'image/png',
barcodeFormat: 'CODE128',
decimalPlaces: 2,
roundHalfUp: true,
amountType: 'CNY',
enableFooterTotal: false,
});
emit('update-element', { id: props.selectedElement.id, patch: { columns, headerConfig: undefined } as any });
}
function removeTableColumn(index: number) {
if (!props.selectedElement) return;
const columns = tableColumns.value.map((item) => ({ ...item })).filter((_, idx) => idx !== index);
const validKeys = new Set(columns.map((item) => String(item?.key || '')));
const mergeColumnKeys = Array.isArray((props.selectedElement as any).mergeColumnKeys)
? (props.selectedElement as any).mergeColumnKeys.filter((item: string) => validKeys.has(String(item || '')))
: [];
emit('update-element', { id: props.selectedElement.id, patch: { columns, headerConfig: undefined, mergeColumnKeys } as any });
}
function updateSelectedTableColumn(key: string, value: any) {
if (!selectedTableColumnInfo.value) return;
updateTableColumn(selectedTableColumnInfo.value.index, key, value);
}
function updateFreeTableElement(nextPatch: any) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
emit('update-element', { id: props.selectedElement.id, patch: nextPatch as any });
}
function patchFreeTableOuterSide(side: 'top' | 'right' | 'bottom' | 'left', checked: boolean) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') {
return;
}
const el = props.selectedElement as any;
updateField('outerBorder', { ...(el.outerBorder || {}), [side]: checked });
}
function patchFreeTableInnerSide(key: 'horizontal' | 'vertical', checked: boolean) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') {
return;
}
const el = props.selectedElement as any;
updateField('innerBorder', { ...(el.innerBorder || {}), [key]: checked });
}
function handleFreeTableColWidthChange(index: number, val: number | string | null) {
const el = freeTableTrackEl.value;
if (!el) {
return;
}
const v = Number(val);
if (!Number.isFinite(v)) {
return;
}
const cur = resolveFreeTableColWidthsMm(el);
const next = setColWidthAt(cur, index, v, Math.max(0.01, Number(el.w) || 0.01));
if (!next) {
createMessage.warning(`无法满足该列宽(单列最小约 ${MIN_FREE_TABLE_TRACK_MM}mm`);
return;
}
updateFreeTableElement({ colWidths: next });
}
function handleFreeTableRowHeightChange(index: number, val: number | string | null) {
const el = freeTableTrackEl.value;
if (!el) {
return;
}
const v = Number(val);
if (!Number.isFinite(v)) {
return;
}
const cur = resolveFreeTableRowHeightsMm(el);
const next = setRowHeightAt(cur, index, v, Math.max(0.01, Number(el.h) || 0.01));
if (!next) {
createMessage.warning(`无法满足该行高(单行最小约 ${MIN_FREE_TABLE_TRACK_MM}mm`);
return;
}
updateFreeTableElement({ rowHeights: next });
}
function evenFreeTableColWidths() {
const el = freeTableTrackEl.value;
if (!el) {
return;
}
const cc = Math.max(1, Number(el.colCount || 1));
const w = Math.max(0.01, Number(el.w) || 0.01);
updateFreeTableElement({ colWidths: buildEvenColWidths(cc, w) });
}
function evenFreeTableRowHeights() {
const el = freeTableTrackEl.value;
if (!el) {
return;
}
const rc = Math.max(1, Number(el.rowCount || 1));
const h = Math.max(0.01, Number(el.h) || 0.01);
updateFreeTableElement({ rowHeights: buildEvenRowHeights(rc, h) });
}
/** 行/列变化后:裁剪越界锚点并填满网格 */
function rebuildFreeTableCells(rowCount: number, colCount: number, baseCells: any[] = []) {
const clipped = clipAnchorsToGrid(rowCount, colCount, baseCells);
return normalizeFreeTableAnchors(rowCount, colCount, clipped);
}
function mergeFreeTableSelection() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const rect = props.selectedFreeTableMergeRect;
if (!rect) return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
let anchors = normalizeFreeTableAnchors(rowCount, colCount, el.cells || []);
if (!canMergeFreeTableRegion(anchors, rowCount, colCount, rect.r0, rect.c0, rect.r1, rect.c1)) {
createMessage.warning('所选区域无法合并(需均为未合并的 1×1 单元格,且为完整矩形)');
return;
}
anchors = mergeFreeTableRegion(anchors, rowCount, colCount, rect.r0, rect.c0, rect.r1, rect.c1);
updateFreeTableElement({ cells: anchors });
createMessage.success('已合并单元格');
}
function splitFreeTableMerged() {
const info = selectedFreeTableCellInfo.value;
if (!props.selectedElement || props.selectedElement.type !== 'freeTable' || !info) return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, el.cells || []);
const next = splitFreeTableAt(anchors, rowCount, colCount, info.row, info.col);
updateFreeTableElement({ cells: next });
createMessage.success('已拆分合并单元格');
}
function addFreeTableRow() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1)) + 1;
const colCount = Math.max(1, Number(el.colCount || 1));
const cells = rebuildFreeTableCells(rowCount, colCount, el.cells || []);
const prevRh = resolveFreeTableRowHeightsMm(el);
const nextRh = rowHeightsAfterAddRow(prevRh, Math.max(0.01, Number(el.h) || 0.01));
updateFreeTableElement({ rowCount, cells, rowHeights: nextRh });
}
function removeFreeTableRow() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const prevRowCount = Math.max(1, Number(el.rowCount || 1));
if (prevRowCount <= 1) return;
const rowCount = prevRowCount - 1;
const colCount = Math.max(1, Number(el.colCount || 1));
const cells = rebuildFreeTableCells(rowCount, colCount, el.cells || []);
const prevRh = resolveFreeTableRowHeightsMm(el);
const nextRh = rowHeightsAfterRemoveRow(prevRh, prevRowCount - 1, Math.max(0.01, Number(el.h) || 0.01));
updateFreeTableElement({ rowCount, cells, rowHeights: nextRh });
}
function addFreeTableCol() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1)) + 1;
const cells = rebuildFreeTableCells(rowCount, colCount, el.cells || []);
const prevCw = resolveFreeTableColWidthsMm(el);
const nextCw = colWidthsAfterAddCol(prevCw, Math.max(0.01, Number(el.w) || 0.01));
updateFreeTableElement({ colCount, cells, colWidths: nextCw });
}
function removeFreeTableCol() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const prevColCount = Math.max(1, Number(el.colCount || 1));
if (prevColCount <= 1) return;
const colCount = prevColCount - 1;
const cells = rebuildFreeTableCells(rowCount, colCount, el.cells || []);
const prevCw = resolveFreeTableColWidthsMm(el);
const nextCw = colWidthsAfterRemoveCol(prevCw, prevColCount - 1, Math.max(0.01, Number(el.w) || 0.01));
updateFreeTableElement({ colCount, cells, colWidths: nextCw });
}
function updateSelectedFreeTableCell(key: string, value: any) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const info = selectedFreeTableCellInfo.value;
if (!info) return;
const rowCount = Math.max(1, Number((props.selectedElement as any).rowCount || 1));
const colCount = Math.max(1, Number((props.selectedElement as any).colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (props.selectedElement as any).cells || []);
const next = anchors.map((item: any) => {
if (item.row === info.row && item.col === info.col) {
const row = { ...item };
if (String(key).startsWith('hideBorder')) {
if (value) {
row[key] = true;
} else {
delete row[key];
}
} else {
row[key] = value;
}
return row;
}
return { ...item };
});
updateFreeTableElement({ cells: next });
}
function removeSelectedTableColumn() {
if (!selectedTableColumnInfo.value) return;
removeTableColumn(selectedTableColumnInfo.value.index);
}
function resolveFooterLabelColumnKey() {
const key = String((props.selectedElement as any)?.footerLabelColumnKey || '');
if (key) {
return key;
}
return tableColumns.value?.[0]?.key || '';
}
function resolveHeaderConfigRowCount() {
const count = Number((props.selectedElement as any)?.headerConfig?.rowCount || 1);
if (Number.isFinite(count) && count > 0) {
return count;
}
return 1;
}
function handleHeaderConfigChange(nextHeaderConfig: any) {
if (!props.selectedElement) return;
const nextColumns = syncColumnTitlesByHeaderConfig(tableColumns.value, nextHeaderConfig);
emit('update-element', {
id: props.selectedElement.id,
patch: {
headerConfig: nextHeaderConfig,
columns: nextColumns,
} as any,
});
}
function handleMultiHeaderToggle(checked: boolean) {
updateField('enableMultiHeader', checked === true);
if (checked !== true) {
headerConfigModalOpen.value = false;
}
}
function syncColumnTitlesByHeaderConfig(columns: any[], headerConfig: any) {
const colCount = columns.length;
const rowCount = Math.max(1, Number(headerConfig?.rowCount || 1));
const owner: any[][] = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => null));
const cells = Array.isArray(headerConfig?.cells) ? headerConfig.cells : [];
cells.forEach((item: any) => {
const row = Math.max(0, Number(item?.row || 0));
const col = Math.max(0, Number(item?.col || 0));
const rowspan = Math.max(1, Number(item?.rowspan || 1));
const colspan = Math.max(1, Number(item?.colspan || 1));
if (row >= rowCount || col >= colCount || owner[row][col]) return;
const maxRow = Math.min(rowCount, row + rowspan);
const maxCol = Math.min(colCount, col + colspan);
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
if (owner[r][c]) return;
}
}
const next = { ...item, row, col, rowspan: maxRow - row, colspan: maxCol - col };
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
owner[r][c] = next;
}
}
});
return columns.map((col, index) => {
const bottomCell = owner[rowCount - 1]?.[index];
const nextTitle =
bottomCell && Number(bottomCell?.row ?? rowCount - 1) < rowCount - 1
? String(col?.title || `${index + 1}`)
: String(bottomCell?.title || col?.title || `${index + 1}`);
return { ...col, title: nextTitle };
});
}
function handleColumnContentTypeChange(value: string) {
const type = String(value || 'text');
updateSelectedTableColumn('contentType', type);
if (type === 'text') {
updateSelectedTableColumn('autoWrap', true);
updateSelectedTableColumn('autoFitFont', false);
return;
}
if (type === 'number' || type === 'amount') {
updateSelectedTableColumn('decimalPlaces', 2);
updateSelectedTableColumn('roundHalfUp', true);
updateSelectedTableColumn('amountType', 'CNY');
updateSelectedTableColumn('enableFooterTotal', false);
return;
}
if (type === 'image') {
updateSelectedTableColumn('fillCell', true);
updateSelectedTableColumn('contentScale', 100);
updateSelectedTableColumn('imageFit', 'contain');
return;
}
if (type === 'qrcode') {
updateSelectedTableColumn('fillCell', true);
updateSelectedTableColumn('contentScale', 100);
updateSelectedTableColumn('qrLevel', 'M');
updateSelectedTableColumn('qrRenderType', 'image/png');
return;
}
if (type === 'barcode') {
updateSelectedTableColumn('fillCell', true);
updateSelectedTableColumn('contentScale', 100);
updateSelectedTableColumn('barcodeFormat', 'CODE128');
}
}
function isText(type: string) {
return ['title', 'subtitle', 'text', 'date', 'pageNo', 'reportHeader', 'reportFooter'].includes(type);
}
function isReportBand(type: string) {
return type === 'reportHeader' || type === 'reportFooter';
}
function isReportFooter(type: string) {
return type === 'reportFooter';
}
function toColorHex(value: string) {
if (!value) return '#ffffff';
const color = String(value).trim();
const lower = color.toLowerCase();
if (lower === 'transparent' || lower === 'none' || lower === 'rgba(0,0,0,0)' || lower === 'rgba(0, 0, 0, 0)') {
return '#ffffff';
}
if (/^#([0-9a-fA-F]{6})$/.test(color)) return color;
if (/^#([0-9a-fA-F]{3})$/.test(color)) {
const short = color.slice(1);
return `#${short[0]}${short[0]}${short[1]}${short[1]}${short[2]}${short[2]}`;
}
return '#000000';
}
function handleColorInput(key: string, event: Event, fallback: string) {
const value = (event?.target as HTMLInputElement | null)?.value || fallback;
updateField(key, value);
}
function handleColumnColorInput(event: Event) {
const value = (event?.target as HTMLInputElement | null)?.value || '#111111';
updateSelectedTableColumn('fontColor', value);
}
function handleStyleColorInput(key: string, event: Event, fallback: string) {
const value = (event?.target as HTMLInputElement | null)?.value || fallback;
updateStyle(key, value);
}
function handlePlainTextBackgroundColorInput(event: Event) {
const value = (event?.target as HTMLInputElement | null)?.value || '#ffffff';
updateStyle('backgroundColor', value);
}
function handleFreeTableCellColorInput(key: 'color' | 'backgroundColor', event: Event, fallback: string) {
const value = (event?.target as HTMLInputElement | null)?.value || fallback;
updateSelectedFreeTableCell(key, value);
}
</script>
<style scoped lang="less">
.properties-panel {
display: flex;
flex-direction: column;
height: calc(100vh - 130px);
padding: 0;
overflow: hidden;
border-left: 1px solid #f0f0f0;
background: #fff;
.properties-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
min-height: 44px;
box-sizing: border-box;
}
.properties-panel__title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
line-height: 1.4;
}
.properties-panel__header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.properties-panel__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
margin: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: rgba(0, 0, 0, 0.55);
line-height: 1;
&:hover {
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
border-color: rgba(22, 119, 255, 0.2);
}
}
.properties-panel__icon-svg {
display: block;
flex-shrink: 0;
}
.properties-panel__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.panel-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
cursor: pointer;
}
.panel-subtitle {
margin-bottom: 6px;
font-size: 12px;
color: #666;
cursor: pointer;
}
.section-card {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
background: #fff;
> summary {
list-style: none;
}
> summary::-webkit-details-marker {
display: none;
}
}
.table-column-list {
display: flex;
flex-direction: column;
gap: 8px;
.table-column-item {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
background: #fafafa;
}
}
.color-input-row {
display: flex;
align-items: center;
gap: 8px;
:deep(.ant-input-group-wrapper) {
flex: 1;
}
}
/** 原生取色器:与文本框并排展示 */
.native-color-picker-trigger {
flex: 0 0 28px;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
box-sizing: border-box;
overflow: hidden;
background: #fff;
}
.column-width-entry-btn {
margin-top: 4px;
font-weight: 600;
}
.column-width-modal {
.column-width-modal-tip {
margin-bottom: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #389e0d;
font-size: 12px;
}
.column-width-list {
max-height: 420px;
overflow: auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-right: 4px;
}
.column-width-item {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 10px;
background: #fafafa;
display: flex;
flex-direction: column;
gap: 8px;
}
.column-width-item-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.column-width-index {
display: inline-block;
min-width: 48px;
text-align: center;
border-radius: 10px;
padding: 0 8px;
line-height: 20px;
font-size: 12px;
color: #1677ff;
background: #e6f4ff;
}
.column-width-title {
color: #666;
font-size: 12px;
}
.column-width-modal-footer {
margin-top: 12px;
text-align: right;
}
}
.free-table-merge-tip {
font-size: 12px;
color: #666;
line-height: 1.5;
padding: 6px 8px;
background: #fafafa;
border-radius: 6px;
border: 1px dashed #d9d9d9;
}
/* 行数/列数并排:避免 addon 组合框在窄侧栏溢出重叠 */
.free-table-dim-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
width: 100%;
}
.free-table-dim-item {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.free-table-dim-label {
flex: 0 0 auto;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
white-space: nowrap;
}
.free-table-dim-item :deep(.ant-input-number) {
flex: 1;
min-width: 0;
width: 100%;
}
.free-table-track-head {
font-size: 12px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
margin-top: 4px;
}
.free-table-track-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.free-table-track-label {
flex: 0 0 52px;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
.free-table-border-switch-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 6px;
align-items: center;
width: 100%;
}
.free-table-border-switch-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.free-table-border-switch-label {
font-size: 11px;
color: rgba(0, 0, 0, 0.55);
}
/** 自由表格线型下拉:与画笔样式类似,用线条预览 */
.free-table-line-style-option {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
}
.free-table-line-preview {
flex: 0 0 56px;
height: 0;
border-top: 2px solid rgba(0, 0, 0, 0.82);
box-sizing: border-box;
}
.free-table-line-preview--solid {
border-top-style: solid;
}
.free-table-line-preview--dashed {
border-top-style: dashed;
}
.free-table-line-preview--dotted {
border-top-style: dotted;
}
.free-table-line-preview--dash_dot {
border-top: none;
height: 2px;
background: repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.82) 0 5px,
transparent 5px 7px,
rgba(0, 0, 0, 0.82) 7px 8px,
transparent 8px 14px
);
}
.free-table-line-preview--double_dash_dot {
border-top: none;
height: 2px;
background: repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.82) 0 5px,
transparent 5px 7px,
rgba(0, 0, 0, 0.82) 7px 8px,
transparent 8px 9px,
rgba(0, 0, 0, 0.82) 9px 10px,
transparent 10px 16px
);
}
.bind-param-compact {
display: flex;
width: 100%;
align-items: stretch;
border: 1px solid #d9d9d9;
border-radius: 6px;
overflow: hidden;
background: #fff;
.bind-param-compact__addon {
flex: 0 0 80px;
max-width: 88px;
padding: 0 10px;
display: flex;
align-items: center;
background: #fafafa;
border-right: 1px solid #d9d9d9;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 32px;
}
.bind-param-compact__select {
flex: 1;
min-width: 0;
:deep(.ant-select-selector) {
border: none !important;
box-shadow: none !important;
}
}
}
}
</style>