Skip to content

Commit f653d73

Browse files
pythonHuangclaude
andcommitted
feat: 报表边框完整设置-上/下/左/右分边+粗细(1/2/3px)+颜色+线型(实线/虚线/点线/双线)+交叉斜线(HTML渐变+Excel对角线),单元格背景图(URL+填充方式),文档背景图(页面设置,HTML预览),修复border:false未移除默认边框的渲染bug
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent e4ea26c commit f653d73

2 files changed

Lines changed: 191 additions & 12 deletions

File tree

‎Juggle.Application/Services/Impl/ReportExecutionService.cs‎

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ private static string RenderHtmlCore(JsonElement root, Dictionary<string, DataTa
136136
var materialized = MaterializeRows(root, datasets);
137137
var templateUsed = new HashSet<(int r, int c)>(); // 静态行之间的跨行合并占用
138138

139+
// 文档背景图:包一层容器渲染
140+
var pageBg = root.TryGetProperty("page", out var pgEl)
141+
&& pgEl.TryGetProperty("bgImage", out var pbi) && pbi.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(pbi.GetString())
142+
? pbi.GetString() : null;
143+
if (pageBg != null)
144+
sb.AppendLine($"<div style=\"background-image:url('{pageBg}');background-size:cover;background-position:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;padding:8px;\">");
145+
139146
sb.AppendLine("<table>");
140147
// 列宽所见即所得:按设计器 cols[].width 生成 colgroup
141148
if (root.TryGetProperty("cols", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array)
@@ -167,7 +174,7 @@ private static string RenderHtmlCore(JsonElement root, Dictionary<string, DataTa
167174
var rowspan = hasCell ? Math.Max(1, GetInt(cell, "rowspan", 1)) : 1;
168175
if (rr.DataIndex != null) rowspan = 1; // 数据扩展行不支持跨行合并
169176

170-
var style = hasCell ? "border:1px solid #ccc;" + ReadCellStyle(cell) : "border:1px solid #ccc;";
177+
var style = ReadCellStyle(cell, hasCell);
171178
if (rowHeight > 0) style += $"height:{rowHeight}px;";
172179
// 斑马纹:偶数数据行加浅灰底(单元格自带背景色时优先生效)
173180
if (rr.Zebra && rr.RowNumber % 2 == 0 && (!hasCell || !HasBgColor(cell)))
@@ -186,7 +193,9 @@ private static string RenderHtmlCore(JsonElement root, Dictionary<string, DataTa
186193
}
187194
sb.AppendLine("</tr>");
188195
}
189-
sb.AppendLine("</table></body></html>");
196+
sb.AppendLine("</table>");
197+
if (pageBg != null) sb.AppendLine("</div>");
198+
sb.AppendLine("</body></html>");
190199
return sb.ToString();
191200
}
192201

@@ -399,11 +408,13 @@ private static int GetRowHeight(JsonElement root, int rowIndex)
399408
private static int GetInt(JsonElement el, string prop, int def)
400409
=> el.TryGetProperty(prop, out var v) ? Math.Max(1, v.GetInt32()) : def;
401410

402-
/// <summary>读取单元格样式为 HTML 内联样式追加内容。</summary>
403-
private static string ReadCellStyle(JsonElement cell)
411+
/// <summary>读取单元格样式为 HTML 内联样式(含默认边框处理)。</summary>
412+
private static string ReadCellStyle(JsonElement cell, bool hasCell)
404413
{
405-
var style = "";
406-
if (cell.TryGetProperty("style", out var sEl))
414+
var hasBorderProp = hasCell && cell.TryGetProperty("style", out var sEl) && sEl.TryGetProperty("border", out _);
415+
// 未显式设置边框时给默认细边框;设置了(false/对象)则由下面逻辑接管
416+
var style = hasBorderProp ? "" : "border:1px solid #ccc;";
417+
if (hasCell && cell.TryGetProperty("style", out sEl))
407418
{
408419
if (sEl.TryGetProperty("bold", out var b) && b.ValueKind == JsonValueKind.True) style += "font-weight:bold;";
409420
if (sEl.TryGetProperty("italic", out var it) && it.ValueKind == JsonValueKind.True) style += "font-style:italic;";
@@ -413,7 +424,35 @@ private static string ReadCellStyle(JsonElement cell)
413424
if (sEl.TryGetProperty("color", out var cl) && cl.ValueKind == JsonValueKind.String) style += $"color:{cl.GetString()};";
414425
if (sEl.TryGetProperty("bgColor", out var bg) && bg.ValueKind == JsonValueKind.String) style += $"background-color:{bg.GetString()};";
415426
if (sEl.TryGetProperty("align", out var al) && al.ValueKind == JsonValueKind.String) style += $"text-align:{al.GetString()};";
416-
if (sEl.TryGetProperty("border", out var bd) && bd.ValueKind == JsonValueKind.False) style = style.Replace("border:1px solid #ccc;", "");
427+
428+
// 边框:对象=分边(上/下/左/右)/粗细/颜色/线型/交叉斜线;false=无边框
429+
if (sEl.TryGetProperty("border", out var bdEl) && bdEl.ValueKind == JsonValueKind.Object)
430+
{
431+
var bw = GetInt(bdEl, "width", 1);
432+
var bc = bdEl.TryGetProperty("color", out var bce) && bce.ValueKind == JsonValueKind.String ? bce.GetString() : "#333";
433+
var bs = bdEl.TryGetProperty("style", out var bse) && bse.ValueKind == JsonValueKind.String ? bse.GetString() : "solid";
434+
var bt = bdEl.TryGetProperty("top", out var bte) && bte.ValueKind == JsonValueKind.True;
435+
var bb = bdEl.TryGetProperty("bottom", out var bbe) && bbe.ValueKind == JsonValueKind.True;
436+
var bl = bdEl.TryGetProperty("left", out var ble) && ble.ValueKind == JsonValueKind.True;
437+
var br = bdEl.TryGetProperty("right", out var bre) && bre.ValueKind == JsonValueKind.True;
438+
if (bt) style += $"border-top:{bw}px {bs} {bc};";
439+
if (bb) style += $"border-bottom:{bw}px {bs} {bc};";
440+
if (bl) style += $"border-left:{bw}px {bs} {bc};";
441+
if (br) style += $"border-right:{bw}px {bs} {bc};";
442+
// 交叉斜线:两条对角渐变
443+
if (bdEl.TryGetProperty("diagonal", out var bdD) && bdD.ValueKind == JsonValueKind.True)
444+
{
445+
var half = bw * 0.5;
446+
style += $"background-image:linear-gradient(to top right,transparent calc(50% - {half}px),{bc},transparent calc(50% + {half}px)),linear-gradient(to bottom right,transparent calc(50% - {half}px),{bc},transparent calc(50% + {half}px));";
447+
}
448+
}
449+
450+
// 单元格背景图
451+
if (sEl.TryGetProperty("bgImage", out var bgi) && bgi.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(bgi.GetString()))
452+
{
453+
var size = sEl.TryGetProperty("bgImageSize", out var bis) && bis.ValueKind == JsonValueKind.String ? bis.GetString() : "cover";
454+
style += $"background-image:url('{bgi.GetString()}');background-size:{size};background-position:center;background-repeat:no-repeat;";
455+
}
417456
}
418457
return style;
419458
}
@@ -492,8 +531,42 @@ private static byte[] ExportExcelCore(JsonElement root, Dictionary<string, DataT
492531
"right" => XLAlignmentHorizontalValues.Right,
493532
_ => XLAlignmentHorizontalValues.Left
494533
};
495-
if (se.TryGetProperty("border", out var bd) && bd.ValueKind != JsonValueKind.False)
496-
xlCell.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
534+
// 边框:false=无;对象=分边/粗细/颜色/线型/交叉斜线;true=旧格式外框细线
535+
if (se.TryGetProperty("border", out var bdEl))
536+
{
537+
if (bdEl.ValueKind == JsonValueKind.False)
538+
{
539+
xlCell.Style.Border.OutsideBorder = XLBorderStyleValues.None;
540+
xlCell.Style.Border.InsideBorder = XLBorderStyleValues.None;
541+
}
542+
else if (bdEl.ValueKind == JsonValueKind.Object)
543+
{
544+
var bw = GetInt(bdEl, "width", 1);
545+
var bc = bdEl.TryGetProperty("color", out var bce) && bce.ValueKind == JsonValueKind.String ? bce.GetString() : "#333";
546+
var bs = bdEl.TryGetProperty("style", out var bse) && bse.ValueKind == JsonValueKind.String ? bse.GetString() : "solid";
547+
var lineStyle = bw >= 2 ? XLBorderStyleValues.Medium : XLBorderStyleValues.Thin;
548+
if (bs == "dashed") lineStyle = XLBorderStyleValues.Dashed;
549+
else if (bs == "dotted") lineStyle = XLBorderStyleValues.Dotted;
550+
else if (bs == "double") lineStyle = XLBorderStyleValues.Double;
551+
var xlColor = XLColor.FromHtml(bc!);
552+
var xlBorder = xlCell.Style.Border;
553+
if (bdEl.TryGetProperty("top", out var bte) && bte.ValueKind == JsonValueKind.True) { xlBorder.TopBorder = lineStyle; xlBorder.TopBorderColor = xlColor; }
554+
if (bdEl.TryGetProperty("bottom", out var bbe) && bbe.ValueKind == JsonValueKind.True) { xlBorder.BottomBorder = lineStyle; xlBorder.BottomBorderColor = xlColor; }
555+
if (bdEl.TryGetProperty("left", out var ble) && ble.ValueKind == JsonValueKind.True) { xlBorder.LeftBorder = lineStyle; xlBorder.LeftBorderColor = xlColor; }
556+
if (bdEl.TryGetProperty("right", out var bre) && bre.ValueKind == JsonValueKind.True) { xlBorder.RightBorder = lineStyle; xlBorder.RightBorderColor = xlColor; }
557+
if (bdEl.TryGetProperty("diagonal", out var bdD) && bdD.ValueKind == JsonValueKind.True)
558+
{
559+
xlBorder.DiagonalBorder = lineStyle;
560+
xlBorder.DiagonalBorderColor = xlColor;
561+
xlBorder.DiagonalUp = true;
562+
xlBorder.DiagonalDown = true;
563+
}
564+
}
565+
else if (bdEl.ValueKind != JsonValueKind.False)
566+
{
567+
xlCell.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
568+
}
569+
}
497570
}
498571

