feat(附件管理): 完善附件管理功能

This commit is contained in:
xiaoqidun committed 2026-09-30 02:22:56 +08:00
1 parent 418a2fa0a7
commit 38004171cf
10 files changed
+238 -26

No files matched your search

+13 -1
View File
@@ -489,6 +489,18 @@
<div class="form-actions"><div id="credentialsStatus" class="form-status" role="status" aria-live="polite"></div><button id="credentialsCancel" class="button" type="button">取消</button><button class="button" type="submit">解锁</button></div>
</form>
</dialog>
<dialog id="attachmentDialog" class="form-dialog" aria-labelledby="attachmentTitle">
<form id="attachmentForm">
<h2 id="attachmentTitle" class="form-title">添加附件</h2>
<div id="attachmentFileRow" class="form-row"><label for="attachmentFile">文件</label><input id="attachmentFile" type="file" multiple required></div>
<div id="attachmentNameRow" class="form-row" hidden><label for="attachmentName">名称</label><input id="attachmentName" type="text" autocomplete="off" spellcheck="false"></div>
<div class="form-actions">
<div id="attachmentStatus" class="form-status" role="status"></div>
<button id="attachmentCancel" class="button" type="button">取消</button>
<button id="attachmentSubmit" class="button" type="submit">添加</button>
</div>
</form>
</dialog>
<dialog id="encryptionPanel" class="form-dialog" aria-labelledby="encryptionTitle">
<form id="encryptionForm">
<h2 id="encryptionTitle" class="form-title">加密文档</h2>
@@ -640,7 +652,7 @@
<dl class="meta-list"><div><dt>加密</dt><dd id="securityEncryption">未加密</dd></div><div id="securitySchemeRow" hidden><dt>方式</dt><dd id="securityScheme"></dd></div><div id="securityMethodRow" hidden><dt>算法</dt><dd id="securityMethod"></dd></div></dl>
</section>
<section id="attachmentPanel" class="attachment-panel" aria-labelledby="attachmentPanelTitle" hidden>
<div id="attachmentPanelTitle" class="panel-title">附件</div>
<div class="panel-row"><div id="attachmentPanelTitle" class="panel-title compact">附件</div><div class="panel-actions"><button id="attachmentAdd" class="small-button" type="button" title="添加附件" aria-haspopup="dialog" aria-controls="attachmentDialog" hidden>添加</button></div></div>
<div id="attachmentList" class="attachment-list"></div>
</section>
<section id="signaturePanel" class="signature-panel" aria-labelledby="signaturePanelTitle" hidden>
+21
View File
@@ -1904,6 +1904,7 @@ button.outline-link:hover {
.empty-state[hidden],
.editor-group > [hidden],
.panel-row > [hidden],
.panel-actions > [hidden],
.meta-list > [hidden],
.navigation-tabs[hidden],
.page-selection-tools[hidden],
@@ -2549,6 +2550,26 @@ button.outline-link:hover {
gap: 8px;
}
.attachment-row {
display: flex;
align-items: center;
gap: 8px;
}
.attachment-row .attachment-item {
min-width: 0;
flex: 1;
}
.attachment-actions {
flex: 0 0 60px;
width: 60px;
border: 1px solid var(--line-strong);
background: #ffffff;
color: #1f2428;
cursor: pointer;
}
.attachment-item {
width: 100%;
padding: 4px 0;
+93 -7
View File
@@ -481,6 +481,17 @@ const el = {
metaFonts: document.querySelector("#metaFonts"),
attachmentPanel: document.querySelector("#attachmentPanel"),
attachmentList: document.querySelector("#attachmentList"),
attachmentAdd: document.querySelector("#attachmentAdd"),
attachmentDialog: document.querySelector("#attachmentDialog"),
attachmentForm: document.querySelector("#attachmentForm"),
attachmentTitle: document.querySelector("#attachmentTitle"),
attachmentFile: document.querySelector("#attachmentFile"),
attachmentFileRow: document.querySelector("#attachmentFileRow"),
attachmentName: document.querySelector("#attachmentName"),
attachmentNameRow: document.querySelector("#attachmentNameRow"),
attachmentStatus: document.querySelector("#attachmentStatus"),
attachmentCancel: document.querySelector("#attachmentCancel"),
attachmentSubmit: document.querySelector("#attachmentSubmit"),
signaturePanel: document.querySelector("#signaturePanel"),
signatureSummary: document.querySelector("#signatureSummary"),
signatureList: document.querySelector("#signatureList"),
@@ -2916,6 +2927,8 @@ async function changeDocument(name, item, ...args) {
el.outlineStatus.textContent = err.message;
} else if (el.infoPanel.open) {
el.infoStatus.textContent = err.message;
} else if (el.attachmentDialog.open) {
el.attachmentStatus.textContent = err.message;
} else if (el.pagePanel.open) {
el.pageStatus.textContent = err.message;
} else if (el.paragraphPanel.open) {
@@ -4380,6 +4393,43 @@ function downloadAttachment(attachment) {
return downloadResource(attachment, "ofdgoExportAttachment", "附件");
}
editorClick(el.attachmentAdd, () => openAttachmentDialog("add"));
el.attachmentCancel.addEventListener("click", () => el.attachmentDialog.close());
el.attachmentDialog.addEventListener("cancel", event => { if (el.attachmentForm.inert) event.preventDefault(); });
el.attachmentDialog.addEventListener("close", () => {
el.attachmentForm.reset();
delete el.attachmentDialog.attachmentEdit;
});
el.attachmentForm.addEventListener("submit", async event => {
event.preventDefault();
const edit = el.attachmentDialog.attachmentEdit;
if (!edit || edit.openSeq !== state.openSeq || !el.attachmentForm.reportValidity()) return;
const files = Array.from(el.attachmentFile.files);
const names = edit.action === "rename" ? [el.attachmentName.value] : files.map(file => file.name);
if (await changeDocument("ofdgoChangeAttachment", null, edit.action, edit.id, names, files)) {
el.attachmentDialog.close();
setStatus({add:"附件已添加", rename:"附件已改名", replace:"附件已替换"}[edit.action]);
}
});
function openAttachmentDialog(action, attachment) {
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
const rename = action === "rename";
el.attachmentForm.reset();
el.attachmentDialog.attachmentEdit = {action, id: attachment?.id || "", openSeq: state.openSeq};
el.attachmentTitle.textContent = {add:"添加附件", rename:"附件改名", replace:"替换附件"}[action];
el.attachmentSubmit.textContent = {add:"添加", rename:"确定", replace:"替换"}[action];
el.attachmentFileRow.hidden = rename;
el.attachmentFile.required = !rename;
el.attachmentFile.multiple = action === "add";
el.attachmentNameRow.hidden = !rename;
el.attachmentName.required = rename;
el.attachmentName.value = attachment?.name || "";
el.attachmentStatus.textContent = "";
el.attachmentDialog.showModal();
(rename ? el.attachmentName : el.attachmentFile).focus();
}
async function saveDocumentImage(resource) {
if (!resource || !state.doc || document.body.hasAttribute("aria-busy")) return;
const seq = state.openSeq;
@@ -7241,7 +7291,7 @@ function renderMeta(keepDetails = false) {
el.pageTotal.textContent = String(doc.pageCount || 0);
if (keepDetails && doc.detailsPending) return;
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
renderMetaContent(el.attachmentList, [doc.attachments, doc.attachmentError], renderAttachments);
renderMetaContent(el.attachmentList, [doc.attachments, doc.attachmentError, state.editing], renderAttachments);
renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts();
@@ -7256,8 +7306,9 @@ function renderMetaContent(node, value, render) {
}
function renderAttachments() {
const attachments = state.doc?.attachments || [];
el.attachmentPanel.hidden = !attachments.length && !state.doc?.attachmentError;
const attachments = (state.doc?.attachments || []).filter(item => state.editing || item.visible !== false);
el.attachmentPanel.hidden = !state.doc || !state.editing && !attachments.length && !state.doc.attachmentError;
el.attachmentAdd.hidden = !state.editing;
el.attachmentList.replaceChildren();
if (state.doc?.attachmentError) {
const error = document.createElement("div");
@@ -7267,17 +7318,25 @@ function renderAttachments() {
el.attachmentList.append(error);
return;
}
if (!attachments.length) {
const empty = document.createElement("div");
empty.className = "attachment-detail";
empty.textContent = "暂无附件";
el.attachmentList.append(empty);
}
for (const attachment of attachments) {
const row = document.createElement("div");
row.className = "attachment-row";
const item = document.createElement("button");
item.type = "button";
item.className = "attachment-item";
item.title = `下载 ${attachment.fileName}`;
item.setAttribute("aria-label", `下载 ${attachment.name}`);
item.title = `另存 ${attachment.fileName}`;
item.setAttribute("aria-label", `另存 ${attachment.name}`);
const name = document.createElement("span");
name.className = "attachment-name";
name.textContent = attachment.name;
item.append(name);
const detail = [attachment.format, attachment.size == null ? "" : formatBytes(attachment.size * 1024)].filter(Boolean).join(" · ");
const detail = [attachment.format, attachment.size == null ? "" : formatBytes(attachment.size * 1024), attachment.visible === false ? "隐藏" : ""].filter(Boolean).join(" · ");
if (detail) {
const meta = document.createElement("span");
meta.className = "attachment-detail";
@@ -7285,7 +7344,30 @@ function renderAttachments() {
item.append(meta);
}
item.addEventListener("click", () => downloadAttachment(attachment));
el.attachmentList.append(item);
row.append(item);
if (state.editing) {
const actions = document.createElement("select");
actions.className = "small-button attachment-actions";
actions.title = "附件操作";
actions.setAttribute("aria-label", `${attachment.name} 操作`);
for (const [value, text] of [["", "操作"], ["rename", "改名"], ["replace", "替换"], ["delete", "删除"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
actions.append(option);
}
actions.addEventListener("change", async () => {
const action = actions.value;
actions.value = "";
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
if (action === "delete") {
if (window.confirm(`删除附件“${attachment.name}”?`) && await changeDocument("ofdgoChangeAttachment", null, action, attachment.id, [], [])) setStatus("附件已删除");
} else if (action) openAttachmentDialog(action, attachment);
});
row.append(actions);
}
el.attachmentList.append(row);
}
}
@@ -8005,6 +8087,8 @@ function updateFitSpace() {
function updateControls() {
renderSecurity();
el.attachmentAdd.disabled = !state.editing || !state.ready || state.exporting;
renderMetaContent(el.attachmentList, [state.doc?.attachments, state.doc?.attachmentError, state.editing], renderAttachments);
const hasDoc = Boolean(state.doc);
updateDisplayControls();
updateEditorTools();
@@ -8217,6 +8301,8 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.pageForm.inert = busy;
el.paragraphForm.inert = busy;
el.infoForm.inert = busy;
el.attachmentForm.inert = busy;
el.attachmentPanel.inert = busy;
el.annotationForm.inert = busy;
el.importForm.inert = busy && !state.importing;
for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy;
+7
View File
@@ -173,6 +173,13 @@ async function handleMessage({ id, name, args }) {
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
try {
signal?.throwIfAborted();
if (name === "ofdgoChangeAttachment" && (args[0] === "add" || args[0] === "replace")) {
const files = args[3], reader = new FileReaderSync();
args[3] = index => {
try { return { bytes: new Uint8Array(reader.readAsArrayBuffer(files[index])) }; }
catch (err) { return { error: err.message }; }
};
}
if (name === "ofdgoImportImages") {
const files = args[0], reader = new FileReaderSync();
args[0] = files.map(file => file.name);
+41 -2
View File
@@ -30,7 +30,6 @@ import (
"io"
"io/fs"
"math"
"path"
"reflect"
"slices"
"strconv"
@@ -212,6 +211,7 @@ func RunWASM() {
registerAsyncCallback("ofdgoImportImages", importImages)
registerCallback("ofdgoEditDocument", editDocument)
registerCallback("ofdgoUpdateInfo", updateInfo)
registerCallback("ofdgoChangeAttachment", changeAttachment)
registerCallback("ofdgoChangePage", changePage)
registerCallback("ofdgoBatchPages", batchPages)
registerCallback("ofdgoChangeOutline", changeOutline)
@@ -801,7 +801,7 @@ func renderPage(args []js.Value) (any, error) {
return nil, err
}
for _, attachment := range attachments {
attachmentNames[attachment.ID] = path.Base(currentSession.Reader.ResPath(attachment.FileLoc))
attachmentNames[attachment.ID] = attachment.FileName()
}
break
}
@@ -3535,6 +3535,45 @@ func updateInfo(args []js.Value) (any, error) {
})
}
// changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史
// 入参: args 操作、附件标识、名称列表、文件读取回调
// 返回: any 编辑状态, error 错误信息
func changeAttachment(args []js.Value) (any, error) {
return changeObjects(func() error {
action, id := args[0].String(), args[1].String()
switch action {
case "rename":
return currentEditor.RenameAttachment(id, args[2].Index(0).String())
case "delete":
return currentEditor.DeleteAttachment(id)
case "add", "replace":
count := args[2].Length()
if count == 0 || action == "replace" && count != 1 {
return fmt.Errorf("请选择附件")
}
for i := range count {
result := args[3].Invoke(i)
if message := result.Get("error"); message.Type() == js.TypeString {
return fmt.Errorf("%s", message.String())
}
data, err := bytesFromJS(result.Get("bytes"))
if err != nil {
return err
}
if action == "replace" {
return currentEditor.ReplaceAttachment(id, data)
}
if _, err := currentEditor.AddAttachment(args[2].Index(i).String(), data); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("unsupported attachment action %q", action)
}
})
}
// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置
// 入参: args 当前页索引,省略时使用首页
// 返回: any 编辑状态, error 错误信息
+5 -4
View File
@@ -18,7 +18,6 @@ import (
"bytes"
"fmt"
"io"
"path"
"reflect"
"slices"
"strings"
@@ -111,13 +110,14 @@ type DocumentDetails struct {
// OutlineInfo 目录节点信息
type OutlineInfo = ofdgo.OutlineInfo
// AttachmentInfo 可见附件信息,Size单位为KB
// AttachmentInfo 附件信息,Size单位为KB
type AttachmentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Format string `json:"format,omitempty"`
Size *float64 `json:"size,omitempty"`
FileName string `json:"fileName"`
Visible bool `json:"visible"`
}
// PageInfo 页面信息
@@ -419,13 +419,14 @@ func (s *Session) Details() DocumentDetails {
info := DocumentDetails{}
if attachments, err := s.Reader.Attachments(); err == nil {
for _, attachment := range attachments {
if attachment.Visible {
if attachment.Visible || s.editing {
info.Attachments = append(info.Attachments, AttachmentInfo{
ID: attachment.ID,
Name: attachment.Name,
Format: attachment.Format,
Size: attachment.Size,
FileName: path.Base(s.Reader.ResPath(attachment.FileLoc)),
FileName: attachment.FileName(),
Visible: attachment.Visible,
})
}
}
+24 -1
View File
@@ -14,7 +14,11 @@
package ofdgo
import "encoding/xml"
import (
"encoding/xml"
"path"
"strings"
)
// Document 文档结构
type Document struct {
@@ -226,6 +230,25 @@ func (doc *Document) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return nil
}
// FileName 获取另存文件名,优先使用带扩展名的显示名称,剔除目录及文件名非法字符
// 返回: string 文件名
func (a Attachment) FileName() string {
name := path.Base(strings.ReplaceAll(a.Name, "\\", "/"))
file := path.Base(strings.ReplaceAll(a.FileLoc, "\\", "/"))
if name == "." || name == "/" || path.Ext(name) == "" && path.Ext(file) != "" {
name = file
}
if name == "." || name == "/" {
name = "attachment"
}
return strings.Map(func(r rune) rune {
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
return '_'
}
return r
}, name)
}
// UnmarshalXML 解析附件并应用默认值
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
+28 -9
View File
@@ -18,9 +18,9 @@ import (
"bytes"
"encoding/xml"
"fmt"
"maps"
"path"
"strings"
"time"
"unicode/utf8"
)
@@ -130,7 +130,7 @@ func (e *Editor) RenameAttachment(id, name string) error {
})
}
// ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式
// ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式,不新增日期
// 入参: id 附件标识, data 文件数据,调用后可复用
// 返回: error 附件结构或提交错误
func (e *Editor) ReplaceAttachment(id string, data []byte) error {
@@ -150,23 +150,28 @@ func (e *Editor) ReplaceAttachment(id string, data []byte) error {
} else {
fragment = editorPatchXML(fragment, []editorXMLPatch{{node.open, node.open, editorXMLText("FileLoc", "/"+file)}})
}
for _, attr := range []struct{ name, value string }{
{"Size", ofdNumber(float64(len(data)) / 1024)}, {"ModDate", time.Now().UTC().Format(time.RFC3339)},
} {
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
fragment, err = editorXMLAttribute(fragment, root, attr.name, attr.value)
fragment, err = editorXMLAttribute(fragment, root, "Size", ofdNumber(float64(len(data))/1024))
if err != nil {
return nil, err
}
}
parts[file] = bytes.Clone(data)
return fragment, nil
})
}
// DeleteAttachment 删除附件声明,保存时清理可确认无引用的内容,支持撤销和重做
// 入参: id 附件标识
// 返回: error 附件结构或提交错误
func (e *Editor) DeleteAttachment(id string) error {
return e.editAttachment(id, func([]byte, *editorXML, map[string][]byte) ([]byte, error) {
return nil, nil
})
}
// editAttachment 对单个附件片段执行原子编辑,兼容独立附件文件及内联声明
// 入参: id 附件标识, change 片段及资源修改函数
// 返回: error 结构、标识或提交错误
@@ -194,7 +199,7 @@ func (e *Editor) editAttachment(id string, change func([]byte, *editorXML, map[s
var found *editorXML
if root != nil {
for _, child := range root.children {
if child.name.Local == "Attachment" && child.attr("ID") == id {
if packageOFDNode(child, "Attachment") && child.attr("ID") == id {
if found != nil {
return fmt.Errorf("duplicate attachment ID: %s", id)
}
@@ -222,7 +227,21 @@ func (e *Editor) editAttachment(id string, change func([]byte, *editorXML, map[s
return nil
}
parts[name] = editorPatchXML(data, []editorXMLPatch{{found.start, found.end, updated}})
return e.commitAnnotationParts(base, parts)
if err := e.commitAnnotationParts(base, parts); err != nil {
return err
}
if file := node.child("FileLoc"); file != nil {
location := strings.TrimSpace(editorImportText(fragment, file))
if location != "" {
retired := maps.Clone(base.retiredAttachments)
if retired == nil {
retired = make(map[string]bool)
}
retired[strings.ToLower(cleanPackagePath(base.reader.ResPath(location)))] = true
e.source.retiredAttachments = retired
}
}
return nil
}
// validateAttachmentName 校验非空附件名及XML可无损表示的Unicode字符
+1
View File
@@ -41,6 +41,7 @@ type editorSource struct {
idsReady bool
annotationStates map[string]map[string]editorCompositeState
annotationPages map[string]bool
retiredAttachments map[string]bool
}
// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用
+5 -2
View File
@@ -34,7 +34,7 @@ type editorResourceRefs struct {
fonts map[string]*editorFontUsage
}
// compactSourceResources 在保存副本中清理无引用字体和图片,并裁剪可完整确认用字的原有字体
// compactSourceResources 在保存副本中清理无引用字体、图片和已删除附件,并裁剪可完整确认用字的原有字体
// 按GB/T33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档
// 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过
// 入参: parts 已修改和新增的包内条目, progress 保存进度回调
@@ -89,7 +89,10 @@ func (e *Editor) compactSourceResources(parts map[string][]byte, progress editor
if err := progress.report("references", len(ordered), len(ordered)); err != nil {
return nil, err
}
removed := make(map[string]bool)
removed := maps.Clone(e.source.retiredAttachments)
if removed == nil {
removed = make(map[string]bool)
}
usedFiles := maps.Clone(refs.files)
updates := make(map[string][]byte)
fontFiles := make(map[string]*editorFontUsage)