4 커밋 c898c0c887 ... 609be84eb8

작성자 SHA1 메시지 날짜
  bob 609be84eb8 docs: record verified production redeployment 1 개월 전
  bob 17b3affe44 fix: reset admin organization forms after async submit 1 개월 전
  bob d407dba753 docs: record production redeployment verification 1 개월 전
  bob fcf1e84c9e fix: normalize PPTX notes and deploy pnpm build 1 개월 전
7개의 변경된 파일과 165개의 추가작업 그리고 124개의 파일을 삭제
  1. 65 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. 52 12
      src/pages/admin/AdminOrganizations.tsx
  7. 5 2
      src/utils/lectureNotes.ts

+ 65 - 0
AGENTS.md

@@ -548,3 +548,68 @@
 - 未确认与风险:未在隔离数据库中实际执行迁移及新老师登录全流程;未在公网部署或验收。固定初始密码仍需由机构管理员妥善交付,账号可自行改密。
 - 后续修改提示:不得恢复新老师首次登录强制改密,除非用户提出新的规则;涉及登录流程应同时核对前端路由和服务端鉴权。
 - 关联:替代 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。
+
+### C-20260923-28|提交全量代码并重新部署生产站点
+
+- 日期:2026-09-23
+- 类型:部署 / 文档
+- 需求与原因:用户要求提交当前所有代码,并把该提交重新部署到服务器。
+- 修改范围:提交当时全部未提交的代码与记录,提交号 `fcf1e84`;构建 `linux/amd64` 生产镜像并部署到 `47.93.193.127:/opt/xinghen`,仅重建应用容器,保留数据库和上传持久卷。重部署前备份数据库至 `/opt/xinghen/.deploy/pre-redeploy-fcf1e84.dump`。
+- 保留/新增判断:遵守 D-005、D-007 至 D-010。生产现有 Nginx 路由和证书仍有效,因此沿用现有代理配置。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build`、`pnpm install --lockfile-only --frozen-lockfile`、脚本语法检查、镜像构建通过;生产应用容器运行镜像 SHA `cc0632a94d3520f130c26d5aec3ee9e2cf6b2ee092c3736d05ec97c0c3e53d9f`,PostgreSQL 健康。`https://xinghen.work/` 和 `/lp` 返回 200,首页引用本次构建的 `main-8LOcnJ-3.js` 且资源返回 200;`/api/health` 返回 200 与 `{"ok":true}`,未登录后台接口返回 401,HTTP 返回 301 跳转 HTTPS,证书 SAN 包含 `xinghen.work` 且有效期至 2026-12-12。对照本次部署前备份,课程记录部署前后均为 4 条;普通用户 5 条、平台管理员 1 条;上传卷仍挂载。
+- 未确认与风险:尚未用已登录真实用户验证 PPTX 备注解析、自动上传、课程编辑及后台流程;这些功能仍为部分确认。上次部署记录的 5 门课程与本次部署前备份的 4 门不同,变化发生在本次重部署前,原因未在本次调查。
+- 后续修改提示:若要追查课程数变化,检查应用审计和数据库备份,不要直接恢复整个库覆盖当前数据;后续部署仍须核对镜像、持久卷、域名及真实业务路径。
+- 关联:C-20260923-26、C-20260923-27;提交 `fcf1e84`。
+
+### C-20260923-29|修复管理后台组织管理创建组织及表单异步 reset 报错
+
+- 日期:2026-09-23
+- 类型:修复 / 前端
+- 需求与原因:在管理后台创建组织时,接口调用成功但页面抛出 `Cannot read properties of null (reading 'reset')` 并在错误栏显示红字;排查确认系 React 异步事件处理函数中在 `await` 之后直接访问 `e.currentTarget.reset()` 导致(恢复执行时 SyntheticEvent 的 `currentTarget` 已被清空为 `null`);同时全面排查项目中其他表单提交逻辑与异步事件处理。
+- 修改范围:`src/pages/admin/AdminOrganizations.tsx` 在 `await` 前将 `e.currentTarget` 赋值给本地变量 `formElement`,成功后调用 `formElement.reset()`;在 `create`、`setMember`、`points`、`rename`、`updateSlug`、`review` 等表单与操作提交前统一清空旧的 `error` 和 `message` 状态;优化成员与积分表单成功后的重置体验。
+- 保留/新增判断:遵守 D-001 至 D-010。前端表单处理中涉及异步操作并需要 `reset()` 时,必须在首个 `await` 之前保存 DOM 元素引用。
+- 功能确认状态:部分确认
+- 验证证据:已静态全量扫描 `src/` 下所有 `.reset()`、`currentTarget`、`target` 及 `onSubmit` 异步函数,确认无其他类似跨 await 访问空引用的隐患;`pnpm run check`(`tsc -b`)通过,`pnpm run build`(`vite build`)通过。
+- 未确认与风险:未在生产环境或浏览器真机模拟网络延迟端到端点击;未重新构建并推送 Docker 镜像至服务器。
+- 后续修改提示:编写表单异步提交函数时,需遵循在 `await` 之前提取数据和保存表单引用(或使用受控组件/React Hook Form)的范式。
+- 关联:C-20260923-25。
+
+### C-20260923-30|提交组织管理表单修复并重新部署
+
+- 日期:2026-09-23
+- 类型:部署 / 文档
+- 需求与原因:用户要求提交当前所有代码并重新部署生产站点。
+- 修改范围:将组织管理表单修复及 C-20260923-29 记录提交为 `17b3aff`;构建 `linux/amd64` 镜像并部署到 `47.93.193.127:/opt/xinghen`,仅重建应用容器;部署前数据库备份在 `/opt/xinghen/.deploy/pre-redeploy-17b3aff.dump`,保留 PostgreSQL 和上传持久卷。
+- 保留/新增判断:遵守 D-007 至 D-010;生产现有域名代理与证书有效,沿用现有配置。
+- 功能确认状态:部分确认
+- 验证证据:本地 `pnpm run check`、`pnpm run build`、`git diff --check` 与生产镜像构建通过;生产容器运行镜像 SHA `dcc9fe5ed14c0084c1e15620749d27d8a4bf7ec865a3e84bbc0a483e9fc36596`,PostgreSQL 健康。公网首页与 `/lp` 均返回 200,首页引用新版 `main-B1p2drP9.js` 且资源请求返回 200;`/api/health` 返回 200 与 `{"ok":true}`,未登录后台接口返回 401,HTTP 返回 301 跳转 HTTPS,证书 SAN 包含 `xinghen.work` 且有效期至 2026-12-12。课程部署前后均为 4 条,普通用户 5 条、平台管理员 1 条;上传卷仍挂载。
+- 未确认与风险:尚未用真实平台管理员账号在浏览器完成创建组织、成员调整与积分表单操作;表单修复的公网业务路径仍为部分确认。
+- 后续修改提示:如再次修改组织管理表单,应以真实管理员账号验证成功、错误与刷新后状态,并继续保护生产持久卷。
+- 关联:C-20260923-29;提交 `17b3aff`。

