提交 1ebb03ec authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 a1fc9f27
...@@ -31,7 +31,6 @@ ...@@ -31,7 +31,6 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html-docx-js-typescript": "^0.1.5",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
...@@ -42,8 +41,8 @@ ...@@ -42,8 +41,8 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-use": "^17.6.0",
"react-router": "^7.18.1", "react-router": "^7.18.1",
"react-use": "^17.6.0",
"snabbdom": "^3.6.2", "snabbdom": "^3.6.2",
"zustand": "^4.5.5" "zustand": "^4.5.5"
}, },
......
...@@ -59,8 +59,7 @@ const renderAudio = (elem, children, editor) => { ...@@ -59,8 +59,7 @@ const renderAudio = (elem, children, editor) => {
}, },
[ [
h('audio', { h('audio', {
props: { src: audioUrl, controls: true, autoPlay: true }, props: { src: audioUrl, controls: true },
style: { width: '220px', height: '100px' },
}), }),
], ],
) )
...@@ -129,7 +128,7 @@ const chapterAudioToHtml = (elem, childrenHtml) => { ...@@ -129,7 +128,7 @@ const chapterAudioToHtml = (elem, childrenHtml) => {
// 生成 HTML 代码 // 生成 HTML 代码
const html = `<div class="chapter-audio-container" data-w-e-type="chapterAudio" data-w-e-is-void data-audioUrl="${audioUrl}" data-title="${title}" style="text-align: center; padding: 10px 0;"> const html = `<div class="chapter-audio-container" data-w-e-type="chapterAudio" data-w-e-is-void data-audioUrl="${audioUrl}" data-title="${title}" style="text-align: center; padding: 10px 0;">
<div class="chapter-audio-pic" style="text-align: center;"> <div class="chapter-audio-pic" style="text-align: center;">
<audio controls src="${audioUrl}" style="width: 220px; height: 100px;" alt="${title}" /> <audio controls src="${audioUrl}" alt="${title}" />
</div> </div>
${title ? `<div class="chapter-audio-title" style="text-align: center; color: #666; font-size: 14px; line-height: 20px;">${title}</div>` : ''} ${title ? `<div class="chapter-audio-title" style="text-align: center; color: #666; font-size: 14px; line-height: 20px;">${title}</div>` : ''}
</div>` </div>`
......
import { useState, forwardRef, useImperativeHandle } from 'react' import { useEffect, useState } from 'react'
import { Divider, Input, Space, Button, Form, ColorPicker } from 'antd' import { Button, Col, ColorPicker, Input, message, Row, Space } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import { SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor' import { SlateEditor, SlateElement, SlateTransforms } from '@wangeditor/editor'
import {
insertBlockNodesAndFocus,
removeSelectedEmptyParagraph,
} from '../../shared/editor-operations'
import { useEditorStore } from '@/stores/editorStore' import { useEditorStore } from '@/stores/editorStore'
import '../../index.less' import { getChapterTopic } from '../../shared/request'
import { bookNameList } from './question-api'
import CustomerTopic from './components/customer'
import InsertTopic from './components/insert'
import ListTopic from './components/list'
import './styles.less'
const PracticeModal = (props, ref) => { const PracticeSettingsDialog = ({ editor, chapterId, bookId, closePanel }) => {
const { bookId, chapterId, setPracticeOpenVisible } = props const practiceRandom = useEditorStore((state) => state.practiceRandom)
const practiceTitle = useEditorStore((state) => state.practiceTitle)
const practiceTheme = useEditorStore((state) => state.practiceTheme)
const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom) const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom)
const { editor } = props
const [form] = Form.useForm()
const [themeValue, setThemeValue] = useState('#ab1941')
const [initValues, setInitValues] = useState({ practice: '', theme: '#ab1941' })
const [nodes, setNodes] = useState(null)
useImperativeHandle(ref, () => { const [theme, setTheme] = useState(practiceTheme || '#ab1941')
return { const [title, setTitle] = useState(practiceTitle || '')
nodes, const [editingTitle, setEditingTitle] = useState(false)
} const [manualAdd, setManualAdd] = useState(false)
const [customerType, setCustomerType] = useState('')
const [bookList, setBookList] = useState([])
const [selectedQuestions, setSelectedQuestions] = useState([])
const [insertedQuestions, setInsertedQuestions] = useState([])
useEffect(() => {
setTheme(practiceTheme || '#ab1941')
setTitle(practiceTitle || '')
}, [practiceTheme, practiceTitle])
useEffect(() => {
bookNameList({}).then((data) => setBookList(data || []))
}, [])
useEffect(() => {
if (!bookId || !chapterId || !practiceRandom) return
getChapterTopic({
book_id: bookId,
chapter_id: chapterId,
position: practiceRandom,
}).then((data) => {
const questions = (data || []).map((item) => ({ ...item, choose_id: item.question_id }))
setInsertedQuestions(questions)
setSelectedQuestions(questions)
}) })
}, [bookId, chapterId, practiceRandom])
const textColorChange = (value) => { const updatePracticeTitle = () => {
const hex = typeof value === 'string' ? value : value?.toHexString() const nextTitle = title.trim()
setThemeValue(hex) if (!nextTitle) {
form.setFieldsValue({ theme: hex }) message.error('请输入标题')
return
} }
const callback = (practiceNum, practiceTitle) => { for (const [node, path] of SlateEditor.nodes(editor, {
const nodeEntries = SlateEditor.nodes(editor, { match: (node) => SlateElement.isElement(node) && node.type === 'chapterPractice',
match: (node) => {
// JS syntax
if (SlateElement.isElement(node)) {
if (node.type === 'chapterPractice') {
return true
}
}
return false
},
universal: true, universal: true,
})) {
if (node.practiceNum !== practiceRandom) continue
SlateTransforms.setNodes(editor, { ...node, title: nextTitle, theme }, { at: path })
setPracticeRandom({
practiceNum: node.practiceNum,
practiceTitle: nextTitle,
practiceTheme: theme,
}) })
setEditingTitle(false)
setNodes(nodeEntries) return
setPracticeRandom({ practiceNum, practiceTitle }) }
} }
const onFinish = (values) => { const addSelectedQuestions = () => {
editor.restoreSelection() if (selectedQuestions.length === 0) {
message.error('请先勾选试题')
removeSelectedEmptyParagraph(editor, SlateTransforms) return
}
const practiceNum = Math.random().toString(32).substring(2, 10) const existed = new Set(insertedQuestions.map((item) => String(item.choose_id)))
insertBlockNodesAndFocus(editor, SlateTransforms, { setInsertedQuestions((current) => [
type: 'chapterPractice', ...current,
title: values.practice, ...selectedQuestions.filter((item) => !existed.has(String(item.choose_id))),
practiceNum: practiceNum, ])
bookId,
chapterId,
theme: values.theme,
callback: callback,
children: [{ text: '' }],
})
form.resetFields()
setPracticeOpenVisible(false)
editor.focus()
} }
return ( return (
<div> <Row gutter={20} className='practice-topic-row'>
<Divider /> <Col span={12} className='common practice-left'>
<div className='editor-content-form'> <div className='practice-header'>
<Form <div className='practice-l-img'>
layout='vertical' <svg className='svg-icon' aria-hidden='true'>
name='validate_other' <use xlinkHref='#icon-writing' />
form={form} </svg>
onFinish={onFinish}
initialValues={initValues}
>
<Form.Item
label='标题'
name='practice'
rules={[{ required: true, message: '请输入标题' }]}
extra='最多输入100字'
>
<Input maxLength={100} placeholder='' allowClear />
</Form.Item>
<Form.Item>
<div className='justcontent-color-inline'>
<Form.Item
label='练习主题色'
name='theme'
className='flex-max'
rules={[
{ required: true, message: '请选择颜色' },
{ pattern: /^#[0-9A-Fa-f]{6}$/i, message: '请输入正确的16进制色值' },
]}
>
<Input placeholder='' allowClear />
</Form.Item>
<Form.Item label={` `}>
<ColorPicker
disabledAlpha
value={themeValue}
defaultValue={themeValue}
format='hex'
onChange={textColorChange}
/>
</Form.Item>
</div> </div>
</Form.Item> <div className='practice-l-title'>
<Form.Item className='editor-form-buttons'> {editingTitle ? (
<Space> <Space>
<Button type='default' onClick={() => setPracticeOpenVisible(false)}> <Input value={title} onChange={(event) => setTitle(event.target.value)} />
取消 <ColorPicker disabledAlpha value={theme} format='hex' onChange={(value) => setTheme(value.toHexString())} />
</Button> <Button type='primary' onClick={updatePracticeTitle}>确定</Button>
<Button type='primary' htmlType='submit'> <Button onClick={() => setEditingTitle(false)}>取消</Button>
插入
</Button>
</Space> </Space>
</Form.Item> ) : (
</Form> <h2 className='title' onClick={() => setEditingTitle(true)}>
<ColorPicker disabled value={theme} size='small' />
<span className='titles'>{practiceTitle}</span>
<span className='click'>(点击编辑)</span>
</h2>
)}
</div>
</div> </div>
<div className='practice-topic-list'>
<InsertTopic
insertList={insertedQuestions}
setInsertList={setInsertedQuestions}
chooseList={selectedQuestions}
setChooseList={setSelectedQuestions}
practiceRandom={practiceRandom}
chapterId={chapterId}
bookId={bookId}
closePanel={closePanel}
/>
</div>
</Col>
<Col span={12} className='common practice-right'>
<div className='practice-header right'>
<Button onClick={() => setManualAdd((value) => !value)}>
{manualAdd ? '从题库选择' : '新增试题'}
</Button>
<Button type='link' icon={<CloseOutlined style={{ color: '#b83956' }} />} onClick={closePanel} />
</div>
<div className='practice-topic-list'>
{manualAdd ? (
<CustomerTopic
setPracticeStatus={setManualAdd}
bookNameIdData={bookList}
bookId={bookId}
setCustomerType={setCustomerType}
/>
) : (
<ListTopic
chooseList={selectedQuestions}
setChooseList={setSelectedQuestions}
addTopicList={addSelectedQuestions}
practiceStatus={manualAdd}
bookNameIdData={bookList}
bookId={bookId}
customerType={customerType}
/>
)}
</div> </div>
</Col>
</Row>
) )
} }
export default forwardRef(PracticeModal) export default PracticeSettingsDialog
...@@ -65,8 +65,7 @@ const renderVideo = (elem, children, editor) => { ...@@ -65,8 +65,7 @@ const renderVideo = (elem, children, editor) => {
}, },
[ [
h('video', { h('video', {
props: { src: videoUrl, controls: true, autoPlay: true }, props: { src: videoUrl, controls: true },
style: { width: '320px', height: '100px' },
}), }),
], ],
) )
...@@ -142,7 +141,7 @@ const chapterVideoToHtml = (elem, childrenHtml) => { ...@@ -142,7 +141,7 @@ const chapterVideoToHtml = (elem, childrenHtml) => {
style="text-align: center; padding: 10px 0;" style="text-align: center; padding: 10px 0;"
> >
<div class="chapter-video-pic" style="text-align: center;"> <div class="chapter-video-pic" style="text-align: center;">
<video controls src="${videoUrl}" style="width: 320px; height: 100px;" alt="${title}" /> <video controls src="${videoUrl}" alt="${title}" />
</div> </div>
${title ? `<div class="chapter-video-title" style="text-align: center; color: #666; font-size: 14px; line-height: 20px;">${title}</div>` : ''} ${title ? `<div class="chapter-video-title" style="text-align: center; color: #666; font-size: 14px; line-height: 20px;">${title}</div>` : ''}
</div>` </div>`
......
...@@ -22,9 +22,11 @@ export interface ManagementListResult { ...@@ -22,9 +22,11 @@ export interface ManagementListResult {
export interface ExportBookResult { export interface ExportBookResult {
name?: string name?: string
content?: string download_url?: string
} }
export type ExportBookType = 'word' | 'pdf' | 'epub'
export interface ImportBookValues { export interface ImportBookValues {
catalog_level: number catalog_level: number
file: File file: File
...@@ -69,8 +71,8 @@ export function updateBook(params: ApiParams) { ...@@ -69,8 +71,8 @@ export function updateBook(params: ApiParams) {
return axios.post('/api/admin/book/teacher/edit', params) return axios.post('/api/admin/book/teacher/edit', params)
} }
export function exportManagementBook(id: number) { export function exportManagementBook(id: number, type: ExportBookType) {
return getPayload<ExportBookResult>('/api/admin/book/teacher/export', { id }) return getPayload<ExportBookResult>('/api/admin/book/teacher/export', { id, type })
} }
export function releaseManagementBook(id: number, releaseMess?: string) { export function releaseManagementBook(id: number, releaseMess?: string) {
......
import { Modal, Row, Space } from 'antd' import { Modal, Radio, Row, Space } from 'antd'
import { useEffect, useState } from 'react'
export type ExportFormat = 'word' | 'pdf' | 'epub'
interface ExportBookModalProps { interface ExportBookModalProps {
open: boolean open: boolean
onCancel: () => void onCancel: () => void
onConfirm: () => void | Promise<void> onConfirm: (format: ExportFormat) => void | Promise<void>
} }
export default function ExportBookModal({ open, onCancel, onConfirm }: ExportBookModalProps) { export default function ExportBookModal({ open, onCancel, onConfirm }: ExportBookModalProps) {
const [format, setFormat] = useState<ExportFormat>('word')
useEffect(() => {
if (open) setFormat('word')
}, [open])
return ( return (
<Modal <Modal
mask={false} mask={false}
centered centered
open={open} open={open}
onOk={onConfirm} onOk={() => onConfirm(format)}
onCancel={onCancel} onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => ( footer={(_, { OkBtn, CancelBtn }) => (
<Row justify='center' className='delModal' style={{ marginBottom: '30px' }}> <Row justify='center' className='delModal' style={{ marginBottom: '30px' }}>
...@@ -24,6 +33,14 @@ export default function ExportBookModal({ open, onCancel, onConfirm }: ExportBoo ...@@ -24,6 +33,14 @@ export default function ExportBookModal({ open, onCancel, onConfirm }: ExportBoo
)} )}
okText='确认' okText='确认'
> >
<div style={{ textAlign: 'center', paddingTop: 28 }}>
<div style={{ marginBottom: 12, color: '#666' }}>导出格式</div>
<Radio.Group value={format} onChange={(event) => setFormat(event.target.value)}>
<Radio value='word'>Word</Radio>
<Radio value='pdf'>PDF</Radio>
<Radio value='epub'>EPUB</Radio>
</Radio.Group>
</div>
<p style={{ textAlign: 'center', padding: '30px 0 18px', fontSize: 16 }}> <p style={{ textAlign: 'center', padding: '30px 0 18px', fontSize: 16 }}>
将导出书籍正文、图片、画廊、扩展阅读、练习题、链接、气泡提示、视频和音频 将导出书籍正文、图片、画廊、扩展阅读、练习题、链接、气泡提示、视频和音频
</p> </p>
......
import { describe, expect, it } from 'vitest'
import { normalizeBookExportHtml } from './normalizeBookExportHtml'
describe('normalizeBookExportHtml', () => {
it('turns editor-only blocks into static Word content', async () => {
const html = `
<div data-export-chapter-id="8">
<div class="chapter-gallery-container" data-galleryList="${encodeURIComponent(JSON.stringify([{ url: 'https://cdn.example.com/a.png', title: '图片说明' }]))}" data-title="画廊标题"></div>
<div class="chapter-expand" data-w-e-type="chapterExpandRead" data-random="expand-1" data-name="扩展阅读"></div>
<div class="chapter-practice" data-w-e-type="chapterPractice" data-practiceNum="practice-1"></div>
<span class="chapter-item-link" data-w-e-type="chapterLink" data-title="资料链接" data-linktype="1" data-link="https://example.com"></span>
<span class="chapter-item-tooltip" data-w-e-type="chapterTooltip" data-title="术语" data-content="术语解释"></span>
<div class="chapter-video-container" data-w-e-type="chapterVideo" data-title="课程视频" data-videoUrl="https://example.com/video.mp4"></div>
</div>
`
const result = await normalizeBookExportHtml(html, {
bookId: 7,
fetchExpandRead: async (params) => {
expect(params).toEqual({ book_id: 7, chapter_id: 8, position: 'expand-1' })
return { content: '<p>扩展正文</p>' }
},
fetchPracticeQuestions: async (params) => {
expect(params).toEqual({ book_id: 7, chapter_id: 8, position: 'practice-1' })
return [
{
titles: '单选题题干',
question_style: 1,
question_list: JSON.stringify([{ option: '选项 A' }, { option: '选项 B' }]),
answers: 'A',
analysis: '题目解析',
},
]
},
})
expect(result).toContain('画廊标题')
expect(result).toContain('https://cdn.example.com/a.png')
expect(result).toContain('图片说明')
expect(result).toContain('扩展正文')
expect(result).toContain('单选题题干')
expect(result).toContain('选项 A')
expect(result).toContain('答案:A')
expect(result).toContain('资料链接')
expect(result).toContain('术语解释')
expect(result).toContain('课程视频')
expect(result).toContain('https://example.com/video.mp4')
expect(result).not.toContain('chapter-practice')
expect(result).not.toContain('<video')
})
})
export interface ExportPracticeQuestion {
titles?: string
question_style?: number | string
question_list?: string | unknown[]
answers?: string
analysis?: string
}
interface ExportParams {
book_id: number
chapter_id: number
position: string
}
interface NormalizeBookExportOptions {
bookId: number
fetchExpandRead: (params: ExportParams) => Promise<{ content?: string } | null | undefined>
fetchPracticeQuestions: (params: ExportParams) => Promise<ExportPracticeQuestion[]>
}
function getChapterId(element: Element): number | null {
const container = element.closest('[data-export-chapter-id]')
const chapterId = Number(container?.getAttribute('data-export-chapter-id'))
return Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null
}
function createTextElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
text: string,
className?: string,
): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName)
element.textContent = text
if (className) element.className = className
return element
}
function parseGalleryList(value: string | null): Array<{ url?: string; title?: string }> {
if (!value) return []
try {
return JSON.parse(decodeURIComponent(value))
} catch {
try {
return JSON.parse(value)
} catch {
return []
}
}
}
function replaceGallery(element: Element) {
const section = document.createElement('section')
section.className = 'word-export-gallery'
const title = element.getAttribute('data-title')?.trim()
if (title) section.append(createTextElement('h4', title))
parseGalleryList(element.getAttribute('data-galleryList')).forEach((item) => {
if (!item.url) return
const figure = document.createElement('figure')
const image = document.createElement('img')
image.src = item.url
image.alt = item.title ?? ''
figure.append(image)
if (item.title) figure.append(createTextElement('figcaption', item.title))
section.append(figure)
})
element.replaceWith(section)
}
function replaceLink(element: Element) {
const title = element.getAttribute('data-title') || element.textContent?.trim() || '链接'
const link = element.getAttribute('data-link')
if (link) {
const anchor = document.createElement('a')
anchor.href = link
anchor.textContent = title
element.replaceWith(anchor)
return
}
element.replaceWith(createTextElement('span', title))
}
function replaceTooltip(element: Element) {
const title = element.getAttribute('data-title') || element.textContent?.trim() || '提示'
const content = element.getAttribute('data-content') || ''
const note = createTextElement('span', `${title}(提示:${content})`, 'word-export-tooltip')
element.replaceWith(note)
}
function replaceMedia(element: Element, type: '视频' | '音频', urlAttribute: string) {
const title = element.getAttribute('data-title') || `${type}内容`
const url =
element.getAttribute(urlAttribute) ||
element.querySelector(type === '视频' ? 'video' : 'audio')?.getAttribute('src') ||
''
const wrapper = document.createElement('p')
wrapper.className = 'word-export-media'
wrapper.append(`${type}:${title}`)
if (url) {
const anchor = document.createElement('a')
anchor.href = url
anchor.textContent = '(点击播放)'
wrapper.append(anchor)
}
element.replaceWith(wrapper)
}
function parseQuestionList(questionList: ExportPracticeQuestion['question_list']): unknown[] {
if (Array.isArray(questionList)) return questionList
if (typeof questionList !== 'string') return []
try {
const value = JSON.parse(questionList)
return Array.isArray(value) ? value : []
} catch {
return []
}
}
function formatOption(option: unknown): string {
if (typeof option === 'string') return option
if (option && typeof option === 'object') {
const value = option as { option?: string; title?: string; content?: string; label?: string }
return value.option ?? value.title ?? value.content ?? value.label ?? ''
}
return ''
}
function createHtmlParagraph(prefix: string, html: string) {
const paragraph = document.createElement('p')
paragraph.append(prefix)
const content = new DOMParser().parseFromString(html, 'text/html')
while (content.body.firstChild) paragraph.append(content.body.firstChild)
return paragraph
}
function createPracticeSection(title: string, questions: ExportPracticeQuestion[]) {
const section = document.createElement('section')
section.className = 'word-export-practice'
section.append(createTextElement('h4', title || '练习题'))
questions.forEach((question, index) => {
section.append(createHtmlParagraph(`${index + 1}. `, question.titles ?? ''))
parseQuestionList(question.question_list).forEach((option, optionIndex) => {
const text = formatOption(option)
if (text)
section.append(createHtmlParagraph(`${String.fromCharCode(65 + optionIndex)}. `, text))
})
if (question.answers) section.append(createTextElement('p', `答案:${question.answers}`))
if (question.analysis) section.append(createTextElement('p', `解析:${question.analysis}`))
})
return section
}
async function replaceExpandRead(element: Element, options: NormalizeBookExportOptions) {
const chapterId = getChapterId(element)
const title =
element.getAttribute('data-title') || element.getAttribute('data-name') || '扩展阅读'
const random = element.getAttribute('data-random') || ''
const section = document.createElement('section')
section.className = 'word-export-expand'
section.append(createTextElement('h4', title))
if (chapterId && random) {
const result = await options.fetchExpandRead({
book_id: options.bookId,
chapter_id: chapterId,
position: random,
})
if (result?.content) {
const content = new DOMParser().parseFromString(result.content, 'text/html')
while (content.body.firstChild) section.append(content.body.firstChild)
}
}
element.replaceWith(section)
}
async function replacePractice(element: Element, options: NormalizeBookExportOptions) {
const chapterId = getChapterId(element)
const practiceNum = element.getAttribute('data-practiceNum') || ''
const title = element.getAttribute('data-title') || '练习题'
const questions =
chapterId && practiceNum
? await options.fetchPracticeQuestions({
book_id: options.bookId,
chapter_id: chapterId,
position: practiceNum,
})
: []
element.replaceWith(createPracticeSection(title, questions))
}
/** Convert WangEditor custom blocks into static HTML that Word can render. */
export async function normalizeBookExportHtml(
htmlString: string,
options: NormalizeBookExportOptions,
): Promise<string> {
const doc = new DOMParser().parseFromString(htmlString, 'text/html')
doc.body.querySelectorAll('.chapter-gallery-container').forEach(replaceGallery)
doc.body.querySelectorAll('[data-w-e-type="chapterLink"]').forEach(replaceLink)
doc.body.querySelectorAll('[data-w-e-type="chapterTooltip"]').forEach(replaceTooltip)
doc.body
.querySelectorAll(
'[data-w-e-type="chapterVideo"], [data-w-e-type="video"], .chapter-video-container',
)
.forEach((element) => replaceMedia(element, '视频', 'data-videoUrl'))
doc.body
.querySelectorAll('[data-w-e-type="chapterAudio"], .chapter-audio-container')
.forEach((element) => replaceMedia(element, '音频', 'data-audioUrl'))
await Promise.all(
Array.from(
doc.body.querySelectorAll('[data-w-e-type="chapterExpandRead"], .chapter-expand'),
).map((element) => replaceExpandRead(element, options)),
)
await Promise.all(
Array.from(
doc.body.querySelectorAll('[data-w-e-type="chapterPractice"], .chapter-practice'),
).map((element) => replacePractice(element, options)),
)
return doc.body.innerHTML
}
import { Button, DatePicker, Image, Input, Select, Space } from 'antd' import { Button, DatePicker, Image, Input, Select, Space } from 'antd'
import { PlusOutlined, UploadOutlined } from '@ant-design/icons' import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { saveAs } from 'file-saver'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { AppList, type AppListFilterItem } from '@/components/AppList' import { AppList, type AppListFilterItem } from '@/components/AppList'
import { expandReadInfo, getChapterTopic } from '@/components/editor/wangeditor-customer/public-api'
import { exportHtmlToWord } from '@/utils/exportWordHtmlDocx'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { useUserStore } from '@/stores/userStore' import { useUserStore } from '@/stores/userStore'
...@@ -19,8 +18,7 @@ import { ...@@ -19,8 +18,7 @@ import {
type ManagementRow, type ManagementRow,
} from '../api' } from '../api'
import DeleteBookModal from '../components/DeleteBookModal' import DeleteBookModal from '../components/DeleteBookModal'
import ExportBookModal from '../components/ExportBookModal' import ExportBookModal, { type ExportFormat } from '../components/ExportBookModal'
import { normalizeBookExportHtml } from '../export/normalizeBookExportHtml'
import ImportBookDrawer from '../components/ImportBookDrawer' import ImportBookDrawer from '../components/ImportBookDrawer'
import ReleaseBookDrawer, { type ReleaseBookFormValues } from '../components/ReleaseBookDrawer' import ReleaseBookDrawer, { type ReleaseBookFormValues } from '../components/ReleaseBookDrawer'
import type { ManagementFilters } from '../query' import type { ManagementFilters } from '../query'
...@@ -28,6 +26,7 @@ import { useManagementList } from '../useManagementList' ...@@ -28,6 +26,7 @@ import { useManagementList } from '../useManagementList'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
const IMAGE_FALLBACK = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' const IMAGE_FALLBACK = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='
export default function ManagementListView() { export default function ManagementListView() {
const list = useManagementList() const list = useManagementList()
const navigate = useNavigate() const navigate = useNavigate()
...@@ -47,21 +46,20 @@ export default function ManagementListView() { ...@@ -47,21 +46,20 @@ export default function ManagementListView() {
setDeleteOpen(false) setDeleteOpen(false)
} }
const handleExport = async () => { const handleExport = async (format: ExportFormat) => {
if (selectedId === null) return if (selectedId === null) return
setExportOpen(false) setExportOpen(false)
setExporting(true) setExporting(true)
try { try {
const result = await exportManagementBook(selectedId) const result = await exportManagementBook(selectedId, format)
if (!result.name) return if (!result.name) {
const content = await normalizeBookExportHtml(result.content ?? '', { return
bookId: selectedId, }
fetchExpandRead: expandReadInfo, if (!result.download_url) throw new Error('未获取到导出文件地址')
fetchPracticeQuestions: getChapterTopic, saveAs(`/api/admin${result.download_url}`, result.name)
})
await exportHtmlToWord(result.name, content)
} catch (error) { } catch (error) {
console.error('导出Word失败', error) const formatLabel = format === 'pdf' ? 'PDF' : format === 'epub' ? 'EPUB' : 'Word'
console.error(`导出${formatLabel}失败`, error)
} finally { } finally {
setExporting(false) setExporting(false)
} }
......
import { asBlob } from 'html-docx-js-typescript'
import { saveAs } from 'file-saver'
/**
* 将图片 URL 转为 base64 Data URI
* 优先使用 fetch,失败后尝试 Image + Canvas 方式
*/
async function imgToBase64(src) {
// 方式1:fetch
try {
// OSS 刚更新 CORS 规则时,浏览器可能复用规则生效前的无 CORS 缓存响应。
// 导出只在用户主动触发时执行,强制重新校验可确保读取当前响应头。
const res = await fetch(src, { cache: 'reload' })
if (res.ok) {
const blob = await res.blob()
return await blobToDataUri(blob)
}
} catch (e) {
console.warn('fetch 图片失败,尝试 canvas 方式:', src, e)
}
// 方式2:Image + Canvas(可绕过部分限制)
try {
return await loadImageAsBase64(src)
} catch (e) {
console.warn('canvas 转换图片失败:', src, e)
}
return null
}
function blobToDataUri(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
function loadImageAsBase64(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
resolve(canvas.toDataURL('image/png'))
} catch (e) {
reject(e)
}
}
img.onerror = reject
img.src = src
})
}
/**
* 将 DOM 中所有 img 的 src 替换为 base64 Data URI
*/
async function convertImagesToBase64(docEl) {
const imgs = docEl.querySelectorAll('img')
const tasks = Array.from(imgs).map(async (img) => {
const src = img.getAttribute('src')
if (!src || src.startsWith('data:')) return
// 处理相对路径和协议相对路径
let absoluteSrc = src
if (src.startsWith('//')) {
absoluteSrc = window.location.protocol + src
} else if (src.startsWith('/')) {
absoluteSrc = window.location.origin + src
} else if (!src.startsWith('http')) {
absoluteSrc = new URL(src, window.location.href).href
}
const dataUri = await imgToBase64(absoluteSrc)
if (dataUri) {
img.setAttribute('src', dataUri)
} else {
console.warn('图片转 base64 失败,已跳过:', absoluteSrc)
img.remove()
}
})
await Promise.all(tasks)
}
/**
* 使用 html-docx-js-typescript 将 HTML 内容导出为 Word 文档(.docx)
* 图片会自动转为 base64 内嵌到文档中
* @param {string} name - 文件名(不含扩展名)
* @param {string} htmlString - 原始 HTML 字符串
* @param {string[]} removeSelectors - 需要移除的 CSS 选择器列表
*/
export async function exportHtmlToWord(name, htmlString, removeSelectors = []) {
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')
if (removeSelectors.length) {
doc.body.querySelectorAll(removeSelectors.join(', ')).forEach((element) => element.remove())
}
// 将所有图片转为 base64 内嵌
await convertImagesToBase64(doc.body)
// 使用原生 innerHTML 避免 jQuery 序列化可能编码 base64 特殊字符
const htmlContent = doc.body.innerHTML
const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 2cm; }
body { font-family: SimSun, serif; font-size: 14px; line-height: 1.6; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #000; padding: 5px; }
img { max-width: 100%; }
</style></head><body>${htmlContent}</body></html>`
const blob = await asBlob(fullHtml, { orientation: 'portrait' })
saveAs(blob, `${name}.docx`)
}
...@@ -108,11 +108,11 @@ export function BookDetailView() { ...@@ -108,11 +108,11 @@ export function BookDetailView() {
) )
} }
if (isTrialAvailable(book)) return openReader(book.readChapterId) if (isTrialAvailable(book)) return openReader(book.readChapterId)
messageApi.info('购买功能待接入') messageApi.info('请使用 APP 购买')
} }
const selectChapter = (chapter: BookChapter) => { const selectChapter = (chapter: BookChapter) => {
if (chapterAccess(chapter)) return openReader(chapter.id) if (chapterAccess(chapter)) return openReader(chapter.id)
messageApi.info('购买功能待接入') messageApi.info('请使用 APP 购买')
} }
if (detailQuery.isLoading) { if (detailQuery.isLoading) {
......
...@@ -68,7 +68,7 @@ function makeDocument(content: string, notes: ReaderNote[]) { ...@@ -68,7 +68,7 @@ function makeDocument(content: string, notes: ReaderNote[]) {
<html lang="zh-CN"><head><meta charset="utf-8"><style> <html lang="zh-CN"><head><meta charset="utf-8"><style>
:root { color: #2b2520; background: #fffdf8; font-family: Georgia, "Noto Serif SC", "Songti SC", serif; } :root { color: #2b2520; background: #fffdf8; font-family: Georgia, "Noto Serif SC", "Songti SC", serif; }
body { box-sizing: border-box; max-width: 780px; margin: 0 auto; padding: 48px 40px 96px; font-size: 18px; line-height: 2; overflow-wrap: anywhere; } body { box-sizing: border-box; max-width: 780px; margin: 0 auto; padding: 48px 40px 96px; font-size: 18px; line-height: 2; overflow-wrap: anywhere; }
img, video, audio { max-width: 100%; height: auto; } table { display: block; max-width: 100%; overflow-x: auto; } pre { overflow-x: auto; padding: 16px; background: #f5f0e7; } a { color: #a94137; } img, video { max-width: 100%; height: auto; } audio { max-width: 100%; } table { display: block; max-width: 100%; overflow-x: auto; } pre { overflow-x: auto; padding: 16px; background: #f5f0e7; } a { color: #a94137; }
</style><style> </style><style>
mark.reader-annotation { padding: 0; border-radius: 2px; color: inherit; cursor: pointer; } mark.reader-annotation { padding: 0; border-radius: 2px; color: inherit; cursor: pointer; }
mark.reader-annotation--highlight { background: var(--annotation-color, #fff08a); } mark.reader-annotation--highlight { background: var(--annotation-color, #fff08a); }
......
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论