499572
// 斑马纹:偶数数据行浅灰底(单元格自带背景色时优先生效)

‎JuggleNet6.Frontend/src/views/report/ReportDesigner.vue‎

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,40 @@
164164
<div v-else-if="rowType==='footer'" style="margin-top:6px;color:#909399;font-size:11px;line-height:1.6">
165165
渲染 1 次(数据行下方)。绑定数据集后支持聚合:<code>${金额:SUM}</code>、<code>${字段:AVG}</code>、MIN / MAX / COUNT。
166166
</div>
167+
168+
<p style="margin:10px 0 4px">边框:</p>
169+
<div style="display:flex;gap:4px;flex-wrap:wrap">
170+
<el-button size="small" :type="bdState.top?'primary':''" @click="setBorderSide('top')">上</el-button>
171+
<el-button size="small" :type="bdState.bottom?'primary':''" @click="setBorderSide('bottom')">下</el-button>
172+
<el-button size="small" :type="bdState.left?'primary':''" @click="setBorderSide('left')">左</el-button>
173+
<el-button size="small" :type="bdState.right?'primary':''" @click="setBorderSide('right')">右</el-button>
174+
<el-button size="small" @click="setBorderAll(true)">全部</el-button>
175+
<el-button size="small" @click="setBorderAll(false)">无</el-button>
176+
<el-button size="small" :type="bdState.diagonal?'primary':''" @click="setBorderDiagonal">交叉</el-button>
177+
</div>
178+
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
179+
<el-select v-model="bdStyle" size="small" style="width:80px" @change="applyBorderMeta">
180+
<el-option value="solid" label="实线" />
181+
<el-option value="dashed" label="虚线" />
182+
<el-option value="dotted" label="点线" />
183+
<el-option value="double" label="双线" />
184+
</el-select>
185+
<el-select v-model="bdWidth" size="small" style="width:66px" @change="applyBorderMeta">
186+
<el-option :value="1" label="1px" />
187+
<el-option :value="2" label="2px" />
188+
<el-option :value="3" label="3px" />
189+
</el-select>
190+
<el-color-picker v-model="bdColor" size="small" @change="applyBorderMeta" />
191+
</div>
192+
193+
<p style="margin:10px 0 4px">单元格背景图:</p>
194+
<el-input v-model="bgImage" size="small" placeholder="图片 URL(可留空清除)" clearable @change="applyBgImage" />
195+
<p style="margin:10px 0 4px">背景图填充:</p>
196+
<el-select v-model="bgImageSize" size="small" style="width:100%" @change="applyBgImage">
197+
<el-option value="cover" label="铺满(cover)" />
198+
<el-option value="contain" label="完整(contain)" />
199+
<el-option value="auto" label="原始大小(auto)" />
200+
</el-select>
167201
</div>
168202
<el-empty v-else description="点击单元格查看属性" />
169203
</div>
@@ -181,6 +215,7 @@
181215
<el-form-item label="页边距(px)"><el-input v-model="pageMargin.top" placeholder="上" style="width:60px" /><span style="margin:0 4px">-</span><el-input v-model="pageMargin.right" placeholder="右" style="width:60px" /><span style="margin:0 4px">-</span><el-input v-model="pageMargin.bottom" placeholder="下" style="width:60px" /><span style="margin:0 4px">-</span><el-input v-model="pageMargin.left" placeholder="左" style="width:60px" /></el-form-item>
182216
<el-form-item label="页眉"><el-input v-model="pageHeader" placeholder="如: &quot;销售报表 - ${date}&quot;" /></el-form-item>
183217
<el-form-item label="页脚"><el-input v-model="pageFooter" placeholder="如: &quot;第 ${page} 页 / 共 ${total} 页&quot;" /></el-form-item>
218+
<el-form-item label="背景图"><el-input v-model="pageBgImage" placeholder="文档背景图片 URL(可留空)" clearable /></el-form-item>
184219
</el-form>
185220
<template #footer><el-button @click="pageSettingsVisible=false">确定</el-button></template>
186221
</el-dialog>
@@ -393,6 +428,12 @@ const selectedCols = ref(new Set<number>())
393428
const ctrlDown = ref(false)
394429
const boldActive = ref(false), italicActive = ref(false)
395430
const selFontSize = ref(12), selFontName = ref('Microsoft YaHei'), selColor = ref(''), selBgColor = ref('')
431+
// 边框设置(分边/粗细/颜色/线型/交叉斜线)
432+
const bdState = reactive({ top: true, bottom: true, left: true, right: true, diagonal: false })
433+
const bdStyle = ref('solid'), bdWidth = ref(1), bdColor = ref('#cccccc')
434+
// 单元格背景图 / 文档背景图
435+
const bgImage = ref(''), bgImageSize = ref('cover')
436+
const pageBgImage = ref('')
396437
const cellValue = ref(''), rowType = ref('data')
397438
// 行类型与数据集绑定(完整行语义模型)
398439
const rowTypes = ref<string[]>([]) // 每行类型: title/header/data/footer
@@ -640,6 +681,7 @@ function loadLayoutJson() {
640681
pageSize.value = layout.page?.size||'A4'; pageOrientation.value = layout.page?.orientation||'portrait'
641682
if (layout.page?.margin) Object.assign(pageMargin, layout.page.margin)
642683
pageHeader.value = layout.page?.header||''; pageFooter.value = layout.page?.footer||''
684+
pageBgImage.value = layout.page?.bgImage||''
643685
cells.value = {}; if (layout.cells) for (const c of layout.cells) cells.value[`${c.r},${c.c}`] = c
644686
previewParams.value = layout.params||[]
645687
if (layout.datasets) datasets.value = layout.datasets.map((d:any)=>({...d,expanded:true,loading:false}))
@@ -661,7 +703,20 @@ function getCellStyle(r:number,c:number) {
661703
if (s.color) style+=`color:${s.color};`
662704
if (s.bgColor) style+=`background-color:${s.bgColor};`
663705
if (s.align) style+=`text-align:${s.align};`
664-
if (s.border===false) style+='border:none;'
706+
// 边框:false=无;对象=分边/粗细/颜色/线型/交叉斜线;缺省=默认细边框
707+
const b = s.border
708+
if (b === false) style+='border:none;'
709+
else if (b && typeof b === 'object') {
710+
const w=b.width||1, col=b.color||'#333', st=b.style||'solid'
711+
style+='border:none;'
712+
if (b.top) style+=`border-top:${w}px ${st} ${col};`
713+
if (b.bottom) style+=`border-bottom:${w}px ${st} ${col};`
714+
if (b.left) style+=`border-left:${w}px ${st} ${col};`
715+
if (b.right) style+=`border-right:${w}px ${st} ${col};`
716+
if (b.diagonal) style+=`background-image:linear-gradient(to top right,transparent calc(50% - ${w*0.5}px),${col},transparent calc(50% + ${w*0.5}px)),linear-gradient(to bottom right,transparent calc(50% - ${w*0.5}px),${col},transparent calc(50% + ${w*0.5}px));`
717+
}
718+
// 单元格背景图
719+
if (s.bgImage) style+=`background-image:url('${s.bgImage}');background-size:${s.bgImageSize||'cover'};background-position:center;background-repeat:no-repeat;`
665720
return style
666721
}
667722
@@ -694,6 +749,7 @@ function onCellMouseDown(_e:MouseEvent, r:number, c:number) {
694749
italicActive.value = cell?.style?.italic||false
695750
selFontSize.value = cell?.style?.fontSize||12
696751
selColor.value = cell?.style?.color||''; selBgColor.value = cell?.style?.bgColor||''
752+
loadBorderState(cell)
697753
}
698754
699755
function commitEdit() {
@@ -798,7 +854,57 @@ function onStyleChange(prop: 'color' | 'bgColor' | 'fontName' | 'fontSize') {
798854
applyStyle(prop, val)
799855
}
800856
801-
function toggleBorder() { pushHistory(); forEachSelected((r,c)=>{const key=`${r},${c}`,ex=cells.value[key]||{value:''},s=ex.style||{}; s.border=s.border===false?true:false; cells.value[key]={...ex,style:s}}) }
857+
// ===== 边框设置(分边/粗细/颜色/线型/交叉斜线) =====
858+
function loadBorderState(cell: any) {
859+
const b = cell?.style?.border
860+
if (b && typeof b === 'object') {
861+
bdState.top = !!b.top; bdState.bottom = !!b.bottom; bdState.left = !!b.left; bdState.right = !!b.right; bdState.diagonal = !!b.diagonal
862+
bdStyle.value = b.style || 'solid'; bdWidth.value = b.width || 1; bdColor.value = b.color || '#cccccc'
863+
} else {
864+
bdState.top = bdState.bottom = bdState.left = bdState.right = true; bdState.diagonal = false
865+
bdStyle.value = 'solid'; bdWidth.value = 1; bdColor.value = '#cccccc'
866+
}
867+
bgImage.value = cell?.style?.bgImage || ''
868+
bgImageSize.value = cell?.style?.bgImageSize || 'cover'
869+
}
870+
871+
function applyBorder() {
872+
pushHistory()
873+
forEachSelected((r:number, c:number) => {
874+
const key=`${r},${c}`, existing=cells.value[key]||{value:''}
875+
const style = {...(existing.style||{})}
876+
style.border = { top:bdState.top, bottom:bdState.bottom, left:bdState.left, right:bdState.right, diagonal:bdState.diagonal, width:bdWidth.value, style:bdStyle.value, color:bdColor.value }
877+
cells.value[key] = {...existing, style}
878+
})
879+
}
880+
function setBorderSide(side: 'top'|'bottom'|'left'|'right') { bdState[side] = !bdState[side]; applyBorder() }
881+
function setBorderAll(on: boolean) { bdState.top = bdState.bottom = bdState.left = bdState.right = on; applyBorder() }
882+
function setBorderDiagonal() { bdState.diagonal = !bdState.diagonal; applyBorder() }
883+
function applyBorderMeta() { applyBorder() }
884+
885+
/** 单元格背景图 */
886+
function applyBgImage() {
887+
pushHistory()
888+
forEachSelected((r:number, c:number) => {
889+
const key=`${r},${c}`, existing=cells.value[key]||{value:''}
890+
const style = {...(existing.style||{})}
891+
if (bgImage.value) { style.bgImage = bgImage.value; style.bgImageSize = bgImageSize.value }
892+
else { delete style.bgImage; delete style.bgImageSize }
893+
cells.value[key] = {...existing, style}
894+
})
895+
}
896+
897+
function toggleBorder() {
898+
pushHistory()
899+
forEachSelected((r:number,c:number) => {
900+
const key=`${r},${c}`, ex=cells.value[key]||{value:''}, s={...(ex.style||{})}
901+
const b = s.border
902+
const hasAny = b === undefined || b === true || (typeof b==='object' && (b.top||b.bottom||b.left||b.right))
903+
s.border = hasAny ? false : { top:true,bottom:true,left:true,right:true,width:1,style:'solid',color:'#cccccc' }
904+
cells.value[key] = {...ex, style:s}
905+
})
906+
loadBorderState(getCell(selR.value, selC.value))
907+
}
802908
803909
function mergeSelected() {
804910
if (selectedCells.value.length<2) return
@@ -944,7 +1050,7 @@ function saveLayoutJson() {
9441050
const cellArr=Object.entries(cells.value).map(([k,v]:any)=>({r:Number(k.split(',')[0]),c:Number(k.split(',')[1]),...v}))
9451051
const dsArr = datasets.value.map(d=>({id:d.id,name:d.name,sourceType:d.sourceType,sourceRef:d.sourceRef,customSql:d.customSql,dataSourceId:d.dataSourceId,fields:d.fields}))
9461052
form.layoutJson = JSON.stringify({
947-
page:{size:pageSize.value,orientation:pageOrientation.value,margin:{...pageMargin},header:pageHeader.value,footer:pageFooter.value},
1053+
page:{size:pageSize.value,orientation:pageOrientation.value,margin:{...pageMargin},header:pageHeader.value,footer:pageFooter.value,bgImage:pageBgImage.value},
9481054
params:previewParams.value, rows, cols, cells:cellArr,
9491055
datasets: dsArr
9501056
})

0 commit comments

Comments
 (0)