Browse Source

fix: normalize PPTX notes and deploy pnpm build

bob 1 month ago
parent
commit
fcf1e84c9e
6 changed files with 74 additions and 112 deletions
  1. 26 0
      AGENTS.md
  2. 6 6
      Dockerfile
  3. 1 0
      deploy.sh
  4. 4 1
      server/pptx-notes.ts
  5. 32 103
      src/pages/CourseWorkspace.tsx
  6. 5 2
      src/utils/lectureNotes.ts

+ 26 - 0
AGENTS.md

@@ -548,3 +548,29 @@
 - 未确认与风险:未在隔离数据库中实际执行迁移及新老师登录全流程;未在公网部署或验收。固定初始密码仍需由机构管理员妥善交付,账号可自行改密。
 - 后续修改提示:不得恢复新老师首次登录强制改密,除非用户提出新的规则;涉及登录流程应同时核对前端路由和服务端鉴权。
 - 关联:替代 D-006;C-20260923-06。
+
+### C-20260923-26|部署当前系统到 xinghen.work
+
+- 日期:2026-09-23
+- 类型:部署 / 修复
+- 需求与原因:用户要求将整个系统部署到服务器,并确保 `xinghen.work` 可访问;授权必要时覆盖服务器数据库数据。
+- 修改范围:`Dockerfile` 改为按 `pnpm-lock.yaml` 冻结安装、构建和启动;`deploy.sh` 同步时排除 `uploads/`;将当前代码构建为 `linux/amd64` 镜像并上传至 `47.93.193.127:/opt/xinghen`,重建应用容器。保留现有 PostgreSQL 与上传持久卷,未执行数据库覆盖。部署前数据库备份位于服务器 `/opt/xinghen/.deploy/pre-deploy-20260923.dump`。
+- 保留/新增判断:遵守 D-007 至 D-010。数据库迁移在现有库上成功时保留数据,不因已有覆盖授权而无故清库;生产代理与证书配置沿用已核对的当前配置。
+- 功能确认状态:部分确认
+- 验证证据:本地 `pnpm run check`、`pnpm run build`、生产镜像构建、Compose 配置检查、脚本语法检查通过;新容器运行镜像 SHA `589c7600dc81faf8f563db789fa5fb6200fe56b862cd944434f6e08c52e4fddf`。公网首页与 `/lp` 均返回 200,且返回本次构建的主站及落地页资源名;资源请求返回 200;`/api/health` 返回 200 与 `{"ok":true}`;HTTP 跳 HTTPS 返回 301;证书 SAN 包含 `xinghen.work`,有效期至 2026-12-12;未登录访问 `/api/me` 与 `/api/admin/leads` 均返回 401。容器内 LibreOffice 可执行,上传卷仍挂载。迁移后普通用户 5 条、平台管理员 1 条、课程 5 条,`lead_requests` 表存在。
+- 未确认与风险:未使用真实账号完成登录、课程编辑、素材预览、线索提交及后台操作的公网端到端验收;未覆盖所有旧数据组合。回滚可恢复部署前数据库备份和先前镜像,上传卷没有被替换。
+- 后续修改提示:后续部署仍须核对实际域名、当前代理配置和持久卷;若触发数据库覆盖,应先核对备份及目标库;新增功能需补充公网业务路径验收。
+- 关联:D-007、D-008、D-009、D-010、C-20260923-21、C-20260923-24。
+
+### C-20260923-27|修正 PPTX 备注换行并在选取素材后直接上传
+
+- 日期:2026-09-23
+- 类型:修复 / 功能
+- 需求与原因:PPTX 备注导入的口播稿把换行显示为字面量 `
`;课程详情选择素材后还需再次点击上传。
+- 修改范围:`server/pptx-notes.ts` 仅将备注中的十进制或十六进制换行字符引用转为真实换行;`src/utils/lectureNotes.ts` 对以“第 1 页”开头的旧版纯文本导入稿在展示、复制和打开编辑器时做相同规范化;`src/pages/CourseWorkspace.tsx` 选择分集素材或通用附件后立即上传,移除待上传清单及二次上传按钮,增加上传期间禁用重复选择,并在失败后刷新素材列表、提示核对后重选。
+- 保留/新增判断:遵守 D-005、D-009 和 C-20260923-20;原始 PPTX 不变,已有人工口播稿不被自动覆盖;只识别换行字符引用,不对任意 HTML 实体做全面解码。历史讲稿的数据库原值暂不批量改写,用户打开并保存后写回规范化文本。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build` 通过;使用合成 PPTX 备注验证 `
` 经解析变成实际换行,而无关的 `&` 仍保持原文本;检查两处文件选择入口均直接调用上传函数,且上传期间禁用再次选择。
+- 未确认与风险:未使用已登录浏览器完成真实上传与刷新回显,也未在公网验证;未对生产数据库中的历史讲稿进行批量更新。现有上传接口逐份存储文件后写入数据库,若请求中途失败可能留下部分成功文件,重试前应查看素材列表。
+- 后续修改提示:若需要批量持久修正历史讲稿,先按课程和分集预览差异并备份原值;PPTX 特殊文件仍需核对幻灯片真实顺序与备注对应关系。
+- 关联:C-20260923-18、C-20260923-20。

+ 6 - 6
Dockerfile

@@ -1,9 +1,9 @@
 FROM node:22-alpine AS build
 WORKDIR /app
-COPY package*.json ./
-RUN npm install
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN corepack enable && pnpm install --frozen-lockfile
 COPY . .
-RUN npm run build
+RUN pnpm run build
 
 FROM node:22-alpine
 WORKDIR /app
@@ -11,12 +11,12 @@ ENV NODE_ENV=production
 RUN apk add --no-cache libreoffice font-noto-cjk \
     && test -x /usr/bin/soffice \
     && /usr/bin/soffice --version
-COPY package*.json ./
-RUN npm install --omit=dev
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN corepack enable && pnpm install --frozen-lockfile
 COPY --from=build /app/dist ./dist
 COPY --from=build /app/server ./server
 COPY --from=build /app/sample ./sample
 COPY --from=build /app/tsconfig*.json ./
 RUN mkdir -p uploads
 EXPOSE 3001
-CMD ["npm", "start"]
+CMD ["pnpm", "start"]

+ 1 - 0
deploy.sh

@@ -26,6 +26,7 @@ rsync -az --delete \
   --exclude 'node_modules/' \
   --exclude 'dist/' \
   --exclude '.env' \
+  --exclude 'uploads/' \
   --exclude '.deploy/' \
   --exclude '.deploy-known-hosts' \
   --exclude '*.pem' \

+ 4 - 1
server/pptx-notes.ts

@@ -4,6 +4,9 @@ import { XMLParser } from 'fast-xml-parser'
 
 const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })
 const list = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
+// Some PPTX producers store line breaks as literal character references inside note text.
+// Decode only those references, leaving other user-authored entities untouched.
+export const normalizePptxNoteBreaks = (value: string) => value.replace(/&#(?:10|x0*a);/gi, '\n')
 
 function textNodes(value: unknown): string[] {
   if (!value || typeof value !== 'object') return []
@@ -23,7 +26,7 @@ export async function extractPptxSpeakerNotes(path: string): Promise<string[]> {
     const shapes = list(parser.parse(xml)?.notes?.cSld?.spTree?.sp)
     const body = shapes.find((shape: any) => shape?.nvSpPr?.nvPr?.ph?.['@_type'] === 'body') as any
     const paragraphs = list(body?.txBody?.p)
-      .map((paragraph: unknown) => textNodes(paragraph).join('').trim())
+      .map((paragraph: unknown) => normalizePptxNoteBreaks(textNodes(paragraph).join('')).trim())
       .filter(Boolean)
     notes.push(paragraphs.join('\n'))
   }

+ 32 - 103
src/pages/CourseWorkspace.tsx

@@ -20,7 +20,6 @@ import {
   Trash2,
   Upload,
   UserPlus,
-  X,
   ZoomIn
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
@@ -80,8 +79,8 @@ export function CourseWorkspace() {
     return () => window.removeEventListener('keydown', handleKeyDown)
   }, [previewImage])
 
-  // 全局资产待上传
-  const [globalFiles, setGlobalFiles] = useState<File[]>([])
+  const uploadInProgress = useRef(false)
+  const [uploadingTarget, setUploadingTarget] = useState<string | null>(null)
 
   // 分集展开折叠状态 Map (episodeId -> boolean)
   const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
@@ -89,10 +88,6 @@ export function CourseWorkspace() {
   const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
   const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
 
-  // 分集上传文件输入引用 Map (episodeId -> File[])
-  const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
-  const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
-
   // 通用资产备注状态
   const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
   const [assetNotesDraft, setAssetNotesDraft] = useState('')
@@ -128,21 +123,27 @@ export function CourseWorkspace() {
     setTimeout(() => setSuccessNotice(''), 4000)
   }
 
-  // 上传全局通用资产
-  async function handleUploadGlobalAssets() {
-    if (!globalFiles.length) return
-    if (globalFiles.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
-    setBusy(true)
+  // 选择后直接上传;同步锁避免同一轮渲染中重复选择导致响应互相覆盖。
+  async function handleUploadAssets(files: File[], episodeId?: string) {
+    if (!files.length || uploadInProgress.current) return
+    if (files.length > 20) { setError('每次最多上传 20 份文件,请分批选择'); return }
+    uploadInProgress.current = true
+    setUploadingTarget(episodeId || 'global')
     setError('')
     try {
-      const res = await api.uploadAssets(id, globalFiles)
+      const res = await api.uploadAssets(id, files, episodeId)
       setCourse(res.course)
-      setGlobalFiles([])
-      notify(res.notesImported ? `资产上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '全局资产上传成功')
+      notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : episodeId ? '分集素材上传成功' : '通用附件上传成功')
     } catch (e) {
-      setError((e as Error).message)
+      const message = (e as Error).message
+      try {
+        const latest = await api.getCourse(id)
+        setCourse(latest.course)
+      } catch { /* Keep the upload error visible if refreshing also fails. */ }
+      setError(`上传失败:${message}。请核对素材列表,再选择未上传的文件重试。`)
     } finally {
-      setBusy(false)
+      uploadInProgress.current = false
+      setUploadingTarget(null)
     }
   }
 
