文章编辑器问题

This commit is contained in:
Alex-larget
2026-03-14 23:27:22 +08:00
parent d82ef6d8e4
commit 7ece8f52ff
24 changed files with 642 additions and 217 deletions

View File

@@ -9,9 +9,12 @@ import { getAdminToken } from './auth'
/** 未设置环境变量时使用的默认 API 地址(零配置部署) */
const DEFAULT_API_BASE = 'https://soulapi.quwanzhi.com'
/** 请求超时(毫秒),避免接口无响应时一直卡在加载中 */
/** 默认请求超时(毫秒),避免接口无响应时一直卡在加载中 */
const REQUEST_TIMEOUT = 15000
/** 大请求(如保存长文章)超时 */
export const SAVE_REQUEST_TIMEOUT = 60000
const getBaseUrl = (): string => {
const url = import.meta.env.VITE_API_BASE_URL
if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '')
@@ -25,7 +28,11 @@ export function apiUrl(path: string): string {
return base ? `${base}${p}` : p
}
export type RequestInitWithBody = RequestInit & { data?: unknown }
export type RequestInitWithBody = RequestInit & {
data?: unknown
/** 自定义超时(毫秒),不传则用默认 15s */
timeout?: number
}
/**
* 发起请求。path 为与现网一致的 API 路径(如 /api/admin、/api/orders
@@ -46,8 +53,9 @@ export async function request<T = unknown>(
headers.set('Content-Type', 'application/json')
}
const body = data !== undefined && data !== null ? JSON.stringify(data) : init.body
const timeout = (init as RequestInitWithBody).timeout ?? REQUEST_TIMEOUT
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const timeoutId = setTimeout(() => controller.abort(), timeout)
const res = await fetch(url, {
...init,
headers,
@@ -88,12 +96,12 @@ export function get<T = unknown>(path: string, init?: RequestInit): Promise<T> {
}
/** POST */
export function post<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
export function post<T = unknown>(path: string, data?: unknown, init?: RequestInitWithBody): Promise<T> {
return request<T>(path, { ...init, method: 'POST', data })
}
/** PUT */
export function put<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
export function put<T = unknown>(path: string, data?: unknown, init?: RequestInitWithBody): Promise<T> {
return request<T>(path, { ...init, method: 'PUT', data })
}

View File

