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:
@@ -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' },
|
||||
|
||||
@@ -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">表单:file(必填)、folder(可选,默认 images)、quality(可选 1-100,默认 85)</p>
|
||||
<p className="text-gray-500 text-xs">支持 jpeg/png/gif,单张最大 5MB。JPEG 按 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">表单:file(必填)、folder(可选,默认 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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user