Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
C
center-book
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
EzijingWeb
center-book
Commits
1ebb03ec
提交
1ebb03ec
authored
7月 30, 2026
作者:
王鹏飞
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
chore: update
上级
a1fc9f27
全部展开
隐藏空白字符变更
内嵌
并排
正在显示
13 个修改的文件
包含
186 行增加
和
541 行删除
+186
-541
package.json
apps/admin/package.json
+1
-2
node.js
...ponents/editor/wangeditor-customer/features/audio/node.js
+2
-3
PracticeSettingsDialog.jsx
...tor-customer/features/practice/PracticeSettingsDialog.jsx
+141
-115
node.js
...ponents/editor/wangeditor-customer/features/video/node.js
+2
-3
api.ts
apps/admin/src/modules/books/management/api.ts
+5
-3
ExportBookModal.tsx
...c/modules/books/management/components/ExportBookModal.tsx
+20
-3
normalizeBookExportHtml.test.ts
...s/books/management/export/normalizeBookExportHtml.test.ts
+0
-52
normalizeBookExportHtml.ts
...odules/books/management/export/normalizeBookExportHtml.ts
+0
-221
ManagementListView.tsx
...src/modules/books/management/views/ManagementListView.tsx
+12
-14
exportWordHtmlDocx.js
apps/admin/src/utils/exportWordHtmlDocx.js
+0
-122
BookDetailView.tsx
apps/learning/src/modules/book/views/BookDetailView.tsx
+2
-2
ReaderContent.tsx
.../learning/src/modules/reader/components/ReaderContent.tsx
+1
-1
pnpm-lock.yaml
pnpm-lock.yaml
+0
-0
没有找到文件。
apps/admin/package.json
浏览文件 @
1ebb03ec
...
@@ -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"
},
},
...
...
apps/admin/src/components/editor/wangeditor-customer/features/audio/node.js
浏览文件 @
1ebb03ec
...
@@ -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>`
...
...
apps/admin/src/components/editor/wangeditor-customer/features/practice/PracticeSettingsDialog.jsx
浏览文件 @
1ebb03ec
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
([])
const
textColorChange
=
(
value
)
=>
{
useEffect
(()
=>
{
const
hex
=
typeof
value
===
'string'
?
value
:
value
?.
toHexString
()
setTheme
(
practiceTheme
||
'#ab1941'
)
setThemeValue
(
hex
)
setTitle
(
practiceTitle
||
''
)
form
.
setFieldsValue
({
theme
:
hex
})
},
[
practiceTheme
,
practiceTitle
])
}
const
callback
=
(
practiceNum
,
practiceTitle
)
=>
{
useEffect
(()
=>
{
const
nodeEntries
=
SlateEditor
.
nodes
(
editor
,
{
bookNameList
({}).
then
((
data
)
=>
setBookList
(
data
||
[]))
match
:
(
node
)
=>
{
},
[])
// JS syntax
if
(
SlateElement
.
isElement
(
node
))
{
if
(
node
.
type
===
'chapterPractice'
)
{
return
true
}
}
return
false
},
universal
:
true
,
})
setNodes
(
nodeEntries
)
useEffect
(()
=>
{
setPracticeRandom
({
practiceNum
,
practiceTitle
})
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
onFinish
=
(
values
)
=>
{
const
updatePracticeTitle
=
()
=>
{
editor
.
restoreSelection
()
const
nextTitle
=
title
.
trim
()
if
(
!
nextTitle
)
{
message
.
error
(
'请输入标题'
)
return
}
removeSelectedEmptyParagraph
(
editor
,
SlateTransforms
)
for
(
const
[
node
,
path
]
of
SlateEditor
.
nodes
(
editor
,
{
match
:
(
node
)
=>
SlateElement
.
isElement
(
node
)
&&
node
.
type
===
'chapterPractice'
,
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
)
return
}
}
const
practiceNum
=
Math
.
random
().
toString
(
32
).
substring
(
2
,
10
)
const
addSelectedQuestions
=
()
=>
{
insertBlockNodesAndFocus
(
editor
,
SlateTransforms
,
{
if
(
selectedQuestions
.
length
===
0
)
{
type
:
'chapterPractice'
,
message
.
error
(
'请先勾选试题'
)
title
:
values
.
practice
,
return
practiceNum
:
practiceNum
,
}
bookId
,
const
existed
=
new
Set
(
insertedQuestions
.
map
((
item
)
=>
String
(
item
.
choose_id
)))
chapterId
,
setInsertedQuestions
((
current
)
=>
[
theme
:
values
.
theme
,
...
current
,
callback
:
callback
,
...
selectedQuestions
.
filter
((
item
)
=>
!
existed
.
has
(
String
(
item
.
choose_id
))),
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
}
</
div
>
initialValues=
{
initValues
}
<
div
className=
'practice-l-title'
>
>
{
editingTitle
?
(
<
Form
.
Item
<
Space
>
label=
'标题'
<
Input
value=
{
title
}
onChange=
{
(
event
)
=>
setTitle
(
event
.
target
.
value
)
}
/>
name=
'practice'
<
ColorPicker
disabledAlpha
value=
{
theme
}
format=
'hex'
onChange=
{
(
value
)
=>
setTheme
(
value
.
toHexString
())
}
/>
rules=
{
[{
required
:
true
,
message
:
'请输入标题'
}]
}
<
Button
type=
'primary'
onClick=
{
updatePracticeTitle
}
>
确定
</
Button
>
extra=
'最多输入100字'
<
Button
onClick=
{
()
=>
setEditingTitle
(
false
)
}
>
取消
</
Button
>
>
</
Space
>
<
Input
maxLength=
{
100
}
placeholder=
''
allowClear
/>
)
:
(
</
Form
.
Item
>
<
h2
className=
'title'
onClick=
{
()
=>
setEditingTitle
(
true
)
}
>
<
Form
.
Item
>
<
ColorPicker
disabled
value=
{
theme
}
size=
'small'
/>
<
div
className=
'justcontent-color-inline'
>
<
span
className=
'titles'
>
{
practiceTitle
}
</
span
>
<
Form
.
Item
<
span
className=
'click'
>
(点击编辑)
</
span
>
label=
'练习主题色'
</
h2
>
name=
'theme'
)
}
className=
'flex-max'
</
div
>
rules=
{
[
</
div
>
{
required
:
true
,
message
:
'请选择颜色'
},
<
div
className=
'practice-topic-list'
>
{
pattern
:
/^#
[
0-9A-Fa-f
]
{6}$/i
,
message
:
'请输入正确的16进制色值'
},
<
InsertTopic
]
}
insertList=
{
insertedQuestions
}
>
setInsertList=
{
setInsertedQuestions
}
<
Input
placeholder=
''
allowClear
/>
chooseList=
{
selectedQuestions
}
</
Form
.
Item
>
setChooseList=
{
setSelectedQuestions
}
<
Form
.
Item
label=
{
` `
}
>
practiceRandom=
{
practiceRandom
}
<
ColorPicker
chapterId=
{
chapterId
}
disabledAlpha
bookId=
{
bookId
}
value=
{
themeValue
}
closePanel=
{
closePanel
}
defaultValue=
{
themeValue
}
/>
format=
'hex'
</
div
>
onChange=
{
textColorChange
}
</
Col
>
/>
<
Col
span=
{
12
}
className=
'common practice-right'
>
</
Form
.
Item
>
<
div
className=
'practice-header right'
>
</
div
>
<
Button
onClick=
{
()
=>
setManualAdd
((
value
)
=>
!
value
)
}
>
</
Form
.
Item
>
{
manualAdd
?
'从题库选择'
:
'新增试题'
}
<
Form
.
Item
className=
'editor-form-buttons'
>
</
Button
>
<
Space
>
<
Button
type=
'link'
icon=
{
<
CloseOutlined
style=
{
{
color
:
'#b83956'
}
}
/>
}
onClick=
{
closePanel
}
/>
<
Button
type=
'default'
onClick=
{
()
=>
setPracticeOpenVisible
(
false
)
}
>
</
div
>
取消
<
div
className=
'practice-topic-list'
>
</
Button
>
{
manualAdd
?
(
<
Button
type=
'primary'
htmlType=
'submit'
>
<
CustomerTopic
插入
setPracticeStatus=
{
setManualAdd
}
</
Button
>
bookNameIdData=
{
bookList
}
</
Space
>
bookId=
{
bookId
}
</
Form
.
Item
>
setCustomerType=
{
setCustomerType
}
</
Form
>
/>
</
div
>
)
:
(
</
div
>
<
ListTopic
chooseList=
{
selectedQuestions
}
setChooseList=
{
setSelectedQuestions
}
addTopicList=
{
addSelectedQuestions
}
practiceStatus=
{
manualAdd
}
bookNameIdData=
{
bookList
}
bookId=
{
bookId
}
customerType=
{
customerType
}
/>
)
}
</
div
>
</
Col
>
</
Row
>
)
)
}
}
export
default
forwardRef
(
PracticeModal
)
export
default
PracticeSettingsDialog
apps/admin/src/components/editor/wangeditor-customer/features/video/node.js
浏览文件 @
1ebb03ec
...
@@ -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>`
...
...
apps/admin/src/modules/books/management/api.ts
浏览文件 @
1ebb03ec
...
@@ -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
)
{
...
...
apps/admin/src/modules/books/management/components/ExportBookModal.tsx
浏览文件 @
1ebb03ec
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
>
...
...
apps/admin/src/modules/books/management/export/normalizeBookExportHtml.test.ts
deleted
100644 → 0
浏览文件 @
a1fc9f27
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'
)
})
})
apps/admin/src/modules/books/management/export/normalizeBookExportHtml.ts
deleted
100644 → 0
浏览文件 @
a1fc9f27
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
}
apps/admin/src/modules/books/management/views/ManagementListView.tsx
浏览文件 @
1ebb03ec
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
)
}
}
...
...
apps/admin/src/utils/exportWordHtmlDocx.js
deleted
100644 → 0
浏览文件 @
a1fc9f27
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`
)
}
apps/learning/src/modules/book/views/BookDetailView.tsx
浏览文件 @
1ebb03ec
...
@@ -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
)
{
...
...
apps/learning/src/modules/reader/components/ReaderContent.tsx
浏览文件 @
1ebb03ec
...
@@ -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); }
...
...
pnpm-lock.yaml
浏览文件 @
1ebb03ec
差异被折叠。
点击展开。
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论