Repository navigation
feat(forum): 按“专注画布”优化发布区布局、材料入口与配色 #184
Copy link
Copy link
Closed
Description
背景与设计结论
作业 / 自由讨论发布区目前的文件上传、附加链接等入口较分散,白色区域与重复边框过多,削弱了正文、材料与辅助信息的层次。用户已从三版 HTML 原型中选定第三版「专注画布」,并确认两轮修改:
- 删除右上角的「发布内容」,在保持其他布局与交互不变的基础上优化配色。
- 删除品牌旁的「/ 课程讨论」,包含斜杠及其文案;继续保持其他部分不变。
本 issue 将以上已确认设计落实到现有学习论坛发布组件。用户可见名称使用「自由讨论」,与 #132 一致。
页面结构
采用连续的单栏编辑画布。自上而下的顺序为:
- 现有应用页头:保留品牌及原有导航结构,移除「发布内容」和「/ 课程讨论」两处冗余文字,清理其独立容器占位。
- 发布者信息行:账号头像、作者、实际可见范围,以及已有展开 / 收起入口。
- 发布类型:「自由讨论 / 正式作业」使用原型中的下划线选中态;正式作业入口沿用现有角色权限。
- 正文编辑区:作为主要视觉区域,保留现有输入、Markdown、预览、字数限制与快捷键能力。
- 格式工具栏:放在正文区域下方,以轻分隔线与正文衔接。
- 材料操作条:统一「添加材料」入口与「材料 N」汇总,使用浅鼠尾草绿背景区分正文和辅助操作。
- 标签与资料引用:保持较低视觉权重,按需展开。
- 底部操作区:左侧草稿状态,右侧主发布按钮。
除上述已确认的发布区调整外,页面其余结构、内容流及导航沿用现状。不要把原型预览的三方案切换控件放入产品页面。
材料入口与交互
- 将文件、图片、参考链接归入同一个「添加材料」菜单;菜单项分别为「上传文件或图片」和「附加参考链接」。
- 平时仅显示紧凑操作条、材料总数和文件 / 链接摘要;材料列表默认收起,按用户操作展开,避免为未使用功能常驻多块大面积上传区域。
- 文件与链接使用统一的材料列表样式,保留类型图标、名称、必要状态及现有移除 / 查看能力;添加、移除后同步更新数量和摘要。
- 正文工具栏中的「插入正文超链接」继续编辑正文;「附加参考链接」生成材料项,二者语义保持清晰。
- 标签与作业 / 资料引用沿用现有数据来源和关联逻辑,按原型收纳,不增加新的业务字段。
- 展开编辑区时,将增加的空间分配给正文;作者行和正文之间不产生大段空白。收起时同步收拢外围容器,文字及材料状态继续保留。
配色基准
使用第三版已确认的灰绿背景、白色编辑面与深绿色主操作。正文、辅助文本、材料区域与主按钮形成清晰层次。下列数值为最终原型基准,实施时映射至现有主题 token,作用范围限于该发布区。
| 用途 | 浅色主题 | 深色主题 |
|---|---|---|
| 页面背景 | #EDF4F0 |
#14221B |
| 编辑主表面 | #FFFFFF |
#1E2D25 |
| 浮层 / 材料项表面 | #FFFFFF |
#2A3D31 |
| 辅助区域底色 | #EFF6F2 |
#25382D |
| 浅色强调区域 | #DEEEE4 |
#304C3B |
| 正文文字 | #243B2E |
#E7F0EA |
| 次级文字 | #5B7062 |
#ADBDAF |
| 分隔线 / 边框 | #D5E3DA |
#3C5243 |
| 主操作 / 选中态 | #2E6B50 |
#98CCB0 |
| 主按钮文字 | #FFFFFF |
#173222 |
| 阴影色 | #1D493012 |
#00000038 |
保持原型第三版的字号、间距、圆角、控件排列与展开方式;两处文案删除不牵动其他元素布局。交互状态沿用同一套主题颜色,保证明暗主题的文字、图标、边框和焦点可辨识。
实现约束
- 在现有 Memos 来源的内联发布组件及项目设计 token 上调整样式和材料入口组织,承接 feat(forum): 统一学习论坛命名,按 Memos 原生组件重做布局、内联发布与自动草稿 #132;保留当前正文编辑器、上传服务、标签 / 引用、发布接口及系统自动草稿管理。
- 承接 fix(forum): 缩小顶部编辑输入区,统一使用账号已有真实头像 #163:顶部发布区保持轻量,长正文按现有机制控制高度;头像继续使用对应账号已有真实头像及统一缺省规则。
- 原型中的
lyy、字母头像、示例正文、PDF、链接和材料数量仅用于评审;实际界面必须绑定真实账号、内容与材料状态。不要将原型中的模拟发布、内存草稿或简化 Markdown 解析替换进生产逻辑。 - 保留正式作业现有字段、校验和角色权限。删除「/ 课程讨论」仅删除展示文案,不改变发布上下文、可见性、授权或数据模型。
- 自动草稿、上传进行中 / 失败、发布中 / 失败 / 成功等状态应继续由真实业务流程驱动。
验收标准
- 发布区采用已确认的第三版「专注画布」结构,正文 → 格式工具栏 → 材料条 → 标签与引用 → 底部操作的顺序和层级一致。
- 页头不再显示「发布内容」或「/ 课程讨论」,无残留斜杠、空标签或多余占位;其他已确认的排版保持一致。
- 配色与上表一致,正文和材料区域能够清楚区分,主操作突出,辅助文字清晰可读;明暗主题均可用。
- 文件 / 图片与参考链接统一从「添加材料」进入;材料列表可收起 / 展开,数量和摘要随真实材料变化,无重复的大块上传区域。
- 正文超链接与参考链接材料保持各自语义;上传、移除、预览、标签和资料引用按现有业务正常工作。
- 展开 / 收起没有额外大段留白,长文本不会无限撑高顶部区域,已有输入与材料状态不丢失。
- 自由讨论和正式作业既有权限、自动草稿恢复及发布失败 / 成功行为无回归,真实账号头像保持正确。
- 桌面与窄屏无控件重叠或横向溢出,菜单与焦点可用;按仓库现行要求完成与本变更有关的验证,提供普通 / 展开状态、材料收起 / 展开及明暗主题的可复核截图。
设计参考与关联
- 可下载完整原型:需求会话中的
lingxi-publish-ui-options.html,默认展示「专注画布」。下方折叠区同时保留最终第三版的完整 HTML 片段源码,供本 issue 的阅读者直接核对排版、配色与交互;无需通过会话取得源码。原型用于外观与交互评审,生产行为以现有组件为准。 - feat(forum): 统一学习论坛命名,按 Memos 原生组件重做布局、内联发布与自动草稿 #132:学习论坛命名、Memos 内联发布与系统自动草稿。
- fix(forum): 缩小顶部编辑输入区,统一使用账号已有真实头像 #163:顶部编辑区紧凑布局与真实账号头像。
最终第三版:HTML 参考源码(已删除两处顶部文案)
这是原型的 HTML 片段,包含作用域 CSS 与本地演示交互。原预览宿主提供全局 lucide 图标对象、data-tooltip 提示和 cursor-interaction 样式;独立预览时需提供相应运行环境。完整可直接打开的 HTML 已在需求会话中提供。源码中的示例数据与模拟发布不属于生产实现要求。
<style>
#lingxi-publish-focus-color .lx-shell {
--lx-canvas: light-dark(#edf3ef, #15211b);
--lx-surface: light-dark(#ffffff, #202b25);
--lx-raised: light-dark(#ffffff, #29372f);
--lx-soft: light-dark(#f4f7f5, #25332b);
--lx-tint: light-dark(#e8f2eb, #293e30);
--lx-ink: light-dark(#24372b, #e5eee8);
--lx-subtle: light-dark(#65746a, #a3b5a9);
--lx-line: light-dark(#dce5df, #3b4b40);
--lx-green: light-dark(#35764c, #94c9a3);
--lx-on-green: light-dark(#ffffff, #142b1a);
--lx-danger: light-dark(#aa3838, #ffb4b4);
--lx-shadow: light-dark(#1e402016, #00000030);
color: var(--lx-ink);
background: var(--lx-canvas);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
border: 1px solid var(--lx-line);
border-radius: 18px;
text-align: left;
min-width: 0;
overflow-wrap: anywhere;
}
#lingxi-publish-focus-color .lx-shell * { box-sizing: border-box; }
#lingxi-publish-focus-color .lx-shell [hidden] { display: none !important; }
#lingxi-publish-focus-color :where(.lx-shell button, .lx-shell input, .lx-shell select, .lx-shell textarea) { font: inherit; }
#lingxi-publish-focus-color :where(.lx-shell button) { color: inherit; }
#lingxi-publish-focus-color .lx-shell button:disabled { cursor: not-allowed; opacity: .45; }
#lingxi-publish-focus-color .lx-shell button svg,
#lingxi-publish-focus-color .lx-shell i[data-lucide] { width: 16px; height: 16px; flex: 0 0 16px; }
#lingxi-publish-focus-color .lx-shell input::placeholder,
#lingxi-publish-focus-color .lx-shell textarea::placeholder { color: var(--lx-subtle); opacity: .9; }
#lingxi-publish-focus-color .lx-shell button { -webkit-tap-highlight-color: transparent; }
#lingxi-publish-focus-color .lx-appbar { height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 24px; border-bottom: 1px solid var(--lx-line); }
#lingxi-publish-focus-color .lx-brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: .03em; }
#lingxi-publish-focus-color .lx-brandmark { display: grid; place-items: center; width: 27px; height: 27px; background: var(--lx-green); color: var(--lx-on-green); border-radius: 8px; }
#lingxi-publish-focus-color .lx-app-context { color: var(--lx-subtle); font-size: 12px; font-weight: 400; letter-spacing: 0; }
#lingxi-publish-focus-color .lx-stage { min-height: 722px; padding: 24px; }
#lingxi-publish-focus-color .lx-collapsed-shell .lx-stage { min-height: 0; }
#lingxi-publish-focus-color .lx-composer { background: var(--lx-surface); border: 1px solid var(--lx-line); border-radius: 14px; box-shadow: 0 8px 24px var(--lx-shadow); position: relative; min-width: 0; }
#lingxi-publish-focus-color .lx-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 22px 12px; }
#lingxi-publish-focus-color .lx-author { display: flex; align-items: center; gap: 10px; min-width: 0; }
#lingxi-publish-focus-color .lx-avatar { display: grid; place-items: center; width: 32px; height: 32px; flex: 0 0 32px; border-radius: 50%; background: var(--lx-tint); color: var(--lx-green); font-size: 14px; font-weight: 600; }
#lingxi-publish-focus-color .lx-author-name { display: block; font-size: 14px; font-weight: 600; }
#lingxi-publish-focus-color .lx-meta { display: inline-flex; align-items: center; gap: 5px; color: var(--lx-subtle); font-size: 12px; }
#lingxi-publish-focus-color .lx-head-actions { display: flex; align-items: center; gap: 3px; }
#lingxi-publish-focus-color .lx-icon { padding: 0; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 0; border-radius: 7px; background: transparent; }
#lingxi-publish-focus-color .lx-icon:hover { background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-btn { min-height: 34px; padding: 6px 11px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--lx-line); border-radius: 8px; background: var(--lx-surface); white-space: nowrap; font-size: 13px; }
#lingxi-publish-focus-color .lx-btn:hover { background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-btn-quiet { border-color: transparent; background: transparent; color: var(--lx-subtle); }
#lingxi-publish-focus-color .lx-btn-primary { background: var(--lx-green); color: var(--lx-on-green); border-color: var(--lx-green); min-height: 38px; padding: 8px 19px; font-weight: 500; }
#lingxi-publish-focus-color .lx-btn-primary:hover { background: var(--lx-green); filter: brightness(.95); }
#lingxi-publish-focus-color .lx-btn-tint { color: var(--lx-green); background: var(--lx-tint); border-color: transparent; }
#lingxi-publish-focus-color .lx-modes { display: inline-flex; align-items: center; gap: 3px; padding: 3px; border-radius: 9px; background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-mode { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 12px; border: 0; border-radius: 6px; background: transparent; color: var(--lx-subtle); font-size: 13px; white-space: nowrap; }
#lingxi-publish-focus-color .lx-mode[aria-pressed="true"] { background: var(--lx-surface); color: var(--lx-green); box-shadow: 0 1px 4px var(--lx-shadow); font-weight: 600; }
#lingxi-publish-focus-color .lx-mode-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 22px 12px; }
#lingxi-publish-focus-color .lx-editor { min-width: 0; }
#lingxi-publish-focus-color .lx-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 14px; border-top: 1px solid var(--lx-line); border-bottom: 1px solid var(--lx-line); background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-formatting { display: flex; align-items: center; flex-wrap: wrap; gap: 1px; }
#lingxi-publish-focus-color .lx-divider { width: 1px; height: 16px; margin: 0 6px; background: var(--lx-line); }
#lingxi-publish-focus-color .lx-write { display: block; width: 100%; min-height: 168px; padding: 19px 23px 10px; border: none; border-radius: 0; background: transparent; color: var(--lx-ink); font-size: 15px; line-height: 1.9; resize: vertical; }
#lingxi-publish-focus-color .lx-write:focus { outline-offset: -3px; }
#lingxi-publish-focus-color .lx-write-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 23px 10px; color: var(--lx-subtle); font-size: 11px; }
#lingxi-publish-focus-color .lx-counter { font-variant-numeric: tabular-nums; white-space: nowrap; }
#lingxi-publish-focus-color .lx-live-preview { min-height: 168px; padding: 19px 23px 10px; font-size: 15px; line-height: 1.9; overflow-wrap: anywhere; }
#lingxi-publish-focus-color .lx-live-preview img { display: block; max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 7px; margin: 8px 0; }
#lingxi-publish-focus-color .lx-live-preview p { margin: 0 0 13px; }
#lingxi-publish-focus-color .lx-live-preview h3 { margin: 0 0 12px; font-size: 19px; line-height: 1.5; }
#lingxi-publish-focus-color .lx-live-preview ul,
#lingxi-publish-focus-color .lx-live-preview ol { margin: 0 0 12px; padding-left: 24px; }
#lingxi-publish-focus-color .lx-live-preview code { background: var(--lx-soft); border-radius: 4px; padding: 2px 4px; font-size: 13px; }
#lingxi-publish-focus-color .lx-live-preview a { color: var(--lx-green); text-decoration: underline; }
#lingxi-publish-focus-color .lx-materials { padding: 12px 22px; border-top: 1px solid var(--lx-line); background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-materials-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
#lingxi-publish-focus-color .lx-compact .lx-no-materials .lx-materials-head { margin-bottom: 0; }
#lingxi-publish-focus-color .lx-section-label { font-size: 12px; font-weight: 600; letter-spacing: .03em; display: inline-flex; align-items: center; gap: 6px; }
#lingxi-publish-focus-color .lx-count-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 5px; background: var(--lx-tint); color: var(--lx-green); font-size: 11px; }
#lingxi-publish-focus-color .lx-add-wrap { position: relative; }
#lingxi-publish-focus-color .lx-add-menu { position: absolute; top: 41px; left: 0; z-index: 6; width: 218px; padding: 6px; border: 1px solid var(--lx-line); border-radius: 10px; background: var(--lx-raised); box-shadow: 0 12px 34px var(--lx-shadow); }
#lingxi-publish-focus-color .lx-add-menu button { display: flex; align-items: center; gap: 9px; padding: 10px; width: 100%; background: transparent; border: none; border-radius: 6px; text-align: left; font-size: 13px; }
#lingxi-publish-focus-color .lx-add-menu button:hover { background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-files { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; }
#lingxi-publish-focus-color .lx-file { display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; padding: 5px 7px 5px 9px; background: var(--lx-surface); border: 1px solid var(--lx-line); border-radius: 8px; }
#lingxi-publish-focus-color .lx-file-kind { display: grid; place-items: center; flex: 0 0 29px; width: 29px; height: 29px; background: var(--lx-tint); color: var(--lx-green); border-radius: 6px; }
#lingxi-publish-focus-color .lx-file-text { min-width: 0; }
#lingxi-publish-focus-color .lx-file-name { display: block; font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
#lingxi-publish-focus-color .lx-file-size { display: block; font-size: 11px; color: var(--lx-subtle); }
#lingxi-publish-focus-color .lx-file .lx-icon { width: 25px; height: 27px; flex: 0 0 25px; color: var(--lx-subtle); }
#lingxi-publish-focus-color .lx-empty { margin: 0; color: var(--lx-subtle); font-size: 12px; padding: 5px 0; }
#lingxi-publish-focus-color .lx-extras { padding: 8px 22px; border-top: 1px solid var(--lx-line); }
#lingxi-publish-focus-color .lx-extra-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
#lingxi-publish-focus-color .lx-extra-trigger { display: inline-flex; align-items: center; gap: 5px; color: var(--lx-subtle); background: transparent; border: 0; padding: 5px 0; font-size: 12px; }
#lingxi-publish-focus-color .lx-tags { display: flex; flex-wrap: wrap; gap: 5px; }
#lingxi-publish-focus-color .lx-tag { display: inline-flex; align-items: center; gap: 4px; border: 0; background: var(--lx-tint); color: var(--lx-green); border-radius: 5px; padding: 3px 6px; font-size: 11px; }
#lingxi-publish-focus-color .lx-tag svg { width: 12px !important; height: 12px !important; }
#lingxi-publish-focus-color .lx-extra-panel { margin: 12px 0 2px; padding: 13px; border: 1px solid var(--lx-line); border-radius: 9px; background: var(--lx-soft); display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
#lingxi-publish-focus-color .lx-label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--lx-ink); font-weight: 500; }
#lingxi-publish-focus-color .lx-input { width: 100%; min-width: 0; height: 35px; padding: 6px 9px; border: 1px solid var(--lx-line); border-radius: 7px; background: var(--lx-surface); color: var(--lx-ink); font-size: 13px; }
#lingxi-publish-focus-color .lx-inline-field { display: flex; gap: 6px; }
#lingxi-publish-focus-color .lx-inline-field .lx-btn { flex: 0 0 auto; }
#lingxi-publish-focus-color .lx-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 22px; border-top: 1px solid var(--lx-line); background: var(--lx-surface); border-radius: 0 0 14px 14px; }
#lingxi-publish-focus-color .lx-saved { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--lx-subtle); }
#lingxi-publish-focus-color .lx-saved svg { color: var(--lx-green); width: 14px; height: 14px; }
#lingxi-publish-focus-color .lx-foot-actions { display: flex; align-items: center; gap: 8px; }
#lingxi-publish-focus-color .lx-collapsed { padding: 4px 22px 19px; font-size: 13px; color: var(--lx-subtle); }
#lingxi-publish-focus-color .lx-status { padding: 0 22px 12px; font-size: 12px; color: var(--lx-green); }
#lingxi-publish-focus-color .lx-status:empty { display: none; }
#lingxi-publish-focus-color .lx-is-expanded .lx-write,
#lingxi-publish-focus-color .lx-is-expanded .lx-live-preview { min-height: 330px; }
#lingxi-publish-focus-color .lx-drop-active { box-shadow: inset 0 0 0 2px var(--lx-green); background: var(--lx-tint); }
#lingxi-publish-focus-color .lx-inline-dialog { margin: 8px 22px 14px; padding: 15px; background: var(--lx-tint); border: 1px solid var(--lx-line); border-radius: 10px; }
#lingxi-publish-focus-color .lx-dialog-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 9px; font-weight: 500; font-size: 13px; }
#lingxi-publish-focus-color .lx-dialog-fields { display: grid; grid-template-columns: 1fr 1.6fr; gap: 10px; }
#lingxi-publish-focus-color .lx-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
#lingxi-publish-focus-color .lx-error { color: var(--lx-danger); font-size: 12px; margin: 8px 0 0; }
#lingxi-publish-focus-color .lx-error:empty { display: none; }
#lingxi-publish-focus-color .lx-split { --lx-canvas: light-dark(#eef2f1, #16211d); --lx-soft: light-dark(#f1f6f3, #25332b); }
#lingxi-publish-focus-color .lx-split .lx-head { padding-bottom: 18px; border-bottom: 1px solid var(--lx-line); }
#lingxi-publish-focus-color .lx-split-grid { display: grid; grid-template-columns: minmax(0, 1fr) 242px; }
#lingxi-publish-focus-color .lx-split .lx-writing-column { padding: 19px 0 0; min-width: 0; }
#lingxi-publish-focus-color .lx-split .lx-mode-row { padding-bottom: 18px; }
#lingxi-publish-focus-color .lx-split .lx-toolbar { border-top: 0; background: var(--lx-surface); padding-left: 16px; padding-right: 16px; }
#lingxi-publish-focus-color .lx-split .lx-write,
#lingxi-publish-focus-color .lx-split .lx-live-preview { min-height: 244px; }
#lingxi-publish-focus-color .lx-split .lx-materials { padding: 18px 15px; border-top: 0; border-left: 1px solid var(--lx-line); }
#lingxi-publish-focus-color .lx-split .lx-materials-head { margin-bottom: 16px; }
#lingxi-publish-focus-color .lx-material-buttons { display: flex; align-items: center; gap: 7px; margin-bottom: 14px; }
#lingxi-publish-focus-color .lx-material-buttons .lx-btn { flex: 1; }
#lingxi-publish-focus-color .lx-split .lx-files { flex-direction: column; }
#lingxi-publish-focus-color .lx-split .lx-file { width: 100%; padding: 9px 6px 9px 8px; }
#lingxi-publish-focus-color .lx-split .lx-file-text { flex: 1; }
#lingxi-publish-focus-color .lx-split .lx-extras { margin-top: 20px; padding: 14px 0 0; }
#lingxi-publish-focus-color .lx-split .lx-extra-summary { align-items: flex-start; gap: 8px; flex-direction: column; }
#lingxi-publish-focus-color .lx-split .lx-extra-panel { grid-template-columns: 1fr; padding: 10px; }
#lingxi-publish-focus-color .lx-split .lx-inline-dialog { margin-top: 14px; }
#lingxi-publish-focus-color .lx-split .lx-foot { background: var(--lx-soft); }
#lingxi-publish-focus-color .lx-split .lx-is-expanded .lx-write,
#lingxi-publish-focus-color .lx-split .lx-is-expanded .lx-live-preview { min-height: 350px; }
#lingxi-publish-focus-color .lx-focus {
--lx-canvas: light-dark(#edf4f0, #14221b);
--lx-surface: light-dark(#ffffff, #1e2d25);
--lx-raised: light-dark(#ffffff, #2a3d31);
--lx-soft: light-dark(#eff6f2, #25382d);
--lx-tint: light-dark(#deeee4, #304c3b);
--lx-ink: light-dark(#243b2e, #e7f0ea);
--lx-subtle: light-dark(#5b7062, #adbdaf);
--lx-line: light-dark(#d5e3da, #3c5243);
--lx-green: light-dark(#2e6b50, #98ccb0);
--lx-on-green: light-dark(#ffffff, #173222);
--lx-shadow: light-dark(#1d493012, #00000038);
}
#lingxi-publish-focus-color .lx-focus .lx-composer { border: 0; border-radius: 9px; box-shadow: 0 5px 22px var(--lx-shadow); }
#lingxi-publish-focus-color .lx-focus .lx-head { padding: 19px 26px 15px; }
#lingxi-publish-focus-color .lx-focus .lx-mode-row { padding: 0 26px; }
#lingxi-publish-focus-color .lx-focus .lx-modes { padding: 0; gap: 22px; background: transparent; border-radius: 0; }
#lingxi-publish-focus-color .lx-focus .lx-mode { padding: 10px 1px; border-radius: 0; border-bottom: 2px solid transparent; background: transparent; }
#lingxi-publish-focus-color .lx-focus .lx-mode[aria-pressed="true"] { background: transparent; box-shadow: none; border-bottom-color: var(--lx-green); }
#lingxi-publish-focus-color .lx-focus .lx-write,
#lingxi-publish-focus-color .lx-focus .lx-live-preview { min-height: 225px; padding: 25px 27px 12px; }
#lingxi-publish-focus-color .lx-focus .lx-write-meta { padding-left: 27px; padding-right: 27px; }
#lingxi-publish-focus-color .lx-focus .lx-toolbar { margin: 0 26px; padding: 9px 0; background: transparent; border-top: 1px solid var(--lx-line); border-bottom: 0; }
#lingxi-publish-focus-color .lx-focus-bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 20px; background: var(--lx-soft); border-top: 1px solid var(--lx-line); }
#lingxi-publish-focus-color .lx-focus-left { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
#lingxi-publish-focus-color .lx-focus .lx-materials { border-top: 0; background: var(--lx-soft); padding-top: 0; }
#lingxi-publish-focus-color .lx-focus .lx-files { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
#lingxi-publish-focus-color .lx-focus .lx-file-text { flex: 1; }
#lingxi-publish-focus-color .lx-focus .lx-extras { padding: 0; border: 0; }
#lingxi-publish-focus-color .lx-focus .lx-extra-panel { margin: 12px 22px; }
#lingxi-publish-focus-color .lx-focus .lx-foot { border-radius: 0 0 9px 9px; }
#lingxi-publish-focus-color .lx-focus .lx-is-expanded .lx-write,
#lingxi-publish-focus-color .lx-focus .lx-is-expanded .lx-live-preview { min-height: 370px; }
#lingxi-publish-focus-color .lx-focus .lx-add-menu { top: auto; bottom: 40px; }
#lingxi-publish-focus-color .lx-tray-summary { color: var(--lx-subtle); font-size: 12px; }
@media (max-width: 800px) {
#lingxi-publish-focus-color .lx-stage { padding: 20px 17px; min-height: 730px; }
#lingxi-publish-focus-color .lx-split-grid { grid-template-columns: minmax(0, 1fr) 210px; }
#lingxi-publish-focus-color .lx-split .lx-materials { padding-left: 12px; padding-right: 12px; }
#lingxi-publish-focus-color .lx-split .lx-head { padding-left: 18px; padding-right: 18px; }
#lingxi-publish-focus-color .lx-split .lx-toolbar { flex-wrap: wrap; }
#lingxi-publish-focus-color .lx-split .lx-toolbar .lx-preview-btn { margin-left: auto; }
#lingxi-publish-focus-color .lx-shortcut { display: none; }
}
@media (max-width: 620px) {
#lingxi-publish-focus-color .lx-appbar { padding: 0 15px; height: 49px; }
#lingxi-publish-focus-color .lx-app-context { font-size: 11px; }
#lingxi-publish-focus-color .lx-stage { padding: 14px 10px; min-height: 950px; }
#lingxi-publish-focus-color .lx-head { padding: 16px 15px 14px; }
#lingxi-publish-focus-color .lx-mode-row { padding: 0 15px 14px; }
#lingxi-publish-focus-color .lx-toolbar { padding: 7px 9px; flex-wrap: wrap; }
#lingxi-publish-focus-color .lx-formatting { gap: 0; }
#lingxi-publish-focus-color .lx-divider { margin-left: 3px; margin-right: 3px; }
#lingxi-publish-focus-color .lx-toolbar .lx-preview-btn { margin-left: auto; }
#lingxi-publish-focus-color .lx-write,
#lingxi-publish-focus-color .lx-live-preview { padding: 18px 16px 10px; min-height: 224px; font-size: 16px; }
#lingxi-publish-focus-color .lx-write-meta { padding: 6px 16px 12px; }
#lingxi-publish-focus-color .lx-materials { padding: 13px 15px; }
#lingxi-publish-focus-color .lx-materials-head { flex-wrap: wrap; }
#lingxi-publish-focus-color .lx-materials-hint { display: none; }
#lingxi-publish-focus-color .lx-extras { padding: 10px 15px; }
#lingxi-publish-focus-color .lx-file { width: 100%; }
#lingxi-publish-focus-color .lx-file-text { flex: 1; }
#lingxi-publish-focus-color .lx-foot { padding: 13px 15px; gap: 8px; flex-wrap: wrap; }
#lingxi-publish-focus-color .lx-foot-actions { margin-left: auto; }
#lingxi-publish-focus-color .lx-saved { font-size: 11px; }
#lingxi-publish-focus-color .lx-extra-panel,
#lingxi-publish-focus-color .lx-dialog-fields { grid-template-columns: 1fr; }
#lingxi-publish-focus-color .lx-inline-dialog { margin-left: 15px; margin-right: 15px; }
#lingxi-publish-focus-color .lx-input { font-size: 16px; min-height: 39px; }
#lingxi-publish-focus-color .lx-split-grid { grid-template-columns: 1fr; }
#lingxi-publish-focus-color .lx-split .lx-materials { border-left: 0; border-top: 1px solid var(--lx-line); padding: 15px; }
#lingxi-publish-focus-color .lx-split .lx-files { flex-direction: row; }
#lingxi-publish-focus-color .lx-split .lx-materials-head { margin-bottom: 10px; }
#lingxi-publish-focus-color .lx-split .lx-extras { margin-top: 12px; }
#lingxi-publish-focus-color .lx-split .lx-write,
#lingxi-publish-focus-color .lx-split .lx-live-preview { min-height: 220px; }
#lingxi-publish-focus-color .lx-split .lx-extra-summary { flex-direction: row; align-items: center; }
#lingxi-publish-focus-color .lx-focus .lx-head { padding: 16px 16px 13px; }
#lingxi-publish-focus-color .lx-focus .lx-mode-row { padding: 0 16px; }
#lingxi-publish-focus-color .lx-focus .lx-write,
#lingxi-publish-focus-color .lx-focus .lx-live-preview { padding: 19px 17px 10px; min-height: 260px; }
#lingxi-publish-focus-color .lx-focus .lx-toolbar { margin: 0 16px; }
#lingxi-publish-focus-color .lx-focus-bar { padding: 11px 13px; }
#lingxi-publish-focus-color .lx-focus .lx-files { grid-template-columns: 1fr; }
#lingxi-publish-focus-color .lx-focus .lx-extra-panel { margin: 12px 15px; }
#lingxi-publish-focus-color .lx-tray-summary { display: none; }
}
@media (pointer: coarse) {
#lingxi-publish-focus-color .lx-icon { min-width: 38px; min-height: 40px; }
#lingxi-publish-focus-color .lx-btn,
#lingxi-publish-focus-color .lx-mode,
#lingxi-publish-focus-color .lx-extra-trigger { min-height: 44px; }
#lingxi-publish-focus-color .lx-input { min-height: 44px; font-size: 16px; }
#lingxi-publish-focus-color .lx-file .lx-icon { min-width: 36px; min-height: 40px; }
}
</style>
<div id="lingxi-publish-focus-color" aria-label="灵犀智学专注画布配色优化">
<section data-variant="专注画布" aria-label="专注画布方案">
<div class="lx-shell lx-focus" data-layout="focus">
<header class="lx-appbar"><div class="lx-brand"><span class="lx-brandmark"><i data-lucide="sprout" aria-hidden="true"></i></span>灵犀智学</div></header>
<div class="lx-stage"><article class="lx-composer" aria-label="专注式发布编辑器"></article></div>
</div>
</section>
</div>
<script>
(() => {
const root = document.getElementById('lingxi-publish-focus-color');
if (!root) return;
const sampleText = '关于子网划分,我想和大家讨论一个问题:\n\n把一个 /24 的网段划分成 4 个等大的子网时,为什么掩码会变成 /26?我整理了一份练习,欢迎分享你的理解。';
const icon = name => '<i data-lucide="' + name + '" aria-hidden="true"></i>';
const safe = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const icons = () => { if (globalThis.lucide) globalThis.lucide.createIcons({ attrs: { width: 16, height: 16 } }); };
const button = (action, name, label, cls = 'lx-btn') => '<button type="button" class="' + cls + ' cursor-interaction" data-action="' + action + '">' + icon(name) + '<span>' + label + '</span></button>';
const iconButton = (action, name, label) => '<button type="button" class="lx-icon cursor-interaction" data-action="' + action + '" aria-label="' + label + '" data-tooltip="' + label + '">' + icon(name) + '</button>';
const formatButton = (format, name, label) => '<button type="button" class="lx-icon cursor-interaction" data-format="' + format + '" aria-label="' + label + '" data-tooltip="' + label + '">' + icon(name) + '</button>';
root.querySelectorAll('.lx-shell').forEach((shell, index) => {
const layout = shell.dataset.layout;
const id = 'lx-' + layout;
const app = shell.querySelector('.lx-composer');
const state = {
mode: 'discussion', preview: false, expanded: false, collapsed: false, trayOpen: false,
files: [
{ id: id + '-sample-pdf', kind: 'file', name: '子网划分练习.pdf', meta: 'PDF · 428 KB · 示例' },
{ id: id + '-sample-link', kind: 'link', name: '课堂参考链接', meta: 'example.com · 示例', url: 'https://example.com/subnet' }
], tags: ['子网划分'], refs: [], inlineImages: [], linkTarget: 'material', linkSelection: [0, 0], sequence: 0
};
const author = '<header class="lx-head"><div class="lx-author"><span class="lx-avatar" aria-hidden="true">L</span><div><span class="lx-author-name">lyy</span><span class="lx-meta">' + icon('users-round') + '本课程成员可见</span></div></div><div class="lx-head-actions">' + iconButton('expand','maximize-2','展开编辑区') + iconButton('collapse','chevron-up','收起编辑器') + '</div></header>';
const modes = '<div class="lx-mode-row"><div class="lx-modes" role="group" aria-label="发布类型"><button type="button" class="lx-mode cursor-interaction" data-mode="discussion" aria-pressed="true">' + icon('messages-square') + '自由讨论</button><button type="button" class="lx-mode cursor-interaction" data-mode="assignment" aria-pressed="false">' + icon('notebook-pen') + '正式作业</button></div></div>';
const toolbar = '<div class="lx-toolbar"><div class="lx-formatting" role="group" aria-label="正文格式">' + formatButton('heading','type','标题格式') + '<span class="lx-divider" aria-hidden="true"></span>' + formatButton('bold','bold','加粗') + formatButton('italic','italic','斜体') + formatButton('strike','strikethrough','删除线') + '<span class="lx-divider" aria-hidden="true"></span>' + formatButton('bullet','list','无序列表') + formatButton('ordered','list-ordered','有序列表') + formatButton('task','list-checks','任务列表') + formatButton('code','code','行内代码') + iconButton('body-link','link','插入正文超链接') + '</div>' + button('preview','eye','预览','lx-btn lx-btn-quiet lx-preview-btn') + '</div>';
const writing = '<div class="lx-editor" data-dropzone><textarea class="lx-write" aria-label="发布正文" maxlength="20000" placeholder="写下你的想法,支持 Markdown…">' + safe(sampleText) + '</textarea><div class="lx-live-preview" aria-label="正文预览" hidden></div><div class="lx-write-meta"><span class="lx-shortcut">Markdown · ⌘ / Ctrl + Enter 发布</span><span class="lx-counter"><span data-count>0</span> / 20,000</span></div></div>';
const addMenu = '<div class="lx-add-wrap">' + button('add-menu','plus','添加材料','lx-btn lx-btn-tint') + '<div class="lx-add-menu" aria-label="添加材料" hidden><button type="button" class="cursor-interaction" data-action="file">' + icon('paperclip') + '上传文件或图片</button><button type="button" class="cursor-interaction" data-action="material-link">' + icon('link') + '附加参考链接</button></div></div>';
const files = '<div class="lx-files" aria-label="已添加材料"></div>';
const extrasTrigger = '<div class="lx-extra-summary"><button type="button" class="lx-extra-trigger cursor-interaction" data-action="extras" aria-expanded="false" aria-controls="' + id + '-extras">' + icon('tag') + '标签与资料引用' + icon('chevron-down') + '</button><div class="lx-tags" aria-label="已选标签及引用"></div></div>';
const extrasPanel = '<div id="' + id + '-extras" class="lx-extra-panel" hidden><div><label class="lx-label" for="' + id + '-tag-input">添加标签</label><div class="lx-inline-field"><input class="lx-input" id="' + id + '-tag-input" data-tag-input maxlength="20" placeholder="如:网络层">' + button('add-tag','plus','添加') + '</div></div><div><label class="lx-label" for="' + id + '-ref-input">引用作业或资料</label><div class="lx-inline-field"><select class="lx-input" id="' + id + '-ref-input" data-ref-input><option value="">选择资料(示例)</option>第四章:网络层讲义</option><option>第三次作业:IP 地址与子网</option></select>' + button('add-ref','plus','引用') + '</div></div></div>';
const extras = '<div class="lx-extras">' + extrasTrigger + extrasPanel + '</div>';
const compactMaterials = '<section class="lx-materials" aria-label="附件材料"><div class="lx-materials-head">' + addMenu + '<span class="lx-meta lx-materials-hint">' + icon('paperclip') + '文件、图片、链接,统一收纳</span></div>' + files + '</section>';
const sideMaterials = '<aside class="lx-materials" aria-label="发布材料侧栏"><div class="lx-materials-head"><span class="lx-section-label">' + icon('folder-open') + '附加材料 <span class="lx-count-badge" data-material-count>2</span></span></div><div class="lx-material-buttons">' + button('file','upload','文件') + button('material-link','link','链接') + '</div>' + files + extras + '</aside>';
const focusMaterials = '<div class="lx-focus-bar"><div class="lx-focus-left">' + addMenu + '<button type="button" class="lx-btn lx-btn-quiet cursor-interaction" data-action="tray" aria-expanded="false" aria-controls="' + id + '-tray">' + icon('paperclip') + '<span data-tray-label>材料 2</span>' + icon('chevron-down') + '</button><span class="lx-tray-summary">1 个文件 · 1 个链接</span></div></div><section class="lx-materials" id="' + id + '-tray" aria-label="材料托盘" hidden>' + files + '</section><div class="lx-extras" style="padding: 10px 22px;">' + extrasTrigger + '</div>' + extrasPanel;
const linkDialog = '<form class="lx-inline-dialog" data-link-dialog hidden><div class="lx-dialog-head"><span data-link-heading>附加参考链接</span>' + iconButton('close-link','x','关闭链接输入') + '</div><div class="lx-dialog-fields"><div><label class="lx-label" for="' + id + '-link-name">链接名称 <span class="lx-meta">选填</span></label><input class="lx-input" id="' + id + '-link-name" data-link-name maxlength="100" placeholder="例如:课堂参考资料"></div><div><label class="lx-label" for="' + id + '-link-url">链接地址</label><input class="lx-input" id="' + id + '-link-url" data-link-url type="url" placeholder="https://" required></div></div><p class="lx-error" data-link-error role="alert"></p><div class="lx-dialog-actions">' + button('close-link','x','取消','lx-btn lx-btn-quiet') + '<button type="submit" class="lx-btn lx-btn-primary cursor-interaction" data-link-submit>添加链接</button></div></form>';
const foot = '<footer class="lx-foot"><span class="lx-saved">' + icon('check') + '<span data-save-status>草稿已暂存</span></span><div class="lx-foot-actions"><span class="lx-meta lx-shortcut">Esc 离开编辑器</span>' + button('publish','arrow-up-right','发布讨论','lx-btn lx-btn-primary lx-publish') + '</div></footer>';
const compact = modes + toolbar + writing + compactMaterials + extras;
const split = '<div class="lx-split-grid"><div class="lx-writing-column">' + modes + toolbar + writing + '</div>' + sideMaterials + '</div>';
const focus = modes + writing + toolbar + focusMaterials;
app.innerHTML = author + '<div class="lx-compose-content">' + (layout === 'split' ? split : layout === 'focus' ? focus : compact) + linkDialog + '<div class="lx-status" role="status" aria-live="polite"></div>' + foot + '</div><div class="lx-collapsed" hidden>草稿已收起,点击右上角继续编辑。</div><input type="file" data-file-input multiple hidden aria-label="选择附件文件">';
const textArea = app.querySelector('textarea');
const preview = app.querySelector('.lx-live-preview');
const status = app.querySelector('.lx-status');
const linkForm = app.querySelector('[data-link-dialog]');
const fileInput = app.querySelector('[data-file-input]');
const setStatus = text => { status.textContent = text; };
function inlineMarkdown(text) {
return safe(text).replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
.replace(/!\[([^\]]*)\]\(local-image:([\w-]+)\)/g, (_, alt, key) => {
const local = state.inlineImages.find(item => item.key === key);
return local ? '<img src="' + safe(local.url) + '" alt="' + alt + '">' : alt;
})
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
function renderMarkdown(text) {
if (!text.trim()) return '<p class="lx-empty">正文预览会显示在这里。</p>';
return text.split(/\n\n+/).map(block => {
if (/^#{1,6}\s/.test(block)) return '<h3>' + inlineMarkdown(block.replace(/^#{1,6}\s/, '')) + '</h3>';
const lines = block.split('\n');
if (lines.every(line => /^- \[[ x]\] /.test(line))) return '<ul>' + lines.map(line => '<li>' + (line.startsWith('- [x]') ? '已完成:' : '待完成:') + inlineMarkdown(line.slice(6)) + '</li>').join('') + '</ul>';
if (lines.every(line => /^- /.test(line))) return '<ul>' + lines.map(line => '<li>' + inlineMarkdown(line.slice(2)) + '</li>').join('') + '</ul>';
if (lines.every(line => /^\d+\. /.test(line))) return '<ol>' + lines.map(line => '<li>' + inlineMarkdown(line.replace(/^\d+\. /, '')) + '</li>').join('') + '</ol>';
return '<p>' + lines.map(inlineMarkdown).join('<br>') + '</p>';
}).join('');
}
function updateText() {
app.querySelector('[data-count]').textContent = textArea.value.length.toLocaleString('en-US');
preview.innerHTML = renderMarkdown(textArea.value);
app.querySelector('[data-save-status]').textContent = '草稿已暂存';
app.querySelector('.lx-publish').disabled = !textArea.value.trim() && state.files.length === 0;
}
function drawFiles() {
const list = app.querySelector('.lx-files');
list.hidden = layout === 'compact' && state.files.length === 0;
app.querySelector('.lx-materials').classList.toggle('lx-no-materials', state.files.length === 0);
if (!state.files.length) {
list.innerHTML = '<p class="lx-empty">还没有材料,可添加文件或参考链接。</p>';
} else {
list.innerHTML = state.files.map(file => '<div class="lx-file"><span class="lx-file-kind">' + icon(file.kind === 'link' ? 'link' : 'file-text') + '</span><div class="lx-file-text"><span class="lx-file-name">' + safe(file.name) + '</span><span class="lx-file-size">' + safe(file.meta) + '</span></div><button type="button" class="lx-icon cursor-interaction" data-remove-file="' + safe(file.id) + '" aria-label="移除' + safe(file.name) + '" data-tooltip="移除材料">' + icon('x') + '</button></div>').join('');
}
app.querySelectorAll('[data-material-count]').forEach(el => { el.textContent = state.files.length; });
const trayLabel = app.querySelector('[data-tray-label]');
if (trayLabel) trayLabel.textContent = '材料 ' + state.files.length;
const traySummary = app.querySelector('.lx-tray-summary');
if (traySummary) traySummary.textContent = state.files.length ? state.files.filter(file => file.kind === 'file').length + ' 个文件 · ' + state.files.filter(file => file.kind === 'link').length + ' 个链接' : '还没有附加材料';
updateText(); icons();
}
function drawTags() {
const tags = app.querySelector('.lx-tags');
tags.innerHTML = state.tags.map((tag, n) => '<button type="button" class="lx-tag cursor-interaction" data-remove-tag="' + n + '" aria-label="移除标签' + safe(tag) + '"># ' + safe(tag) + icon('x') + '</button>').join('') + state.refs.map((ref, n) => '<button type="button" class="lx-tag cursor-interaction" data-remove-ref="' + n + '" aria-label="移除引用' + safe(ref) + '">' + icon('book-open') + safe(ref) + icon('x') + '</button>').join('');
icons();
}
function closeMenus() {
app.querySelectorAll('.lx-add-menu').forEach(el => { el.hidden = true; });
app.querySelectorAll('[data-action="add-menu"]').forEach(el => { el.setAttribute('aria-expanded', 'false'); });
}
function openLink(target) {
state.linkTarget = target;
state.linkSelection = [textArea.selectionStart, textArea.selectionEnd];
textArea.readOnly = target === 'body';
closeMenus();
linkForm.hidden = false;
app.querySelector('[data-link-heading]').textContent = target === 'body' ? '插入正文超链接' : '附加参考链接';
app.querySelector('[data-link-submit]').textContent = target === 'body' ? '插入正文' : '添加到材料';
app.querySelector('[data-link-error]').textContent = '';
app.querySelector('[data-link-name]').value = target === 'body' ? textArea.value.slice(textArea.selectionStart, textArea.selectionEnd) : '';
app.querySelector('[data-link-url]').value = '';
app.querySelector('[data-link-url]').focus();
}
function setTray(open) {
if (layout !== 'focus') return;
state.trayOpen = open;
app.querySelector('#' + id + '-tray').hidden = !open;
const trigger = app.querySelector('[data-action="tray"]');
trigger.setAttribute('aria-expanded', String(open));
}
function acceptFiles(incoming, placeImagesInBody = false) {
const batch = Array.from(incoming || []);
if (!batch.length) return;
let insertedImages = 0;
let addedMaterials = 0;
for (const file of batch) {
if (placeImagesInBody && file.type.startsWith('image/') && !textArea.readOnly) {
const key = id + '-image-' + (++state.sequence);
const token = '\n\n![' + file.name.replace(/[\[\]]/g, '') + '](local-image:' + key + ')\n';
if (textArea.value.length + token.length <= 20000) {
state.inlineImages.push({ key, url: URL.createObjectURL(file) });
textArea.setRangeText(token, textArea.selectionStart, textArea.selectionEnd, 'end');
insertedImages += 1;
continue;
}
}
const extension = file.name.includes('.') ? file.name.split('.').pop().toUpperCase() : 'FILE';
const size = file.size >= 1048576 ? (file.size / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(file.size / 1024)) + ' KB';
state.files.push({ id: id + '-local-' + (++state.sequence), kind: 'file', name: file.name, meta: extension + ' · ' + size + ' · 本地预览' });
addedMaterials += 1;
}
drawFiles();
if (addedMaterials) setTray(true);
setStatus(insertedImages ? '已将 ' + insertedImages + ' 张图片插入正文,可点击预览查看。' + (addedMaterials ? '另添加 ' + addedMaterials + ' 份材料。' : '') : '已添加 ' + addedMaterials + ' 个文件,可继续编辑或移除。');
}
function insertFormat(format) {
if (textArea.readOnly) return;
if (state.preview) togglePreview();
const start = textArea.selectionStart, end = textArea.selectionEnd;
const selected = textArea.value.slice(start, end) || '文字';
const forms = { bold: '**' + selected + '**', italic: '*' + selected + '*', strike: '~~' + selected + '~~', heading: '## ' + selected, bullet: '- ' + selected, ordered: '1. ' + selected, task: '- [ ] ' + selected, code: '`' + selected + '`' };
const replacement = forms[format];
if (!replacement || textArea.value.length - (end - start) + replacement.length > 20000) return;
textArea.setRangeText(replacement, start, end, 'select'); textArea.focus(); updateText();
}
function togglePreview() {
state.preview = !state.preview;
textArea.hidden = state.preview; preview.hidden = !state.preview;
const trigger = app.querySelector('[data-action="preview"]');
trigger.innerHTML = icon(state.preview ? 'pencil' : 'eye') + '<span>' + (state.preview ? '继续编辑' : '预览') + '</span>';
trigger.setAttribute('aria-pressed', String(state.preview));
updateText(); icons();
}
function publish() {
if (!textArea.value.trim() && !state.files.length) { setStatus('请先填写正文或添加材料。'); textArea.focus(); return; }
setStatus('发布演示完成:' + (state.mode === 'assignment' ? '正式作业' : '自由讨论') + ' · ' + state.files.length + ' 份材料。内容未提交到课程。');
}
app.addEventListener('click', event => {
const control = event.target.closest('button');
if (!control || !app.contains(control)) return;
if (control.dataset.mode) {
state.mode = control.dataset.mode;
app.querySelectorAll('[data-mode]').forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.mode === state.mode)));
app.querySelector('.lx-publish span').textContent = state.mode === 'assignment' ? '发布作业' : '发布讨论';
textArea.placeholder = state.mode === 'assignment' ? '写下作业内容与要求,支持 Markdown…' : '写下你的想法,支持 Markdown…';
setStatus(''); return;
}
if (control.dataset.format) { insertFormat(control.dataset.format); return; }
if (control.dataset.removeFile) { state.files = state.files.filter(file => file.id !== control.dataset.removeFile); drawFiles(); setStatus('材料已移除。'); return; }
if (control.dataset.removeTag !== undefined) { state.tags.splice(Number(control.dataset.removeTag), 1); drawTags(); return; }
if (control.dataset.removeRef !== undefined) { state.refs.splice(Number(control.dataset.removeRef), 1); drawTags(); return; }
switch (control.dataset.action) {
case 'add-menu': {
const menu = control.parentElement.querySelector('.lx-add-menu');
const open = menu.hidden;
closeMenus(); menu.hidden = !open; control.setAttribute('aria-expanded', String(open)); break;
}
case 'file': closeMenus(); fileInput.click(); break;
case 'material-link': openLink('material'); break;
case 'body-link': openLink('body'); break;
case 'close-link': linkForm.hidden = true; textArea.readOnly = false; break;
case 'preview': togglePreview(); break;
case 'tray': setTray(!state.trayOpen); break;
case 'extras': {
const panel = app.querySelector('#' + id + '-extras');
panel.hidden = !panel.hidden; control.setAttribute('aria-expanded', String(!panel.hidden)); break;
}
case 'add-tag': {
const input = app.querySelector('[data-tag-input]');
const value = input.value.trim().replace(/^#/, '');
if (value && !state.tags.includes(value)) state.tags.push(value);
input.value = ''; drawTags(); break;
}
case 'add-ref': {
const input = app.querySelector('[data-ref-input]');
if (input.value && !state.refs.includes(input.value)) state.refs.push(input.value);
input.value = ''; drawTags(); break;
}
case 'expand': {
state.expanded = !state.expanded;
app.classList.toggle('lx-is-expanded', state.expanded);
control.innerHTML = icon(state.expanded ? 'minimize-2' : 'maximize-2');
control.setAttribute('aria-label', state.expanded ? '恢复紧凑编辑区' : '展开编辑区');
control.dataset.tooltip = state.expanded ? '恢复紧凑编辑区' : '展开编辑区';
control.setAttribute('aria-pressed', String(state.expanded)); icons(); break;
}
case 'collapse': {
state.collapsed = !state.collapsed;
shell.classList.toggle('lx-collapsed-shell', state.collapsed);
app.querySelector('.lx-compose-content').hidden = state.collapsed;
app.querySelector('.lx-collapsed').hidden = !state.collapsed;
control.innerHTML = icon(state.collapsed ? 'chevron-down' : 'chevron-up');
control.setAttribute('aria-label', state.collapsed ? '展开编辑器' : '收起编辑器');
control.dataset.tooltip = state.collapsed ? '展开编辑器' : '收起编辑器';
control.setAttribute('aria-expanded', String(!state.collapsed)); icons(); break;
}
case 'publish': publish(); break;
}
});
textArea.addEventListener('input', () => { updateText(); setStatus(''); });
textArea.addEventListener('paste', event => {
if (event.clipboardData && event.clipboardData.files.length) { event.preventDefault(); acceptFiles(event.clipboardData.files, true); }
});
fileInput.addEventListener('change', () => { acceptFiles(fileInput.files); fileInput.value = ''; });
const dropzone = app.querySelector('[data-dropzone]');
dropzone.addEventListener('dragover', event => { if (event.dataTransfer && Array.from(event.dataTransfer.types).includes('Files')) { event.preventDefault(); dropzone.classList.add('lx-drop-active'); } });
dropzone.addEventListener('dragleave', event => { if (!dropzone.contains(event.relatedTarget)) dropzone.classList.remove('lx-drop-active'); });
dropzone.addEventListener('drop', event => { event.preventDefault(); dropzone.classList.remove('lx-drop-active'); acceptFiles(event.dataTransfer.files, true); });
app.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); publish(); }
if (event.key === 'Escape') {
closeMenus();
if (!linkForm.hidden) { linkForm.hidden = true; textArea.readOnly = false; }
else if (event.target === textArea) textArea.blur();
}
if (event.target.matches('[data-tag-input]') && event.key === 'Enter') { event.preventDefault(); app.querySelector('[data-action="add-tag"]').click(); }
});
linkForm.addEventListener('submit', event => {
event.preventDefault();
const raw = app.querySelector('[data-link-url]').value.trim();
let url;
try { url = new URL(raw); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('scheme'); }
catch { app.querySelector('[data-link-error]').textContent = '请输入以 http:// 或 https:// 开头的有效链接。'; return; }
const name = app.querySelector('[data-link-name]').value.trim() || url.hostname;
if (state.linkTarget === 'body') {
const replacement = '[' + name.replace(/[\[\]]/g, '') + '](' + url.href.replace(/\)/g, '%29') + ')';
const [start, end] = state.linkSelection;
if (textArea.value.length - (end - start) + replacement.length > 20000) { app.querySelector('[data-link-error]').textContent = '正文已达到 20,000 字上限。'; return; }
textArea.setRangeText(replacement, start, end, 'end');
if (state.preview) togglePreview();
updateText(); setStatus('链接已插入正文。');
} else {
state.files.push({ id: id + '-link-' + (++state.sequence), kind: 'link', name, meta: url.hostname, url: url.href });
drawFiles(); setTray(true); setStatus('链接已添加到材料。');
}
linkForm.hidden = true;
textArea.readOnly = false;
});
window.addEventListener('pagehide', () => { state.inlineImages.forEach(item => URL.revokeObjectURL(item.url)); });
document.addEventListener('click', event => { if (!event.target.closest('.lx-add-wrap')) closeMenus(); });
app.querySelectorAll('[data-action="add-menu"]').forEach(el => { el.setAttribute('aria-expanded', 'false'); });
drawFiles(); drawTags(); updateText();
});
icons();
})();
</script>Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
feat(forum): focus canvas publishing area (#188) [skip deploy]
lyyzka commentedon Oct 19, 2026
阶段进度:PR188 已审查并合入 main(26d485dd),Web/Admin CI 成功;原作者官方浏览器3/3、头像回归1/1均零跳过,截图与命令保留在PR。当前仅完成开发合流,生产尚未部署;issue 保持开放,统一发布后回填实际版本与验收。
lyyzka commentedon Oct 19, 2026
本轮已通过 发布 PR194 正常上线;发布工作流 成功,Komodo 已返回 Complete / success=true。生产实际镜像、Web/API 与 Worker readiness、版本及迁移均已核验,源码为
b76c4cc0,正式 LingxiOS3.5.3。关闭依据:PR188 已落实专注画布、统一材料入口、紧凑展开/收起及明暗主题,保留真实上传、草稿与发布流程。正式包浏览器专项 3/3、零跳过,覆盖材料/正文链接、上传与发布失败后的状态保留、草稿恢复,以及桌面和窄屏明暗外观。另有论坛范围收敛集成 10/10 和浏览器 1/1。
论坛浏览器报告与截图可复核;功能证据来自隔离环境,生产版本及 readiness 已确认。