文章编辑器问题
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
# 开发环境:对接当前 Next 后端(与现网 API 路径完全一致,无缝切换)
|
||||
VITE_API_BASE_URL=https://soulapi.quwanzhi.com
|
||||
# VITE_API_BASE_URL=https://souldev.quwanzhi.com
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-7GwP_AfR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B1CMMwBM.css">
|
||||
<script type="module" crossorigin src="/assets/index-BZAN98xm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHi-SnBy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() || ''),
|
||||
|
||||
@@ -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" />
|
||||
|
||||
146
soul-api/cmd/migrate-base64-images/main.go
Normal file
146
soul-api/cmd/migrate-base64-images/main.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// migrate-base64-images 将 chapters 表中 content 内的 base64 图片提取为文件并替换为 URL
|
||||
// 用法:cd soul-api && go run ./cmd/migrate-base64-images [--dry-run]
|
||||
// 测试环境:APP_ENV=development 时加载 .env.development,请先在测试库验证
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
// data:image/png;base64,iVBORw0KG... 或 data:image/jpeg;base64,/9j/4AAQ...
|
||||
var base64ImgRe = regexp.MustCompile(`(?i)src=["'](data:image/([^;"']+);base64,([A-Za-z0-9+/=]+))["']`)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "仅统计和预览,不写入文件与数据库")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("load config: ", err)
|
||||
}
|
||||
config.SetCurrent(cfg)
|
||||
if err := database.Init(cfg.DBDSN); err != nil {
|
||||
log.Fatal("database: ", err)
|
||||
}
|
||||
|
||||
uploadDir := cfg.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
bookImagesDir := filepath.Join(uploadDir, "book-images")
|
||||
if !*dryRun {
|
||||
if err := os.MkdirAll(bookImagesDir, 0755); err != nil {
|
||||
log.Fatal("mkdir book-images: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var chapters []model.Chapter
|
||||
if err := db.Select("id", "mid", "section_title", "content").Where("content LIKE ?", "%data:image%").Find(&chapters).Error; err != nil {
|
||||
log.Fatal("query chapters: ", err)
|
||||
}
|
||||
|
||||
log.Printf("找到 %d 篇含 base64 图片的章节", len(chapters))
|
||||
if len(chapters) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
randomStr := func(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
mimeToExt := map[string]string{
|
||||
"png": ".png",
|
||||
"jpeg": ".jpg",
|
||||
"jpg": ".jpg",
|
||||
"gif": ".gif",
|
||||
"webp": ".webp",
|
||||
}
|
||||
|
||||
totalReplaced := 0
|
||||
totalFiles := 0
|
||||
for _, ch := range chapters {
|
||||
matches := base64ImgRe.FindAllStringSubmatch(ch.Content, -1)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
newContent := ch.Content
|
||||
for _, m := range matches {
|
||||
fullDataURL := m[1]
|
||||
mime := strings.ToLower(strings.TrimSpace(m[2]))
|
||||
b64 := m[3]
|
||||
ext := mimeToExt[mime]
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
log.Printf(" [%s] base64 解码失败: %v", ch.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStr(6), ext)
|
||||
dst := filepath.Join(bookImagesDir, name)
|
||||
url := "/uploads/" + filepath.ToSlash(filepath.Join("book-images", name))
|
||||
|
||||
if !*dryRun {
|
||||
if err := os.WriteFile(dst, decoded, 0644); err != nil {
|
||||
log.Printf(" [%s] 写入文件失败 %s: %v", ch.ID, name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
oldSrc := `src="` + fullDataURL + `"`
|
||||
newSrc := `src="` + url + `"`
|
||||
if strings.Contains(newContent, oldSrc) {
|
||||
newContent = strings.Replace(newContent, oldSrc, newSrc, 1)
|
||||
} else {
|
||||
oldSrc2 := `src='` + fullDataURL + `'`
|
||||
newSrc2 := `src="` + url + `"`
|
||||
newContent = strings.Replace(newContent, oldSrc2, newSrc2, 1)
|
||||
}
|
||||
totalFiles++
|
||||
log.Printf(" [%s] %s -> %s (%d bytes)", ch.ID, mime, name, len(decoded))
|
||||
}
|
||||
|
||||
if newContent != ch.Content {
|
||||
totalReplaced++
|
||||
oldLen := len(ch.Content)
|
||||
newLen := len(newContent)
|
||||
if !*dryRun {
|
||||
if err := db.Model(&model.Chapter{}).Where("id = ?", ch.ID).Update("content", newContent).Error; err != nil {
|
||||
log.Printf(" [%s] 更新数据库失败: %v", ch.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
log.Printf(" [%s] 已更新,content 长度 %d -> %d (减少 %d)", ch.ID, oldLen, newLen, oldLen-newLen)
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
log.Printf("[dry-run] 将处理 %d 篇章节,共 %d 张 base64 图片", totalReplaced, totalFiles)
|
||||
log.Printf("[dry-run] 去掉 --dry-run 后执行以实际写入")
|
||||
} else {
|
||||
log.Printf("完成:更新 %d 篇章节,提取 %d 张图片到 uploads/book-images/", totalReplaced, totalFiles)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ func main() {
|
||||
go func() {
|
||||
log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
|
||||
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
|
||||
if cfg.UploadDir != "" {
|
||||
log.Printf(" -> 上传目录: %s", cfg.UploadDir)
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ type Config struct {
|
||||
|
||||
// 订单对账定时任务间隔(分钟),0 表示不启动内置定时任务
|
||||
SyncOrdersIntervalMinutes int
|
||||
|
||||
// 上传目录(绝对路径,air 运行时避免相对路径解析错误)
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
// BaseURLJoin 将路径拼接到 BaseURL,path 应以 / 开头
|
||||
@@ -239,6 +242,14 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传目录:优先 UPLOAD_DIR 环境变量,否则用项目根下的 uploads
|
||||
uploadDir := strings.TrimSpace(os.Getenv("UPLOAD_DIR"))
|
||||
if uploadDir == "" {
|
||||
uploadDir = resolveUploadDir(workDir, execDir)
|
||||
} else if !filepath.IsAbs(uploadDir) {
|
||||
uploadDir, _ = filepath.Abs(filepath.Join(workDir, uploadDir))
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
Mode: mode,
|
||||
@@ -265,5 +276,21 @@ func Load() (*Config, error) {
|
||||
AdminPassword: adminPassword,
|
||||
AdminSessionSecret: adminSessionSecret,
|
||||
SyncOrdersIntervalMinutes: syncOrdersInterval,
|
||||
UploadDir: uploadDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveUploadDir 解析上传目录绝对路径(air 运行时 exe 在 tmp/,需用项目根)
|
||||
func resolveUploadDir(workDir, execDir string) string {
|
||||
root := workDir
|
||||
if execDir != "" {
|
||||
base := filepath.Base(execDir)
|
||||
if base == "tmp" {
|
||||
root = filepath.Dir(execDir)
|
||||
} else {
|
||||
root = execDir
|
||||
}
|
||||
}
|
||||
abs, _ := filepath.Abs(filepath.Join(root, "uploads"))
|
||||
return abs
|
||||
}
|
||||
|
||||
@@ -306,8 +306,47 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "read":
|
||||
midStr := c.Query("mid")
|
||||
if midStr != "" {
|
||||
// 优先用 mid 获取(管理端编辑、小程序跳转推荐)
|
||||
mid, err := strconv.Atoi(midStr)
|
||||
if err != nil || mid < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "mid 必须为正整数"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Where("mid = ?", mid).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或 mid"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
|
||||
@@ -71,7 +71,7 @@ func SearchGet(c *gin.Context) {
|
||||
price = *ch.Price
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadDir = "uploads"
|
||||
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
|
||||
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
|
||||
|
||||
@@ -40,6 +40,10 @@ func UploadPost(c *gin.Context) {
|
||||
if folder == "" {
|
||||
folder = "avatars"
|
||||
}
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
dir := filepath.Join(uploadDir, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
@@ -48,8 +52,12 @@ func UploadPost(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
relPath := "/uploads/" + filepath.ToSlash(filepath.Join(folder, name))
|
||||
fullURL := relPath
|
||||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||||
fullURL = cfg.BaseURLJoin(relPath)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": fullURL, "data": gin.H{"url": fullURL, "fileName": name, "size": file.Size, "type": ct}})
|
||||
}
|
||||
|
||||
func randomStrUpload(n int) string {
|
||||
@@ -72,7 +80,13 @@ func UploadDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
|
||||
return
|
||||
}
|
||||
fullPath := strings.TrimPrefix(path, "/")
|
||||
rel := strings.TrimPrefix(path, "/uploads/")
|
||||
rel = strings.TrimPrefix(rel, "uploads/")
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel))
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
|
||||
return
|
||||
|
||||
@@ -28,7 +28,11 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
rateLimiter := middleware.NewRateLimiter(100, 200)
|
||||
r.Use(rateLimiter.Middleware())
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
uploadDir := cfg.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./uploads"
|
||||
}
|
||||
r.Static("/uploads", uploadDir)
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
|
||||
62
soul-api/scripts/README-migrate-base64-images.md
Normal file
62
soul-api/scripts/README-migrate-base64-images.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 文章 base64 图片迁移脚本
|
||||
|
||||
将 `chapters` 表中 `content` 字段内嵌的 base64 图片提取为独立文件,并替换为 `/uploads/book-images/xxx` 的 URL,减小文章体积。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 历史文章中有大量粘贴的 base64 图片
|
||||
- 保存时因 content 过大导致超时或失败
|
||||
- 需要将 base64 转为文件存储
|
||||
|
||||
## 执行方式
|
||||
|
||||
### 1. 测试环境(建议先执行)
|
||||
|
||||
```bash
|
||||
cd soul-api
|
||||
|
||||
# 加载测试环境配置(.env.development)
|
||||
$env:APP_ENV="development"
|
||||
|
||||
# 先 dry-run 预览,不写入
|
||||
go run ./cmd/migrate-base64-images --dry-run
|
||||
|
||||
# 确认无误后正式执行
|
||||
go run ./cmd/migrate-base64-images
|
||||
```
|
||||
|
||||
### 2. 生产环境
|
||||
|
||||
```bash
|
||||
cd soul-api
|
||||
$env:APP_ENV="production"
|
||||
go run ./cmd/migrate-base64-images --dry-run # 先预览
|
||||
go run ./cmd/migrate-base64-images # 正式执行
|
||||
```
|
||||
|
||||
### 3. 指定 DSN(覆盖 .env)
|
||||
|
||||
```bash
|
||||
$env:DB_DSN="user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True"
|
||||
go run ./cmd/migrate-base64-images --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--dry-run` | 仅统计和预览,不写入文件与数据库 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
1. 查询 `content LIKE '%data:image%'` 的章节
|
||||
2. 用正则提取 `src="data:image/xxx;base64,..."` 或 `src='...'`
|
||||
3. 解码 base64,保存到 `uploads/book-images/{timestamp}_{random}.{ext}`
|
||||
4. 将 content 中的 base64 src 替换为 `/uploads/book-images/xxx`
|
||||
5. 更新数据库
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **务必先在测试环境验证**,确认无误后再跑生产
|
||||
- 脚本依赖 `UPLOAD_DIR` 或默认 `uploads` 目录
|
||||
- 图片格式支持:png、jpeg、jpg、gif、webp
|
||||
BIN
soul-api/uploads/book-images/1773490566519742800_2dutej.jpg
Normal file
BIN
soul-api/uploads/book-images/1773490566519742800_2dutej.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
soul-api/uploads/book-images/1773490754797877200_l95rch.jpg
Normal file
BIN
soul-api/uploads/book-images/1773490754797877200_l95rch.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
soul-api/uploads/book-images/1773490768336779500_ynwogi.png
Normal file
BIN
soul-api/uploads/book-images/1773490768336779500_ynwogi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
BIN
soul-api/uploads/book-images/1773500723826918200_hm8bdp.png
Normal file
BIN
soul-api/uploads/book-images/1773500723826918200_hm8bdp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 849 KiB |
BIN
soul-api/uploads/book-images/1773500740641980500_wdbr9t.png
Normal file
BIN
soul-api/uploads/book-images/1773500740641980500_wdbr9t.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 849 KiB |
BIN
soul-api/uploads/book-images/1773500758602101900_fq51fu.png
Normal file
BIN
soul-api/uploads/book-images/1773500758602101900_fq51fu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user