feat(phase2): 完成第二阶段评测、函数图与多格式导出 #44

Merged
Kronecker merged 10 commits from feat/phase2-completion into main 2026-09-07 15:11:04 +08:00
3 changed files with 37 additions and 4 deletions
Showing only changes of commit 894f220239 - Show all commits
@@ -9,3 +9,10 @@
- 后端全量 882 项、前端全量 451 项通过;最终排版调整后相关前端19项、资源/浏览器后端15项通过;生产构建成功。1 条既有 Starlette 警告和既有大分包提示保留。
- 浏览器烟测验证 CSS 伪元素文本进入 PDF,文档脚本未执行;Vault 越界图片被拒绝,17 条函数表达式可准备。
- 用户已有的三份笔记修改未纳入提交。
## 元数据栏补充验收
PDF 快照使用与可视编辑器相同的 `splitNoteMetadata` 解析当前内容,在正文前输出笔记属性、标题和标签,保留 `.note-metadata` 结构及 Vue scoped 样式属性。标签输入框和移除按钮不进入导出。识别出的 YAML 不再作为 Markdown 正文渲染;不支持的元数据仍按编辑器规则保留原文。不额外读取磁盘,因此导出包含当前未保存的元数据。
验证:PDF 快照与导出服务 15 项测试通过,前端类型检查及生产构建通过(保留已有大分块提示)。六种主题均实际生成 PDF,并检查包含元数据栏的第一页:paper-moments、dark、light、sepia、ocean-blue、midnight-purple。纸张主题的胶带、缝线及标签配色正常,其他主题的元数据背景、边框和文字配色正常。
@@ -38,3 +38,23 @@ it('an aborted snapshot never requests backend resources',async()=>{
await expect(preparePdfSnapshot('text','note',{theme_id:'light',include_title:false,page_size:'A4'},controller.signal)).rejects.toMatchObject({name:'AbortError'})
expect(apiClient.post).not.toHaveBeenCalled()
})
it('renders snapshot metadata with editor theme scopes and excludes YAML from the body',async()=>{
const markdown='---\ntitle: "<Current & title>"\ntags: ["<tag>", "未保存标签"]\n---\n# Body'
const html=await preparePdfSnapshot(markdown,'filename',{theme_id:'light',include_title:false,page_size:'A4'})
const doc=new DOMParser().parseFromString(html,'text/html')
const metadata=doc.querySelector('.milkdown-host > .note-metadata')!
expect(metadata.querySelector('h1')?.textContent).toBe('<Current & title>')
expect([...metadata.querySelectorAll('.metadata-tag')].map(el=>el.textContent)).toEqual(['<tag>','未保存标签'])
expect([...metadata.querySelectorAll('*')].every(el=>el.hasAttribute('data-v-editor'))).toBe(true)
expect(metadata.querySelector('button,input,form')).toBeNull()
expect(renderMarkdown).toHaveBeenCalledWith('# Body',expect.anything())
expect(apiClient.post).toHaveBeenCalledWith('/api/exports/preview-resources',expect.objectContaining({source:expect.objectContaining({markdown:'# Body'})}))
})
it('does not invent a metadata bar for plain notes or unsupported frontmatter',async()=>{
for(const source of ['plain text','---\ntitle: [invalid]\n---\nbody']) {
const html=await preparePdfSnapshot(source,'filename',{theme_id:'light',include_title:true,page_size:'A4'})
expect(html).not.toContain('<section class="note-metadata"')
expect(renderMarkdown).toHaveBeenCalledWith(source,expect.anything())
}
})
+10 -4
View File
@@ -1,5 +1,7 @@
import { apiClient } from './apiClient'
import { renderMarkdown } from '@/utils/markdown'
import { splitNoteMetadata } from '@/utils/noteMetadata'
import { t } from '@/i18n'
import { mermaidThemeVariables } from './mermaidService'
import { useThemeStore } from '@/stores/theme'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
@@ -71,9 +73,14 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op
const headingStyle=Object.entries(heading.cssVariables).map(([key,value])=>`${key}:${value}`).join(';')
const customHeading=heading.preferences.custom
const dark=theme.isDark
const resources=await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown,file_path:filePath},options})
const metadata=splitNoteMetadata(markdown)
const body=metadata?.body ?? markdown
const scope=scopeAttributes(VisualMarkdownEditor)
// Match the editor DOM and scoped styles, with read-only metadata controls.
const metadataHtml=metadata ? `<section class="note-metadata"${scope} aria-label="${escape(t('笔记属性','Note properties'))}"><span class="metadata-caption"${scope}>${escape(t('笔记属性','Note properties'))}</span>${metadata.title ? `<h1${scope}>${escape(metadata.title)}</h1>` : ''}<div class="metadata-tags"${scope}><span class="metadata-label"${scope}>${escape(t('标签','Tags'))}</span>${metadata.tags.map(tag=>`<span class="metadata-tag"${scope}><span${scope}>${escape(tag)}</span></span>`).join('')}</div></section>` : ''
const resources=await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options})
signal?.throwIfAborted()
const rendered=await renderMarkdown(markdown,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{
const rendered=await renderMarkdown(body,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{
const plot=resources.plots.find(p=>p.source.trim()===source.trim()); if(!plot?.svg)throw Error(plot?.warnings.join('; ')||'函数图像无法导出');return plot
}}})
const fragment=new DOMParser().parseFromString(rendered,'text/html')
@@ -101,6 +108,5 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op
fragment.querySelectorAll('.markdown-code-toolbar button,.diagram-controls').forEach(e=>e.remove())
const css=(await Promise.all(styles.map(s=>embedCss(s.css,s.base,signal)))).join('\n')
signal?.throwIfAborted()
const scope=scopeAttributes(VisualMarkdownEditor)
return `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><title>${escape(title)}</title><style>${css.replace(/<\/style/gi,'<\\/style')}\n:root{${rootVariables}}\n${printRules}</style></head><body${bodyAttrs}><div class="visual-editor pdf-document"${scope} ${customHeading?'data-heading-style="custom"':''} style="${escape(headingStyle)}"><div class="milkdown-host"${scope}><div class="milkdown"><article class="ProseMirror markdown-content" data-code-wrap="${preferences.wrapCode}" data-line-numbers="${preferences.lineNumbers}" style="--markdown-code-indent:${preferences.indent}">${options.include_title?`<h1>${escape(title)}</h1>`:''}${fragment.body.innerHTML}</article></div></div></div></body></html>`
return `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><title>${escape(title)}</title><style>${css.replace(/<\/style/gi,'<\\/style')}\n:root{${rootVariables}}\n${printRules}</style></head><body${bodyAttrs}><div class="visual-editor pdf-document"${scope} ${customHeading?'data-heading-style="custom"':''} style="${escape(headingStyle)}"><div class="milkdown-host"${scope}>${metadataHtml}<div class="milkdown"><article class="ProseMirror markdown-content" data-code-wrap="${preferences.wrapCode}" data-line-numbers="${preferences.lineNumbers}" style="--markdown-code-indent:${preferences.indent}">${options.include_title?`<h1>${escape(title)}</h1>`:''}${fragment.body.innerHTML}</article></div></div></div></body></html>`
}