feat: 内容管理第5批优化 - Bug修复 + 分享功能 + 代付功能

1. Bug修复:
   - 修复Markdown星号/下划线在小程序端原样显示问题(markdownToHtml增加__和_支持,contentParser增加Markdown格式剥离)
   - 修复@提及无反应(MentionSuggestion使用ref保持persons最新值,解决闭包捕获空数组问题)
   - 修复#链接标签点击"未找到小程序配置"(增加appId直接跳转降级路径)

2. 分享功能优化:
   - "分享到朋友圈"改为"分享给好友"(open-type从shareTimeline改为share)
   - 90%收益提示移到分享按钮下方
   - 阅读20%后向上滑动弹出分享浮层提示(4秒自动消失)

3. 代付功能:
   - 后端:新增UserBalance/BalanceTransaction/GiftUnlock三个模型
   - 后端:新增8个余额相关API(查询/充值/充值确认/代付/领取/退款/交易记录/礼物信息)
   - 小程序:阅读页新增"代付分享"按钮,支持用余额为好友解锁章节
   - 分享链接携带gift参数,好友打开自动领取解锁

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 09:20:27 +08:00
parent 8778a42429
commit 991e17698c
260 changed files with 26780 additions and 1026 deletions

View File

@@ -90,7 +90,9 @@ function markdownToHtml(md: string): string {
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
@@ -163,9 +165,11 @@ const LinkTagExtension = Node.create({
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) =>
persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8),
const MentionSuggestion = (personsRef: React.RefObject<PersonItem[]>): any => ({
items: ({ query }: { query: string }) => {
const persons = personsRef.current || []
return persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8)
},
render: () => {
let popup: HTMLDivElement | null = null
let selectedIndex = 0
@@ -247,6 +251,12 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const personsRef = useRef(persons)
personsRef.current = persons
const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
const editor = useEditor({
extensions: [
StarterKit,
@@ -254,7 +264,7 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),
suggestion: MentionSuggestion(personsRef),
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
@@ -263,7 +273,10 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
],
content: initialContent.current,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
onChange(ed.getHTML())
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
onChangeRef.current(ed.getHTML())
}, 300)
},
editorProps: {
attributes: { class: 'rich-editor-content' },

View File

@@ -25,6 +25,8 @@ export function ApiDocPage() {
<p className="text-gray-400 mb-2"></p>
<ul className="space-y-1 text-gray-300 font-mono">
<li>/api/book </li>
<li>/api/miniprogram/upload /</li>
<li>/api/admin/content/upload </li>
<li>/api/payment </li>
<li>/api/referral </li>
<li>/api/user </li>
@@ -52,6 +54,57 @@ export function ApiDocPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.1 </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/image </p>
<p className="text-gray-500 text-xs mb-1">filefolder imagesquality 1-100 85</p>
<p className="text-gray-500 text-xs"> jpeg/png/gif 5MBJPEG quality </p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/images/xxx.jpg", "data": { "url", "fileName", "size", "type", "quality" } }`}
</pre>
</div>
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/video </p>
<p className="text-gray-500 text-xs mb-1">filefolder videos</p>
<p className="text-gray-500 text-xs"> mp4/mov/avi 100MB</p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/videos/xxx.mp4", "data": { "url", "fileName", "size", "type", "folder" } }`}
</pre>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.2 </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-gray-400">POST /api/admin/content/upload AdminAuth</p>
<p className="text-gray-500 text-xs"> API </p>
<pre className="p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`请求体: {
"action": "import",
"data": [{
"id": "ch-001",
"title": "章节标题",
"content": "正文内容",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"partTitle": "第一篇",
"chapterId": "chapter-1",
"chapterTitle": "第1章"
}]
}`}
</pre>
<p className="text-gray-500 text-xs">{`{ "success": true, "message": "导入完成", "imported": N, "failed": M }`}</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">3. </CardTitle>

View File

@@ -1,4 +1,4 @@
/**
/**
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
@@ -450,7 +450,8 @@ export function ChapterTree({
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
onClick={() => onReadSection(section)}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-pointer select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
@@ -473,7 +474,7 @@ export function ChapterTree({
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { Users, Eye, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -71,7 +71,7 @@ export function DashboardPage() {
const [totalUsersCount, setTotalUsersCount] = useState(0)
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [todayClicks, setTodayClicks] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
@@ -95,7 +95,6 @@ export function DashboardPage() {
setTotalUsersCount(stats.totalUsers ?? 0)
setPaidOrderCount(stats.paidOrderCount ?? 0)
setTotalRevenue(stats.totalRevenue ?? 0)
setConversionRate(stats.conversionRate ?? 0)
}
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
@@ -106,7 +105,6 @@ export function DashboardPage() {
setTotalUsersCount(overview.totalUsers ?? 0)
setPaidOrderCount(overview.paidOrderCount ?? 0)
setTotalRevenue(overview.totalRevenue ?? 0)
setConversionRate(overview.conversionRate ?? 0)
}
} catch (e2) {
showError(e2)
@@ -116,6 +114,16 @@ export function DashboardPage() {
setStatsLoading(false)
}
// 加载今日点击(从推广中心接口)
try {
const distOverview = await get<{ success?: boolean; todayClicks?: number }>('/api/admin/distribution/overview', init)
if (distOverview?.success) {
setTodayClicks(distOverview.todayClicks ?? 0)
}
} catch {
// 推广数据加载失败不影响主面板
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
@@ -237,11 +245,11 @@ export function DashboardPage() {
link: '/orders',
},
{
title: '转化率',
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
icon: BookOpen,
color: 'text-orange-400',
bg: 'bg-orange-500/20',
title: '今日点击',
value: statsLoading ? null : todayClicks,
icon: Eye,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
link: '/distribution',
},
]