@@ -84,8 +84,8 @@
.link-remove { background: #374151; color: #9ca3af; }
.rich-editor-content {
min-height: 300px;
max-height: 500px;
min-height: 450px;
max-height: 720px;
overflow-y: auto;
padding: 12px 16px;
color: #e5e7eb;

View File

@@ -232,6 +232,61 @@ const MentionSuggestion = (persons: PersonItem[]): any => ({
},
})
/** 从剪贴板提取图片文件(粘贴截图、复制图片时) */
function getImageFilesFromClipboard(event: ClipboardEvent): File[] {
const files: File[] = []
const items = event.clipboardData?.items
if (!items) return files
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile()
if (file) files.push(file)
}
}
return files
}
const BASE64_IMG_RE = /src=["'](data:image\/([^;"']+);base64,([A-Za-z0-9+/=]+))["']/gi
/** 将 base64 转为 File */
function base64ToFile(b64: string, mime: string): File {
const ext = { png: '.png', jpeg: '.jpg', jpg: '.jpg', gif: '.gif', webp: '.webp' }[mime.toLowerCase()] || '.png'
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return new File([new Blob([arr], { type: `image/${mime}` })], `image${ext}`, { type: `image/${mime}` })
}
/** 将 HTML 中的 base64 图片替换为上传后的 URL支持多个、去重同图只传一次 */
async function replaceBase64ImagesInHtml(
html: string,
onImageUpload: (file: File) => Promise<string>,
): Promise<string> {
const matches = [...html.matchAll(BASE64_IMG_RE)]
if (matches.length === 0) return html
const urlCache = new Map<string, string>() // full dataURL -> 上传后的 url同图只传一次
let result = html
for (const m of matches) {
const full = m[1]
const mime = m[2]
const b64 = m[3]
let url = urlCache.get(full)
if (!url) {
try {
const file = base64ToFile(b64, mime)
url = await onImageUpload(file)
urlCache.set(full, url)
} catch (e) {
console.error('base64 图片上传失败', e)
continue
}
}
// 替换该 base64 的所有出现(同一张图可能被引用多次)
result = result.split(`src="${full}"`).join(`src="${url}"`).split(`src='${full}'`).join(`src="${url}"`)
}
return result
}
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
@@ -242,10 +297,54 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const editorRef = useRef<Editor | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const handlePaste = useCallback(
(_view: unknown, event: ClipboardEvent) => {
const ed = editorRef.current
if (!ed || !onImageUpload) return false
// 1. 粘贴截图/复制图片:剪贴板直接有 image 文件
const imageFiles = getImageFilesFromClipboard(event)
if (imageFiles.length > 0) {
event.preventDefault()
;(async () => {
for (const file of imageFiles) {
try {
const url = await onImageUpload(file)
if (url) ed.chain().focus().setImage({ src: url }).run()
} catch (e) {
console.error('粘贴图片上传失败', e)
}
}
})()
return true
}
// 2. 粘贴 HTML含 base64 图片):复制编辑器内容再粘贴时,自动上传 base64 转为 URL
const html = event.clipboardData?.getData('text/html')
if (html && /data:image\/[^;"']+;base64,/i.test(html)) {
event.preventDefault()
const { from, to } = ed.state.selection
;(async () => {
try {
const processed = await replaceBase64ImagesInHtml(html, onImageUpload)
ed.chain().focus().insertContentAt({ from, to }, processed).run()
} catch (e) {
console.error('粘贴 HTML 内 base64 转换失败', e)
}
})()
return true
}
return false
},
[onImageUpload],
)
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -267,9 +366,14 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
},
editorProps: {
attributes: { class: 'rich-editor-content' },
handlePaste,
},
})
useEffect(() => {
editorRef.current = editor ?? null
}, [editor])
useImperativeHandle(ref, () => ({
getHTML: () => editor?.getHTML() || '',
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),

View File

@@ -48,7 +48,7 @@ import {
Smartphone,
} from 'lucide-react'
import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage'
import { get, put, post, del } from '@/api/client'
import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal'
import { getPersonDetail } from '@/api/ckb'
@@ -56,6 +56,7 @@ import { apiUrl } from '@/api/client'
interface SectionListItem {
id: string
mid?: number
title: string
price: number
isFree?: boolean
@@ -74,6 +75,7 @@ interface SectionListItem {
interface Section {
id: string
mid?: number
title: string
price: number
filePath?: string
@@ -241,6 +243,7 @@ function buildTree(sections: SectionListItem[]): Part[] {
}
part.chapters.get(chapterId)!.sections.push({
id: s.id,
mid: s.mid,
title: s.title,
price: s.price ?? 1,
filePath: s.filePath,
@@ -292,7 +295,7 @@ export function ContentPage() {
const [isLoadingContent, setIsLoadingContent] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
const [searchResults, setSearchResults] = useState<{ id: string; mid?: number; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
const [isSearching, setIsSearching] = useState(false)
const [newSection, setNewSection] = useState({
@@ -678,8 +681,12 @@ export function ContentPage() {
const handleReadSection = async (section: Section & { filePath?: string }) => {
setIsLoadingContent(true)
try {
const url =
section.mid != null && section.mid > 0
? `/api/db/book?action=read&mid=${section.mid}`
: `/api/db/book?action=read&id=${encodeURIComponent(section.id)}`
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string; editionStandard?: boolean; editionPremium?: boolean }; error?: string }>(
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
url,
)
if (data?.success && data.section) {
const sec = data.section as { isNew?: boolean; editionStandard?: boolean; editionPremium?: boolean }
@@ -748,19 +755,23 @@ export function ContentPage() {
const originalId = editingSection.originalId || editingSection.id
const idChanged = editingSection.id !== originalId
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: originalId,
...(idChanged ? { newId: editingSection.id } : {}),
title: editingSection.title,
price: editingSection.isFree ? 0 : editingSection.price,
content,
isFree: editingSection.isFree || editingSection.price === 0,
isNew: editingSection.isNew,
hotScore: editingSection.hotScore,
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
editionPremium: editingSection.editionPremium ?? false,
saveToFile: true,
})
const res = await put<{ success?: boolean; error?: string }>(
'/api/db/book',
{
id: originalId,
...(idChanged ? { newId: editingSection.id } : {}),
title: editingSection.title,
price: editingSection.isFree ? 0 : editingSection.price,
content,
isFree: editingSection.isFree || editingSection.price === 0,
isNew: editingSection.isNew,
hotScore: editingSection.hotScore,
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
editionPremium: editingSection.editionPremium ?? false,
saveToFile: true,
},
{ timeout: SAVE_REQUEST_TIMEOUT },
)
const effectiveId = idChanged ? editingSection.id : originalId
if (editingSection.isPinned !== pinnedSectionIds.includes(effectiveId)) {
await handleTogglePin(effectiveId)
@@ -774,7 +785,8 @@ export function ContentPage() {
}
} catch (e) {
console.error(e)
toast.error('保存失败')
const msg = e instanceof Error && e.name === 'AbortError' ? '保存超时,请检查网络或稍后重试' : '保存失败'
toast.error(msg)
} finally {
setIsSaving(false)
}
@@ -789,22 +801,26 @@ export function ContentPage() {
try {
const currentPart = tree.find((p) => p.id === newSection.partId)
const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId)
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: newSection.id,
title: newSection.title,
price: newSection.isFree ? 0 : newSection.price,
content: autoLinkContent(newSection.content || '', persons, linkTags),
partId: newSection.partId,
partTitle: currentPart?.title ?? '',
chapterId: newSection.chapterId,
chapterTitle: currentChapter?.title ?? '',
isFree: newSection.isFree,
isNew: newSection.isNew,
editionStandard: newSection.editionPremium ? false : (newSection.editionStandard ?? true),
editionPremium: newSection.editionPremium ?? false,
hotScore: newSection.hotScore ?? 0,
saveToFile: false,
})
const res = await put<{ success?: boolean; error?: string }>(
'/api/db/book',
{
id: newSection.id,
title: newSection.title,
price: newSection.isFree ? 0 : newSection.price,
content: autoLinkContent(newSection.content || '', persons, linkTags),
partId: newSection.partId,
partTitle: currentPart?.title ?? '',
chapterId: newSection.chapterId,
chapterTitle: currentChapter?.title ?? '',
isFree: newSection.isFree,
isNew: newSection.isNew,
editionStandard: newSection.editionPremium ? false : (newSection.editionStandard ?? true),
editionPremium: newSection.editionPremium ?? false,
hotScore: newSection.hotScore ?? 0,
saveToFile: false,
},
{ timeout: SAVE_REQUEST_TIMEOUT },
)
if (res && (res as { success?: boolean }).success !== false) {
if (newSection.isPinned) {
const next = [...pinnedSectionIds, newSection.id]
@@ -2134,6 +2150,7 @@ export function ContentPage() {
onClick={() =>
handleReadSection({
id: result.id,
mid: result.mid,
title: result.title,
price: result.price ?? 1,
filePath: '',
@@ -2290,7 +2307,7 @@ export function ContentPage() {
variant="ghost"
size="sm"
className="text-gray-500 hover:text-[#38bdac] h-6 px-1"
onClick={() => handleReadSection({ id: s.id, title: s.title, price: s.price, filePath: '' })}
onClick={() => handleReadSection({ id: s.id, mid: s.mid, title: s.title, price: s.price, filePath: '' })}
title="编辑文章"
>
<Edit3 className="w-3 h-3" />