feat(public-todo): 备注升级为富文本,支持放大书写面板
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 26s

- 备注字段升级为富文本(库表 remark 已由 VARCHAR(1000) 改 TEXT)
- 自研轻量编辑器:contenteditable + execCommand,零依赖,保持单文件风格
  工具栏:加粗/斜体/下划线/删除线/标题/正文/引用/代码块/
          无序/有序列表/待办项/链接/清除格式
- 编辑弹窗右上角「放大」切换大面板(880px 宽,正文 48vh)
- 卡片备注默认纯文本摘要(2 行截断),点击展开完整富文本
- HTML 白名单净化:保存前与渲染前各一次,防存储型 XSS
- 兼容旧的纯文本备注数据
This commit is contained in:
张家训
2026-10-20 09:02:13 +08:00
parent 0e9ab36c94
commit 39bef6a68b
+198 -5
View File
@@ -180,6 +180,57 @@
.dp-quick button:hover { background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
.dp-quick button.dp-clear:hover { background: #fff1f2; color: var(--rose); }
/* ===== 富文本编辑器(contenteditable + execCommand 自研) ===== */
.rte { border: 1.5px solid var(--line); border-radius: 12px; background: #fbfcfe; overflow: hidden; transition: border-color .18s, box-shadow .18s, background .18s; }
.rte:focus-within { border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11); }
.rte-bar { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; padding: 7px 8px; border-bottom: 1px solid var(--line); background: #fff; }
.rte-bar .spacer { flex: 1; }
.rte-btn {
min-width: 28px; height: 28px; padding: 0 4px; border: none; background: transparent; border-radius: 7px;
color: var(--text-sub); cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; transition: background .14s, color .14s;
}
.rte-btn:hover { background: #eef1f8; color: var(--text); }
.rte-btn.on { background: rgba(99, 102, 241, .13); color: var(--indigo-dark); }
.rte-btn.txt { padding: 0 10px; font-size: 12.5px; gap: 4px; }
.rte-btn.warn:hover { background: #fff1f2; color: var(--rose); }
.rte-sep { width: 1px; height: 16px; background: var(--line); margin: 0 4px; }
.rte-body {
padding: 12px 14px; min-height: 150px; max-height: 44vh; overflow-y: auto;
font-size: 14px; line-height: 1.75; color: var(--text); outline: none; word-break: break-word;
}
.rte-body:empty::before { content: attr(data-ph); color: var(--text-muted); pointer-events: none; }
/* 放大书写面板:编辑弹窗右上角按钮切换 */
.modal.lg { max-width: 880px; }
.modal.lg .rte-body { min-height: 48vh; max-height: none; }
/* 富文本排版(编辑器与卡片展开共用 .fmt) */
.fmt h2 { font-size: 17px; font-weight: 700; margin: 10px 0 5px; letter-spacing: -.2px; }
.fmt p { margin: 4px 0; }
.fmt blockquote { border-left: 3px solid var(--indigo); margin: 8px 0; padding: 5px 12px; background: #f5f6ff; border-radius: 0 8px 8px 0; color: var(--text-sub); }
.fmt pre { background: #1e293b; color: #e2e8f0; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; font-family: Consolas, Monaco, monospace; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; }
.fmt code { background: #eef1f8; border-radius: 5px; padding: 1px 5px; font-size: .92em; color: var(--indigo-dark); font-family: Consolas, Monaco, monospace; }
.fmt pre code { background: transparent; color: inherit; padding: 0; }
.fmt ul, .fmt ol { padding-left: 22px; margin: 6px 0; }
.fmt a { color: var(--indigo); text-decoration: underline; }
.fmt .ck-line { display: flex; align-items: flex-start; gap: 7px; margin: 3px 0; }
.fmt .ck-box {
flex-shrink: 0; width: 16px; height: 16px; margin-top: 4px; border: 1.6px solid #cbd5e1;
border-radius: 5px; background: #fff; cursor: pointer; user-select: none;
display: inline-flex; align-items: center; justify-content: center;
}
.fmt .ck-box.ck-checked { background: linear-gradient(135deg, var(--indigo), var(--violet)); border-color: transparent; }
.fmt .ck-box.ck-checked::after { content: '✓'; color: #fff; font-size: 11px; font-weight: 800; }
/* 卡片备注:默认纯文本摘要,点击展开完整富文本 */
.note-box { margin-top: 6px; cursor: pointer; font-size: 13px; color: var(--text-sub); word-break: break-word; }
.note-box .plain { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.note-box .full { display: none; }
.note-box.open .plain { display: none; }
.note-box.open .full { display: block; padding: 10px 13px; background: #fafbff; border: 1px solid var(--line); border-radius: 10px; line-height: 1.75; cursor: default; }
.note-box .full .ck-box { pointer-events: none; }
.add-bar { padding: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
.add-bar .grow { flex: 1; min-width: 200px; }
.add-bar .grow input[type="text"] { border: none; background: transparent; font-size: 15px; padding-left: 4px; }
@@ -247,7 +298,6 @@
.body { flex: 1; min-width: 0; }
.title { font-size: 15px; font-weight: 600; word-break: break-word; }
.note { font-size: 13px; color: var(--text-sub); margin-top: 5px; word-break: break-word; white-space: pre-wrap; }
.meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: #f1f4fa; color: var(--text-sub); }
@@ -531,6 +581,58 @@
return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
}
/* ===== HTML 净化 =====
公开页面任何人可写,富文本存 HTML 再渲染即存储型 XSS。
保存前与渲染前各净化一次,仅放行白名单标签与属性。 */
var ALLOW_TAGS = { P: 1, BR: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, UL: 1, OL: 1, LI: 1, H2: 1, BLOCKQUOTE: 1, CODE: 1, PRE: 1, A: 1, SPAN: 1, DIV: 1 }
var ALLOW_CLASSES = ['ck-box', 'ck-checked', 'ck-line']
function sanitizeHtml(html) {
if (!html) return ''
// 直接解析 body 并整体净化,避免外层包裹被内容里的闭合标签截断
var doc = new DOMParser().parseFromString(html, 'text/html')
cleanNode(doc.body)
return doc.body.innerHTML
}
function cleanNode(node) {
Array.prototype.slice.call(node.childNodes).forEach(function (child) {
if (child.nodeType === 3) return // 文本节点保留
if (child.nodeType !== 1) { node.removeChild(child); return }
var tag = child.tagName
if (!ALLOW_TAGS[tag]) { // 白名单外:剥标签,只留文字
while (child.firstChild) node.insertBefore(child.firstChild, child)
node.removeChild(child)
return
}
Array.prototype.slice.call(child.attributes).forEach(function (a) {
var n = a.name.toLowerCase()
if (n === 'class') {
var keep = String(child.className || '').split(/\s+/).filter(function (c) { return ALLOW_CLASSES.indexOf(c) > -1 })
if (keep.length) child.className = keep.join(' ')
else child.removeAttribute('class')
} else if (tag === 'A' && n === 'href') {
if (!/^(https?:|mailto:)/i.test(a.value)) child.removeAttribute('href')
} else if (n === 'contenteditable') {
child.setAttribute('contenteditable', 'false')
} else {
child.removeAttribute(n)
}
})
if (tag === 'A') {
child.setAttribute('target', '_blank')
child.setAttribute('rel', 'noopener noreferrer')
}
cleanNode(child)
})
}
function stripHtml(html) {
var d = document.createElement('div')
d.innerHTML = html || ''
return (d.textContent || '').replace(/\s+/g, ' ').trim()
}
// 截止日期状态:overdue / today / soon / normal
function dueState(due) {
if (!due) return ''
@@ -769,6 +871,81 @@
return sel
}
/* ===== 富文本编辑器(自研,contenteditable + execCommand) ===== */
var RTE_BAR =
'<div class="rte-bar" contenteditable="false">'
+ '<button type="button" class="rte-btn" data-cmd="bold" title="加粗"><b>B</b></button>'
+ '<button type="button" class="rte-btn" data-cmd="italic" title="斜体"><i>I</i></button>'
+ '<button type="button" class="rte-btn" data-cmd="underline" title="下划线"><u>U</u></button>'
+ '<button type="button" class="rte-btn" data-cmd="strikeThrough" title="删除线"><s>S</s></button>'
+ '<span class="rte-sep"></span>'
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="P" title="正文">¶</button>'
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="H2" title="标题">H</button>'
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">❝</button>'
+ '<button type="button" class="rte-btn txt" data-cmd="formatBlock" data-val="PRE" title="代码块">&lt;/&gt;</button>'
+ '<span class="rte-sep"></span>'
+ '<button type="button" class="rte-btn" data-cmd="insertUnorderedList" title="无序列表">☰</button>'
+ '<button type="button" class="rte-btn" data-cmd="insertOrderedList" title="有序列表">≣</button>'
+ '<button type="button" class="rte-btn" data-cmd="insertCheckbox" title="待办项">☑</button>'
+ '<span class="rte-sep"></span>'
+ '<button type="button" class="rte-btn" data-cmd="createLink" title="插入链接">🔗</button>'
+ '<button type="button" class="rte-btn warn" data-cmd="removeFormat" title="清除格式">✕</button>'
+ '<span class="spacer"></span>'
+ '<button type="button" class="rte-btn txt" id="rteExpand" title="放大书写面板">⤢ 放大</button>'
+ '</div>'
function initEditor(root) {
var body = root.querySelector('.rte-body')
var bar = root.querySelector('.rte-bar')
// 点工具栏时阻止焦点跳走,保住正文里的文字选区
bar.addEventListener('mousedown', function (e) { e.preventDefault() })
bar.addEventListener('click', function (e) {
var btn = e.target.closest('.rte-btn')
if (!btn) return
if (btn.id === 'rteExpand') {
var modal = root.closest('.modal')
var lg = modal.classList.toggle('lg')
btn.innerHTML = lg ? '⤡ 收起' : '⤢ 放大'
btn.title = lg ? '恢复默认面板' : '放大书写面板'
body.focus()
return
}
execCmd(btn.dataset.cmd, btn.dataset.val)
refreshBarState()
})
function execCmd(cmd, val) {
body.focus()
if (cmd === 'insertCheckbox') {
document.execCommand('insertHTML', false, '<div class="ck-line"><span class="ck-box" contenteditable="false"></span>&nbsp;</div>')
} else if (cmd === 'createLink') {
var url = prompt('链接地址(http(s):// 或 mailto:):')
if (url && /^(https?:|mailto:)/i.test(url)) document.execCommand('createLink', false, url)
} else {
document.execCommand(cmd, false, val || null)
}
}
// 光标所在处的格式高亮到工具栏按钮(B/I/U/S 有效,块级命令不支持 state 时忽略)
function refreshBarState() {
Array.prototype.forEach.call(bar.querySelectorAll('.rte-btn[data-cmd]'), function (b) {
var on = false
try { on = document.queryCommandState(b.dataset.cmd) } catch (e) { /* 忽略 */ }
b.classList.toggle('on', !!on)
})
}
body.addEventListener('keyup', refreshBarState)
body.addEventListener('mouseup', refreshBarState)
// 编辑器内点击待办项方框切换勾选
body.addEventListener('click', function (e) {
var box = e.target.closest('.ck-box')
if (box) box.classList.toggle('ck-checked')
})
}
/* ===== 危险操作确认弹窗(替代原生 confirm) ===== */
function confirmDialog(cfg) {
return new Promise(function (resolve) {
@@ -869,7 +1046,12 @@
html += '</div>'
html += '<div class="body">'
html += '<div class="title">' + esc(item.title) + '</div>'
if (item.remark) html += '<div class="note">' + esc(item.remark) + '</div>'
if (item.remark && stripHtml(item.remark)) {
html += '<div class="note-box" data-act="peek" title="点击展开 / 收起">'
+ '<div class="plain">' + esc(stripHtml(item.remark)) + '</div>'
+ '<div class="full fmt">' + sanitizeHtml(item.remark) + '</div>'
+ '</div>'
}
html += '<div class="meta">'
html += '<span class="pri p' + p + '">' + priorityLabel(p) + '</span>'
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
@@ -1002,7 +1184,9 @@
+ '<div class="field"><label>截止日期</label>' + dateSelHtml('mDue', item.dueDate || '') + '</div>'
+ '</div>'
+ '<div class="field"><label>备注</label>'
+ '<textarea id="mRemark" maxlength="1000" placeholder="补充说明(可选)">' + esc(item.remark || '') + '</textarea></div>'
+ '<div class="rte">' + RTE_BAR
+ '<div class="rte-body fmt" id="mRemark" contenteditable="true" data-ph="补充说明,支持加粗 / 列表 / 待办项 / 代码块…"></div>'
+ '</div></div>'
+ '<div class="modal-foot">'
+ '<button class="btn btn-ghost" id="mCancel">取消</button>'
+ '<button class="btn btn-primary" id="mSave">保存</button>'
@@ -1011,6 +1195,9 @@
elModalRoot.innerHTML = html
initSel($('mPriority'))
initDate($('mDue'))
var rteBody = $('mRemark')
rteBody.innerHTML = sanitizeHtml(item.remark || '')
initEditor(rteBody.closest('.rte'))
var mask = $('mask')
var mTitle = $('mTitle')
setTimeout(function () { mTitle.focus() }, 60)
@@ -1024,11 +1211,13 @@
$('mSave').addEventListener('click', function () {
var title = (mTitle.value || '').trim()
if (!title) { toast('标题不能为空'); mTitle.focus(); return }
var remarkHtml = sanitizeHtml($('mRemark').innerHTML)
if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = ''
var data = {
title: title,
priority: getSel('mPriority'),
dueDate: getSel('mDue'),
remark: $('mRemark').value
remark: remarkHtml
}
var req
if (isNew) {
@@ -1080,7 +1269,7 @@
title: it.title,
priority: it.priority || 2,
dueDate: it.dueDate || '',
remark: it.remark || ''
remark: sanitizeHtml(it.remark || '')
}).then(function (r) { if (r && r.code === 200) okCount++ })
})
})
@@ -1177,6 +1366,10 @@
if (act === 'toggle') toggleStatus(item.id, item.status)
else if (act === 'edit') openEdit(item)
else if (act === 'del') removeTodo(item.id, item.title)
else if (act === 'peek') {
if (e.target.closest('a')) return // 点展开内容里的链接时不折叠
actEl.classList.toggle('open')
}
})
$('exportBtn').addEventListener('click', exportJson)