@@ -182,25 +183,6 @@ export function CourseWorkspace() {
     }
   }
 
-  // 上传特定分集的专属资产
-  async function handleUploadEpisodeAssets(episodeId: string) {
-    const files = epFilesMap[episodeId] || []
-    if (!files.length) return
-    if (files.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
-    setUploadingEpId(episodeId)
-    setError('')
-    try {
-      const res = await api.uploadAssets(id, files, episodeId)
-      setCourse(res.course)
-      setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
-      notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '分集素材上传成功')
-    } catch (e) {
-      setError((e as Error).message)
-    } finally {
-      setUploadingEpId(null)
-    }
-  }
-
   // 保存分集讲稿正文
   async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
     setSavingNotesId(episode.id)
@@ -717,7 +699,6 @@ export function CourseWorkspace() {
               {episodes.map((ep) => {
                 const isExpanded = !!expandedMap[ep.id]
                 const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
-                const pendingFiles = epFilesMap[ep.id] || []
 
                 return (
                   <div className={'episode-card ' + (isExpanded ? 'expanded' : 'collapsed')} key={ep.id}>
@@ -787,18 +768,19 @@ export function CourseWorkspace() {
                                 <strong>上传素材</strong>
                                 {epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
                               </div>
-                              <label className="ep-file-label compact-upload-button">
+                              <label className="ep-file-label compact-upload-button" aria-disabled={!!uploadingTarget}>
                                 <Upload size={14} />
-                                <span>添加文件(可多选)</span>
+                                <span>{uploadingTarget === ep.id ? '正在上传…' : '添加文件(可多选)'}</span>
                                 <input
                                   type="file"
                                   accept={assetAccept}
                                   multiple
                                   hidden
+                                  disabled={!!uploadingTarget}
                                   onChange={(e) => {
                                     const selected = Array.from(e.target.files || [])
-                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: [...(prev[ep.id] || []), ...selected] }))
                                     e.target.value = ''
+                                    void handleUploadAssets(selected, ep.id)
                                   }}
                                 />
                               </label>
@@ -826,41 +808,7 @@ export function CourseWorkspace() {
                                 </div>
                               ))}
                               {!epAssets.length && (
-                                <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频,支持一次多选和分次添加。</p>
-                              )}
-                            </div>
-
-                            {/* 待上传文件 */}
-                            <div className="ep-upload-zone">
-                              {pendingFiles.length > 0 && (
-                                <div className="ep-pending-files">
-                                  <div className="pending-chips">
-                                    {pendingFiles.map((f, i) => (
-                                      <span key={i} className="pending-chip">
-                                        {f.name} ({size(f.size)})
-                                        <button
-                                          type="button"
-                                          onClick={() =>
-                                            setEpFilesMap((prev) => ({
-                                              ...prev,
-                                              [ep.id]: pendingFiles.filter((_, x) => x !== i)
-                                            }))
-                                          }
-                                        >
-                                          <X size={12} />
-                                        </button>
-                                      </span>
-                                    ))}
-                                  </div>
-                                  <button
-                                    type="button"
-                                    className="primary-button small-button full-button"
-                                    disabled={uploadingEpId === ep.id}
-                                    onClick={() => handleUploadEpisodeAssets(ep.id)}
-                                  >
-                                    {uploadingEpId === ep.id ? '正在上传…' : ('上传 ' + pendingFiles.length + ' 份文件至本集')}
-                                  </button>
-                                </div>
+                                <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频;选择后自动上传,支持多选和分次添加。</p>
                               )}
                             </div>
                           </div>
@@ -1003,10 +951,10 @@ export function CourseWorkspace() {
                 ))}
               </div>
 