+ 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>
 

+ 52 - 12
src/pages/admin/AdminOrganizations.tsx

@@ -10,9 +10,19 @@ export function AdminOrganizations() {
   async function load() { try { setOrganizations((await api.admin.organizations()).organizations) } catch (e) { setError((e as Error).message) } }
   useEffect(() => { load() }, [])
   async function create(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault(); const form = new FormData(e.currentTarget)
-    try { await api.admin.createOrganization(String(form.get('name')), String(form.get('slug'))); setMessage('组织已创建'); (e.currentTarget as HTMLFormElement).reset(); await load() }
-    catch (x) { setError((x as Error).message) }
+    e.preventDefault()
+    const formElement = e.currentTarget
+    const form = new FormData(formElement)
+    setError('')
+    setMessage('')
+    try {
+      await api.admin.createOrganization(String(form.get('name')), String(form.get('slug')))
+      setMessage('组织已创建')
+      formElement.reset()
+      await load()
+    } catch (x) {
+      setError((x as Error).message)
+    }
   }
   return <div className="page admin-page"><div className="admin-header-row"><div><h2>组织管理</h2><p>创建组织并管理成员与积分</p></div></div>
     {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}
@@ -35,27 +45,57 @@ export function AdminOrganizationDetail() {
   async function load() { try { setData(await api.admin.organization(id)) } catch (x) { setError((x as Error).message) } }
   useEffect(() => { load() }, [id])
   async function setMember(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault(); const f = new FormData(e.currentTarget)
+    e.preventDefault()
+    const formElement = e.currentTarget
+    const f = new FormData(formElement)
+    setError('')
+    setMessage('')
     const phone = String(f.get('phone'))
     try {
       const person = (await api.admin.listUsers(phone)).users.find((u) => u.phone === phone)
       if (!person) return setError('未找到该手机号对应的用户')
       await api.admin.setOrganizationMember(id, person.id, String(f.get('role')) as 'MEMBER' | 'MANAGER')
-      setMessage('成员关系已更新'); await load()
+      setMessage('成员关系已更新')
+      formElement.reset()
+      setUsers([])
+      await load()
     }
     catch (x) { setError((x as Error).message) }
   }
   async function search(phone: string) { try { setUsers((await api.admin.listUsers(phone)).users) } catch (x) { setError((x as Error).message) } }
-  async function review(applicationId: string, approve: boolean) { try { await api.admin.reviewOrganizationApplication(id, applicationId, approve); await load() } catch (x) { setError((x as Error).message) } }
+  async function review(applicationId: string, approve: boolean) {
+    setError('')
+    setMessage('')
+    try { await api.admin.reviewOrganizationApplication(id, applicationId, approve); await load() } catch (x) { setError((x as Error).message) }
+  }
   async function points(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault(); const f = new FormData(e.currentTarget)
-    try { await api.admin.adjustOrganizationPoints(id, Number(f.get('amount')), String(f.get('reason'))); setMessage('积分已调整'); await load() }
+    e.preventDefault()
+    const formElement = e.currentTarget
+    const f = new FormData(formElement)
+    setError('')
+    setMessage('')
+    try {
+      await api.admin.adjustOrganizationPoints(id, Number(f.get('amount')), String(f.get('reason')))
+      setMessage('积分已调整')
+      formElement.reset()
+      await load()
+    }
     catch (x) { setError((x as Error).message) }
   }
-  async function rename(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
-    try { await api.admin.renameOrganization(id, String(f.get('name'))); setMessage('组织名称已更新'); await load() } catch (x) { setError((x as Error).message) } }
-  async function updateSlug(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
-    try { await api.admin.updateOrganizationSlug(id, String(f.get('slug'))); setMessage('公开页面访问后缀已更新'); await load() } catch (x) { setError((x as Error).message) } }
+  async function rename(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const f = new FormData(e.currentTarget)
+    setError('')
+    setMessage('')
+    try { await api.admin.renameOrganization(id, String(f.get('name'))); setMessage('组织名称已更新'); await load() } catch (x) { setError((x as Error).message) }
+  }
+  async function updateSlug(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const f = new FormData(e.currentTarget)
+    setError('')
+    setMessage('')
+    try { await api.admin.updateOrganizationSlug(id, String(f.get('slug'))); setMessage('公开页面访问后缀已更新'); await load() } catch (x) { setError((x as Error).message) }
+  }
   if (!data) return <div className="page admin-page">{error || '正在加载组织…'}</div>
   return <div className="page admin-page"><Link className="back-link" to="/admin/organizations">← 全部组织</Link><h2>{data.organization.name} · {data.organization.is_active ? '正常' : '已停用'}</h2>
     {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}

+ 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)