-              <div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
+              <div className="drop-zone compact" aria-disabled={!!uploadingTarget} onClick={() => { if (!uploadingTarget) globalInputRef.current?.click() }}>
                 <FilePlus2 />
-                <h3>添加通用附件(可多选)</h3>
-                <small>支持 PPTX、PDF、Word、图片和视频;可分次添加,每次最多 20 份</small>
+                <h3>{uploadingTarget === 'global' ? '正在上传…' : '添加通用附件(可多选)'}</h3>
+                <small>支持 PPTX、PDF、Word、图片和视频;选择后自动上传,每次最多 20 份</small>
               </div>
               <input
                 hidden
@@ -1014,32 +962,13 @@ export function CourseWorkspace() {
                 ref={globalInputRef}
                 type="file"
                 accept={assetAccept}
-                onChange={(e) => { setGlobalFiles((prev) => [...prev, ...Array.from(e.target.files || [])]); e.target.value = '' }}
+                disabled={!!uploadingTarget}
+                onChange={(e) => {
+                  const selected = Array.from(e.target.files || [])
+                  e.target.value = ''
+                  void handleUploadAssets(selected)
+                }}
               />
-
-              {globalFiles.length > 0 && (
-                <div className="selected-stack">
-                  {globalFiles.map((f, i) => (
-                    <div className="selected-file" key={i}>
-                      <FileText />
-                      <div>
-                        <strong>{f.name}</strong>
-                        <span>{size(f.size)}</span>
-                      </div>
-                      <button onClick={() => setGlobalFiles(globalFiles.filter((_, x) => x !== i))}>
-                        <X size={16} />
-                      </button>
-                    </div>
-                  ))}
-                  <button
-                    className="primary-button full-button"
-                    disabled={busy}
-                    onClick={handleUploadGlobalAssets}
-                  >
-                    {busy ? '正在上传…' : ('上传 ' + globalFiles.length + ' 个全局文件')}
-                  </button>
-                </div>
-              )}
             </section>
           </main>
 

+ 5 - 2
src/utils/lectureNotes.ts

@@ -1,9 +1,12 @@
 import DOMPurify from 'dompurify'
 
 export const RICH_NOTES_PREFIX = '<!--xinghen-richtext-->'
+const normalizeImportedBreaks = (value: string) => /^第\s*1\s*页\s*\n/.test(value)
+  ? value.replace(/&#(?:10|x0*a);/gi, '\n')
+  : value
 
 export function notesText(value: string) {
-  if (!value.startsWith(RICH_NOTES_PREFIX)) return value
+  if (!value.startsWith(RICH_NOTES_PREFIX)) return normalizeImportedBreaks(value)
   const element = document.createElement('div')
   element.innerHTML = DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
   return element.textContent || ''
@@ -12,7 +15,7 @@ export function notesText(value: string) {
 export function notesHtml(value: string) {
   if (value.startsWith(RICH_NOTES_PREFIX)) return DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
   const element = document.createElement('div')
-  value.split(/\n/).forEach((line) => {
+  normalizeImportedBreaks(value).split(/\n/).forEach((line) => {
     const paragraph = document.createElement('p')
     paragraph.textContent = line || ' '
     element.appendChild(paragraph)