feat: 内容管理深度优化 (03-07~03-09)

- 排名算法权重可配置,排行榜显示点击量/付款数/热度
- 富文本编辑器升级(TipTap),支持@提及/#链接标签/图片/表格
- 「主人公」Tab → 「链接AI」Tab,AI列表+链接标签管理
- 链接标签新增存客宝(ckb)类型,存客宝绑定配置面板
- 人物ID改为可选,名称必填
- 排行榜操作改为「编辑文章」,付款记录移入编辑弹窗
- 章节ID修改支持(originalId/newId机制)
- 付款记录用户ID/订单ID可点击跳转
- 项目推进表补充14-15节(03-07~09改动记录+存客宝技术方案)

Made-with: Cursor
This commit is contained in:
卡若
2026-03-09 05:49:03 +08:00
parent 4bb9e2af36
commit 22bb29f433
29 changed files with 8283 additions and 708 deletions

View File

@@ -0,0 +1,196 @@
.rich-editor-wrapper {
border: 1px solid #374151;
border-radius: 0.5rem;
background: #0a1628;
overflow: hidden;
}
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid #374151;
background: #0f1d32;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #374151;
margin: 0 4px;
}
.rich-editor-toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.rich-editor-toolbar button:hover { background: #1f2937; color: #d1d5db; }
.rich-editor-toolbar button.is-active { background: rgba(56, 189, 172, 0.2); color: #38bdac; }
.rich-editor-toolbar button:disabled { opacity: 0.3; cursor: not-allowed; }
.link-tag-select {
background: #0a1628;
border: 1px solid #374151;
color: #d1d5db;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
max-width: 160px;
}
.link-input-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid #374151;
background: #0f1d32;
}
.link-input {
flex: 1;
background: #0a1628;
border: 1px solid #374151;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
.link-confirm, .link-remove {
padding: 4px 10px;
border-radius: 4px;
border: none;
font-size: 12px;
cursor: pointer;
}
.link-confirm { background: #38bdac; color: white; }
.link-remove { background: #374151; color: #9ca3af; }
.rich-editor-content {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
padding: 12px 16px;
color: #e5e7eb;
font-size: 14px;
line-height: 1.7;
}
.rich-editor-content:focus { outline: none; }
.rich-editor-content h1 { font-size: 1.5em; font-weight: 700; margin: 0.8em 0 0.4em; color: white; }
.rich-editor-content h2 { font-size: 1.3em; font-weight: 600; margin: 0.7em 0 0.3em; color: white; }
.rich-editor-content h3 { font-size: 1.15em; font-weight: 600; margin: 0.6em 0 0.3em; color: white; }
.rich-editor-content p { margin: 0.4em 0; }
.rich-editor-content strong { color: white; }
.rich-editor-content code { background: #1f2937; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #38bdac; }
.rich-editor-content pre { background: #1f2937; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 0.6em 0; }
.rich-editor-content blockquote {
border-left: 3px solid #38bdac;
padding-left: 12px;
margin: 0.6em 0;
color: #9ca3af;
}
.rich-editor-content ul, .rich-editor-content ol { padding-left: 1.5em; margin: 0.4em 0; }
.rich-editor-content li { margin: 0.2em 0; }
.rich-editor-content hr { border: none; border-top: 1px solid #374151; margin: 1em 0; }
.rich-editor-content img { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
.rich-editor-content a, .rich-link { color: #38bdac; text-decoration: underline; cursor: pointer; }
.rich-editor-content table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
}
.rich-editor-content th, .rich-editor-content td {
border: 1px solid #374151;
padding: 6px 10px;
text-align: left;
}
.rich-editor-content th { background: #1f2937; font-weight: 600; }
.rich-editor-content .ProseMirror-placeholder::before {
content: attr(data-placeholder);
color: #6b7280;
float: left;
height: 0;
pointer-events: none;
}
.mention-tag {
background: rgba(56, 189, 172, 0.15);
color: #38bdac;
border-radius: 4px;
padding: 1px 4px;
font-weight: 500;
}
.mention-popup {
position: fixed;
z-index: 9999;
background: #1a2638;
border: 1px solid #374151;
border-radius: 8px;
padding: 4px;
min-width: 180px;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.mention-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
color: #d1d5db;
font-size: 13px;
}
.mention-item:hover, .mention-item.is-selected {
background: rgba(56, 189, 172, 0.15);
color: #38bdac;
}
.mention-name { font-weight: 500; }
.mention-id { font-size: 11px; color: #6b7280; }
.bubble-menu {
display: flex;
gap: 2px;
background: #1a2638;
border: 1px solid #374151;
border-radius: 6px;
padding: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.bubble-menu button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 4px;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
}
.bubble-menu button:hover { background: #1f2937; color: #d1d5db; }
.bubble-menu button.is-active { color: #38bdac; }

View File

@@ -0,0 +1,335 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
import {
Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Image as ImageIcon, Link as LinkIcon,
Table as TableIcon, Undo, Redo, Minus,
} from 'lucide-react'
export interface PersonItem {
id: string
name: string
label?: string
}
export interface LinkTagItem {
id: string
label: string
url: string
type: 'url' | 'miniprogram' | 'ckb'
appId?: string
pagePath?: string
}
export interface RichEditorRef {
getHTML: () => string
getMarkdown: () => string
}
interface RichEditorProps {
content: string
onChange: (html: string) => void
onImageUpload?: (file: File) => Promise<string>
persons?: PersonItem[]
linkTags?: LinkTagItem[]
placeholder?: string
className?: string
}
function htmlToMarkdown(html: string): string {
if (!html) return ''
let md = html
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~')
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~')
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![]($1)')
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
md = md.replace(/<\/?[uo]l[^>]*>/gi, '\n')
md = md.replace(/<br\s*\/?>/gi, '\n')
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
md = md.replace(/<hr\s*\/?>/gi, '---\n\n')
md = md.replace(/<span[^>]*data-type="mention"[^>]*data-id="([^"]*)"[^>]*>@([^<]*)<\/span>/gi, '@$2')
md = md.replace(/<span[^>]*data-type="linkTag"[^>]*data-url="([^"]*)"[^>]*>#([^<]*)<\/span>/gi, '#[$2]($1)')
md = md.replace(/<[^>]+>/g, '')
md = md.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
md = md.replace(/\n{3,}/g, '\n\n')
return md.trim()
}
function markdownToHtml(md: string): string {
if (!md) return ''
if (md.startsWith('<') && md.includes('</')) return md
let html = md
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
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, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
html = html.replace(/^> (.+)$/gm, '<blockquote><p>$1</p></blockquote>')
html = html.replace(/^---$/gm, '<hr />')
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
const lines = html.split('\n')
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (/^<(?:h[1-6]|blockquote|hr|li|ul|ol|table|img)/.test(trimmed)) {
result.push(trimmed)
} else {
result.push(`<p>${trimmed}</p>`)
}
}
return result.join('')
}
// 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),
render: () => {
let popup: HTMLDivElement | null = null
let selectedIndex = 0
let items: PersonItem[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let command: ((p: { id: string; label: string }) => void) | null = null
const update = () => {
if (!popup) return
popup.innerHTML = items.map((item, i) =>
`<div class="mention-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
<span class="mention-name">@${item.name}</span>
<span class="mention-id">${item.label || item.id}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-index') || '0')
if (command && items[idx]) command({ id: items[idx].id, label: items[idx].name })
})
})
}
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStart: (props: any) => {
popup = document.createElement('div')
popup.className = 'mention-popup'
document.body.appendChild(popup)
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate: (props: any) => {
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect && popup) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'ArrowUp') { selectedIndex = Math.max(0, selectedIndex - 1); update(); return true }
if (props.event.key === 'ArrowDown') { selectedIndex = Math.min(items.length - 1, selectedIndex + 1); update(); return true }
if (props.event.key === 'Enter') { if (command && items[selectedIndex]) command({ id: items[selectedIndex].id, label: items[selectedIndex].name }); return true }
if (props.event.key === 'Escape') { popup?.remove(); popup = null; return true }
return false
},
onExit: () => { popup?.remove(); popup = null },
}
},
})
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
onImageUpload,
persons = [],
linkTags = [],
placeholder = '开始编辑内容...',
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({ inline: true, allowBase64: true }),
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),
}),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: true }),
TableRow, TableCell, TableHeader,
],
content: initialContent.current,
onUpdate: ({ editor: ed }) => {
onChange(ed.getHTML())
},
editorProps: {
attributes: { class: 'rich-editor-content' },
},
})
useImperativeHandle(ref, () => ({
getHTML: () => editor?.getHTML() || '',
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),
}))
useEffect(() => {
if (editor && content !== editor.getHTML()) {
const html = markdownToHtml(content)
if (html !== editor.getHTML()) {
editor.commands.setContent(html)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content])
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onImageUpload) {
const url = await onImageUpload(file)
if (url) editor.chain().focus().setImage({ src: url }).run()
} else {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') editor.chain().focus().setImage({ src: reader.result }).run()
}
reader.readAsDataURL(file)
}
e.target.value = ''
}, [editor, onImageUpload])
const insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
editor.chain().focus().insertContent({
type: 'text',
marks: [{ type: 'link', attrs: { href: tag.url, target: '_blank' } }],
text: `#${tag.label}`,
}).run()
}, [editor])
const addLink = useCallback(() => {
if (!editor || !linkUrl) return
editor.chain().focus().setLink({ href: linkUrl }).run()
setLinkUrl('')
setShowLinkInput(false)
}, [editor, linkUrl])
if (!editor) return null
return (
<div className={`rich-editor-wrapper ${className || ''}`}>
<div className="rich-editor-toolbar">
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'is-active' : ''} type="button"><Bold className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'is-active' : ''} type="button"><Italic className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive('strike') ? 'is-active' : ''} type="button"><Strikethrough className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleCode().run()} className={editor.isActive('code') ? 'is-active' : ''} type="button"><Code className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} type="button"><Heading1 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} type="button"><Heading2 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} type="button"><Heading3 className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'is-active' : ''} type="button"><List className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'is-active' : ''} type="button"><ListOrdered className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleBlockquote().run()} className={editor.isActive('blockquote') ? 'is-active' : ''} type="button"><Quote className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()} type="button"><Minus className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} type="button"><ImageIcon className="w-4 h-4" /></button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button"><LinkIcon className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} type="button"><TableIcon className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} type="button"><Undo className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} type="button"><Redo className="w-4 h-4" /></button>
</div>
{linkTags.length > 0 && (
<>
<div className="toolbar-divider" />
<div className="toolbar-group">
<select
className="link-tag-select"
onChange={(e) => {
const tag = linkTags.find(t => t.id === e.target.value)
if (tag) insertLinkTag(tag)
e.target.value = ''
}}
defaultValue=""
>
<option value="" disabled># </option>
{linkTags.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
</>
)}
</div>
{showLinkInput && (
<div className="link-input-bar">
<input
type="url"
placeholder="输入链接地址..."
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addLink()}
className="link-input"
/>
<button onClick={addLink} className="link-confirm" type="button"></button>
<button onClick={() => { editor.chain().focus().unsetLink().run(); setShowLinkInput(false) }} className="link-remove" type="button"></button>
</div>
)}
<EditorContent editor={editor} />
</div>
)
})
RichEditor.displayName = 'RichEditor'
export default RichEditor

View File

@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import {
User,
Phone,
@@ -28,6 +29,7 @@ import {
Search,
CheckCircle2,
Crown,
Key,
Navigation,
Smartphone,
} from 'lucide-react'
@@ -110,6 +112,16 @@ export function UserDetailModal({
const [editTags, setEditTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
// 修改密码
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
// 设成超级个体VIP
const [vipForm, setVipForm] = useState({ isVip: false, vipExpireDate: '', vipRole: '', vipName: '', vipProject: '', vipContact: '', vipBio: '' })
const [vipRoles, setVipRoles] = useState<{ id: number; name: string }[]>([])
const [vipSaving, setVipSaving] = useState(false)
// 用户资料完善(神射手)
const [sssLoading, setSssLoading] = useState(false)
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
@@ -128,7 +140,12 @@ export function UserDetailModal({
setSssError(null)
setBatchIngestResult(null)
setCkbWechatOwner('')
setNewPassword('')
setConfirmPassword('')
loadUserDetail()
get<{ success?: boolean; data?: { id: number; name: string }[] }>('/api/db/vip-roles').then((r) => {
if (r?.success && (r as { data?: { id: number; name: string }[] }).data) setVipRoles((r as { data: { id: number; name: string }[] }).data)
}).catch(() => {})
}
}, [open, userId])
@@ -152,6 +169,15 @@ export function UserDetailModal({
} catch {
setEditTags([])
}
setVipForm({
isVip: !!(u.isVip ?? false),
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
vipRole: String(u.vipRole ?? ''),
vipName: String(u.vipName ?? ''),
vipProject: String(u.vipProject ?? ''),
vipContact: String(u.vipContact ?? ''),
vipBio: String(u.vipBio ?? ''),
})
}
// 行为轨迹(用户旅程)
try {
@@ -228,6 +254,40 @@ export function UserDetailModal({
const removeTag = (tag: string) => setEditTags(editTags.filter((t) => t !== tag))
async function handleSavePassword() {
if (!user) return
if (!newPassword) { alert('请输入新密码'); return }
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
if (newPassword.length < 6) { alert('密码至少 6 位'); return }
setPasswordSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, password: newPassword })
if (data?.success) { alert('修改成功'); setNewPassword(''); setConfirmPassword('') }
else alert('修改失败: ' + (data?.error || ''))
} catch { alert('修改失败') } finally { setPasswordSaving(false) }
}
async function handleSaveVip() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { alert('开启 VIP 请填写有效到期日'); return }
setVipSaving(true)
try {
const payload = {
id: user.id,
isVip: vipForm.isVip,
vipExpireDate: vipForm.isVip ? vipForm.vipExpireDate : undefined,
vipRole: vipForm.vipRole || undefined,
vipName: vipForm.vipName || undefined,
vipProject: vipForm.vipProject || undefined,
vipContact: vipForm.vipContact || undefined,
vipBio: vipForm.vipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) { alert('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else alert('保存失败: ' + (data?.error || ''))
} catch { alert('保存失败') } finally { setVipSaving(false) }
}
// 用户资料完善查询(支持多维度)
async function handleSSSQuery() {
if (!sssQueryPhone && !sssQueryOpenId && !sssQueryWechatId) {
@@ -469,7 +529,98 @@ export function UserDetailModal({
</p>
</div>
</div>
{/* VIP 信息 */}
{/* 快捷操作:修改密码 & 设成超级个体 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg border border-gray-700/50">
<div className="flex items-center gap-2 mb-3">
<Key className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium"></span>
</div>
<div className="space-y-2">
<Input
type="password"
className="bg-[#162840] border-gray-700 text-white"
placeholder="新密码至少6位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
type="password"
className="bg-[#162840] border-gray-700 text-white"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button
size="sm"
onClick={handleSavePassword}
disabled={passwordSaving || !newPassword || !confirmPassword}
className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/40"
>
{passwordSaving ? '保存中...' : '确认修改'}
</Button>
</div>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium"></span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-gray-400 text-sm">VIP </Label>
<Switch
checked={vipForm.isVip}
onCheckedChange={(c) => setVipForm((f) => ({ ...f, isVip: c }))}
/>
</div>
{vipForm.isVip && (
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="date"
className="bg-[#162840] border-gray-700 text-white text-sm"
value={vipForm.vipExpireDate}
onChange={(e) => setVipForm((f) => ({ ...f, vipExpireDate: e.target.value }))}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<select
className="w-full bg-[#162840] border border-gray-700 text-white rounded px-2 py-1.5 text-sm"
value={vipForm.vipRole}
onChange={(e) => setVipForm((f) => ({ ...f, vipRole: e.target.value }))}
>
<option value=""></option>
{vipRoles.map((r) => (
<option key={r.id} value={r.name}>{r.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white text-sm"
placeholder="创业老板排行展示名"
value={vipForm.vipName}
onChange={(e) => setVipForm((f) => ({ ...f, vipName: e.target.value }))}
/>
</div>
<Button
size="sm"
onClick={handleSaveVip}
disabled={vipSaving}
className="bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 border border-amber-500/40"
>
{vipSaving ? '保存中...' : '保存 VIP'}
</Button>
</div>
</div>
</div>
{/* VIP 信息(只读展示) */}
{user.isVip && (
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">

View File

@@ -10,6 +10,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
import '@/components/RichEditor.css'
import {
Select,
SelectContent,
@@ -34,13 +36,14 @@ import {
RefreshCw,
Link2,
Plus,
Image as ImageIcon,
Search,
Trophy,
ChevronLeft,
ChevronRight as ChevronRightIcon,
Pin,
Star,
Hash,
ExternalLink,
} from 'lucide-react'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
@@ -101,6 +104,7 @@ interface SectionOrder {
interface EditingSection {
id: string
originalId?: string
title: string
price: number
content?: string
@@ -183,8 +187,6 @@ export function ContentPage() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
const [isSearching, setIsSearching] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const imageInputRef = useRef<HTMLInputElement>(null)
const [newSection, setNewSection] = useState({
id: '',
@@ -219,6 +221,11 @@ export function ContentPage() {
const [previewPercent, setPreviewPercent] = useState(20)
const [previewPercentLoading, setPreviewPercentLoading] = useState(false)
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const [persons, setPersons] = useState<PersonItem[]>([])
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '' })
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
const richEditorRef = useRef<RichEditorRef>(null)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
@@ -366,6 +373,20 @@ export function ContentPage() {
} catch { /* keep default */ } finally { setPinnedLoading(false) }
}, [])
const loadPersons = useCallback(async () => {
try {
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string }[] }>('/api/db/persons')
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label })))
} catch { /* ignore */ }
}, [])
const loadLinkTags = useCallback(async () => {
try {
const data = await get<{ success?: boolean; linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] }>('/api/db/link-tags')
if (data?.success && data.linkTags) setLinkTags(data.linkTags.map(t => ({ id: t.tagId, label: t.label, url: t.url, type: t.type as 'url' | 'miniprogram', appId: t.appId, pagePath: t.pagePath })))
} catch { /* ignore */ }
}, [])
const handleTogglePin = async (sectionId: string) => {
const next = pinnedSectionIds.includes(sectionId)
? pinnedSectionIds.filter((id) => id !== sectionId)
@@ -406,7 +427,7 @@ export function ContentPage() {
} catch { alert('保存失败') } finally { setPreviewPercentSaving(false) }
}
useEffect(() => { loadPinnedSections(); loadPreviewPercent() }, [loadPinnedSections, loadPreviewPercent])
useEffect(() => { loadPinnedSections(); loadPreviewPercent(); loadPersons(); loadLinkTags() }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags])
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
@@ -435,6 +456,7 @@ export function ContentPage() {
const sec = data.section as { isNew?: boolean }
setEditingSection({
id: section.id,
originalId: section.id,
title: data.section.title ?? section.title,
price: data.section.price ?? section.price,
content: data.section.content ?? '',
@@ -447,6 +469,7 @@ export function ContentPage() {
} else {
setEditingSection({
id: section.id,
originalId: section.id,
title: section.title,
price: section.price,
content: '',
@@ -488,8 +511,11 @@ export function ContentPage() {
for (const pattern of titlePatterns) content = content.replace(pattern, '')
content = content.replace(/^\s*\n+/, '').trim()
const originalId = editingSection.originalId || editingSection.id
const idChanged = editingSection.id !== originalId
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: editingSection.id,
id: originalId,
...(idChanged ? { newId: editingSection.id } : {}),
title: editingSection.title,
price: editingSection.isFree ? 0 : editingSection.price,
content,
@@ -498,8 +524,9 @@ export function ContentPage() {
hotScore: editingSection.hotScore,
saveToFile: true,
})
if (editingSection.isPinned !== pinnedSectionIds.includes(editingSection.id)) {
await handleTogglePin(editingSection.id)
const effectiveId = idChanged ? editingSection.id : originalId
if (editingSection.isPinned !== pinnedSectionIds.includes(effectiveId)) {
await handleTogglePin(effectiveId)
}
if (res && (res as { success?: boolean }).success !== false) {
alert(`已保存章节: ${editingSection.title}`)
@@ -809,37 +836,6 @@ export function ContentPage() {
}
}
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingImage(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-images')
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, credentials: 'include' })
const data = await res.json()
if (data?.success && data?.data?.url) {
const imageMarkdown = `![${file.name}](${data.data.url})`
if (editingSection) {
setEditingSection({
...editingSection,
content: (editingSection.content || '') + '\n\n' + imageMarkdown,
})
}
alert(`图片上传成功: ${data.data.url}`)
} else {
alert('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
alert('上传失败')
} finally {
setUploadingImage(false)
if (imageInputRef.current) imageInputRef.current.value = ''
}
}
const handleSearch = async () => {
if (!searchQuery.trim()) return
setIsSearching(true)
@@ -1229,8 +1225,27 @@ export function ContentPage() {
<tbody>
{sectionOrdersModal.orders.map((o) => (
<tr key={o.id ?? o.orderSn ?? ''} className="border-b border-gray-700/50">
<td className="py-2 pr-2 text-gray-300">{o.orderSn ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">{o.userId ?? o.openId ?? '-'}</td>
<td className="py-2 pr-2">
<button
className="text-blue-400 hover:text-blue-300 hover:underline text-left truncate max-w-[180px] block"
title={`查看订单 ${o.orderSn}`}
onClick={() => window.open(`/orders?search=${o.orderSn ?? o.id ?? ''}`, '_blank')}
>
{o.orderSn ? (o.orderSn.length > 16 ? o.orderSn.slice(0, 8) + '...' + o.orderSn.slice(-6) : o.orderSn) : '-'}
</button>
</td>
<td className="py-2 pr-2">
<button
className="text-[#38bdac] hover:text-[#2da396] hover:underline text-left truncate max-w-[140px] block"
title={`查看用户 ${o.userId ?? o.openId ?? ''}`}
onClick={() => window.open(`/users?search=${o.userId ?? o.openId ?? ''}`, '_blank')}
>
{(() => {
const uid = o.userId ?? o.openId ?? '-'
return uid.length > 12 ? uid.slice(0, 6) + '...' + uid.slice(-4) : uid
})()}
</button>
</td>
<td className="py-2 pr-2 text-gray-300">¥{o.amount ?? 0}</td>
<td className="py-2 pr-2 text-gray-300">{o.status ?? '-'}</td>
<td className="py-2 pr-2 text-gray-500">{o.payTime ?? o.createdAt ?? '-'}</td>
@@ -1496,45 +1511,44 @@ export function ContentPage() {
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-gray-300"> (Markdown格式)</Label>
<div className="flex gap-2">
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={uploadingImage}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{uploadingImage ? <RefreshCw className="w-4 h-4 mr-1 animate-spin" /> : <ImageIcon className="w-4 h-4 mr-1" />}
</Button>
</div>
</div>
<Label className="text-gray-300"> @链接AI人物 #</Label>
{isLoadingContent ? (
<div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[400px] font-mono text-sm placeholder:text-gray-500"
placeholder="此处输入章节内容支持Markdown格式..."
value={editingSection.content}
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
<RichEditor
ref={richEditorRef}
content={editingSection.content || ''}
onChange={(html) => setEditingSection({ ...editingSection, content: html })}
onImageUpload={async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-images')
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
persons={persons}
linkTags={linkTags}
placeholder="开始编辑内容... 输入 @ 可链接AI人物工具栏可插入 #链接标签"
/>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 px-6 py-4 border-t border-gray-700/50">
{editingSection && (
<Button
variant="outline"
onClick={() => handleShowSectionOrders({ id: editingSection.id, title: editingSection.title, price: editingSection.price })}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent mr-auto"
>
<BookOpen className="w-4 h-4 mr-2" />
</Button>
)}
<Button
variant="outline"
onClick={() => setEditingSection(null)}
@@ -1574,6 +1588,10 @@ export function ContentPage() {
<Search className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="link-ai" className="data-[state=active]:bg-purple-500/20 data-[state=active]:text-purple-400 text-gray-400">
<Link2 className="w-4 h-4 mr-2" />
AI
</TabsTrigger>
</TabsList>
<TabsContent value="chapters" className="space-y-4">
@@ -1684,9 +1702,10 @@ export function ContentPage() {
}
>
<div className="flex items-center justify-between">
<div>
<span className="text-[#38bdac] font-mono text-xs mr-2">{result.id}</span>
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-mono text-xs">{result.id}</span>
<span className="text-white">{result.title}</span>
{pinnedSectionIds.includes(result.id) && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
</div>
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
@@ -1787,7 +1806,7 @@ export function ContentPage() {
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{rankingPageSections.map((s, idx) => {
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
@@ -1822,10 +1841,10 @@ export function ContentPage() {
variant="ghost"
size="sm"
className="text-gray-500 hover:text-[#38bdac] h-6 px-1"
onClick={() => handleShowSectionOrders({ id: s.id, title: s.title, price: s.price })}
title="付款记录"
onClick={() => handleReadSection({ id: s.id, title: s.title, price: s.price, filePath: '' })}
title="编辑文章"
>
<BookOpen className="w-3 h-3" />
<Edit3 className="w-3 h-3" />
</Button>
</div>
</div>
@@ -1837,6 +1856,171 @@ export function ContentPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="link-ai" className="space-y-4">
{/* AI列表@人物) */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="pb-3">
<CardTitle className="text-white text-base flex items-center gap-2">
<span className="text-[#38bdac] text-lg font-bold">@</span>
AI列表 @
</CardTitle>
<p className="text-xs text-gray-500 mt-1"> @人物</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-end flex-wrap">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"> *</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 卡若" value={newPerson.name} onChange={e => setNewPerson({ ...newPerson, name: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="自动生成" value={newPerson.personId} onChange={e => setNewPerson({ ...newPerson, personId: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs">/</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="如 超级个体" value={newPerson.label} onChange={e => setNewPerson({ ...newPerson, label: e.target.value })} />
</div>
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-8" onClick={async () => {
if (!newPerson.name) return alert('名称必填')
const payload = { ...newPerson }
if (!payload.personId) payload.personId = newPerson.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)
await post('/api/db/persons', payload)
setNewPerson({ personId: '', name: '', label: '' })
loadPersons()
}}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{persons.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
<div className="flex items-center gap-3 text-sm">
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
</div>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
await del(`/api/db/persons?personId=${p.id}`)
loadPersons()
}}>
<X className="w-3 h-3" />
</Button>
</div>
))}
{persons.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">AI人物 @链接</div>}
</div>
</CardContent>
</Card>
{/* #链接标签管理 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="pb-3">
<CardTitle className="text-white text-base flex items-center gap-2">
<Hash className="w-4 h-4 text-amber-400" />
# //
</CardTitle>
<p className="text-xs text-gray-500 mt-1"> # </p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-end flex-wrap">
<div className="space-y-1">
<Label className="text-gray-400 text-xs">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-24" placeholder="如 team01" value={newLinkTag.tagId} onChange={e => setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 神仙团队" value={newLinkTag.label} onChange={e => setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Select value={newLinkTag.type} onValueChange={v => setNewLinkTag({ ...newLinkTag, type: v as 'url' | 'miniprogram' | 'ckb' })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs">
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序AppID'}
</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-44" placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : 'wx...'} value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId} onChange={e => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
}} />
</div>
{newLinkTag.type === 'miniprogram' && (
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
</div>
)}
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
if (!newLinkTag.tagId || !newLinkTag.label) return alert('标签ID和显示文字必填')
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
loadLinkTags()
}}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{linkTags.map(t => (
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
<div className="flex items-center gap-3 text-sm">
<span className="text-amber-400 font-bold text-base">#{t.label}</span>
<Badge variant="secondary" className={`text-[10px] ${t.type === 'ckb' ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-gray-700 text-gray-300'}`}>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
</Badge>
<a href={t.url} target="_blank" rel="noreferrer" className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1">
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</div>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
await del(`/api/db/link-tags?tagId=${t.id}`)
loadLinkTags()
}}>
<X className="w-3 h-3" />
</Button>
</div>
))}
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">使 # </div>}
</div>
</CardContent>
</Card>
{/* 存客宝绑定配置 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="pb-3">
<CardTitle className="text-white text-base flex items-center gap-2">
<Settings2 className="w-4 h-4 text-green-400" />
</CardTitle>
<p className="text-xs text-gray-500 mt-1"> API @人物 # </p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-400 text-xs"> API </Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8" placeholder="https://ckbapi.quwanzhi.com" defaultValue="https://ckbapi.quwanzhi.com" readOnly />
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8" placeholder="创业实验-内容引流" defaultValue="创业实验-内容引流" readOnly />
</div>
</div>
<p className="text-xs text-gray-500"> <button className="text-[#38bdac] hover:underline" onClick={() => window.open('/match', '_blank')}> </button></p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>

View File

@@ -3,6 +3,7 @@ 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 { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
interface OrderRow {
id: string
@@ -62,9 +63,9 @@ export function DashboardPage() {
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [totalMatches, setTotalMatches] = useState(0)
const [matchRevenue, setMatchRevenue] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
async function loadData() {
setIsLoading(true)
@@ -77,8 +78,6 @@ export function DashboardPage() {
setPaidOrderCount(data.paidOrderCount ?? 0)
setTotalRevenue(data.totalRevenue ?? 0)
setConversionRate(data.conversionRate ?? 0)
setTotalMatches(data.totalMatches ?? 0)
setMatchRevenue(data.matchRevenue ?? 0)
setPurchases(data.recentOrders ?? [])
setUsers(data.newUsers ?? [])
return
@@ -205,22 +204,6 @@ export function DashboardPage() {
bg: 'bg-orange-500/20',
link: '/distribution',
},
{
title: '匹配次数',
value: String(totalMatches),
icon: Users,
color: 'text-cyan-400',
bg: 'bg-cyan-500/20',
link: '/find-partner',
},
{
title: '匹配收益',
value: `¥${(matchRevenue ?? 0).toFixed(2)}`,
icon: TrendingUp,
color: 'text-pink-400',
bg: 'bg-pink-500/20',
link: '/find-partner',
},
]
return (
@@ -320,16 +303,24 @@ export function DashboardPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-gray-300">{buyer}</span>
<button
type="button"
onClick={() => { if (p.userId) { setDetailUserId(p.userId); setShowDetailModal(true) } }}
className="text-sm text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{buyer}
</button>
<span className="text-gray-600">·</span>
<span className="text-sm font-medium text-white truncate">
{product.title}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
{product.subtitle}
</span>
{product.subtitle && product.subtitle !== '章节购买' && (
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
{product.subtitle}
</span>
)}
<span>
{new Date(p.createdAt || 0).toLocaleString('zh-CN', {
month: '2-digit',
@@ -384,9 +375,13 @@ export function DashboardPage() {
{u.nickname?.charAt(0) || '?'}
</div>
<div>
<p className="text-sm font-medium text-white">
<button
type="button"
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{u.nickname || '匿名用户'}
</p>
</button>
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
</div>
</div>
@@ -404,6 +399,13 @@ export function DashboardPage() {
</CardContent>
</Card>
</div>
<UserDetailModal
open={showDetailModal}
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
userId={detailUserId}
onUserUpdated={loadData}
/>
</div>
)
}

View File

@@ -27,13 +27,11 @@ import {
UserPlus,
Trash2,
Edit3,
Key,
Save,
X,
RefreshCw,
Users,
Eye,
Crown,
Plus,
BookOpen,
Settings,
@@ -43,9 +41,9 @@ import {
ArrowUpDown,
ChevronDown,
ChevronUp,
Crown,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { SetVipModal } from '@/components/modules/user/SetVipModal'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
@@ -127,10 +125,7 @@ export function UsersPage() {
// 弹框
const [showUserModal, setShowUserModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [showReferralsModal, setShowReferralsModal] = useState(false)
const [referralsData, setReferralsData] = useState<{ referrals?: unknown[]; stats?: Record<string, unknown> }>({ referrals: [], stats: {} })
@@ -138,8 +133,6 @@ export function UsersPage() {
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [showSetVipModal, setShowSetVipModal] = useState(false)
const [selectedUserForVip, setSelectedUserForVip] = useState<User | null>(null)
const [formData, setFormData] = useState({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false })
// ===== 规则管理 =====
@@ -271,8 +264,6 @@ export function UsersPage() {
} catch { alert('保存失败') } finally { setIsSaving(false) }
}
const handleChangePassword = (user: User) => { setEditingUser(user); setNewPassword(''); setConfirmPassword(''); setShowPasswordModal(true) }
async function handleViewReferrals(user: User) {
setSelectedUserForReferrals(user); setShowReferralsModal(true); setReferralsLoading(true)
try {
@@ -282,18 +273,6 @@ export function UsersPage() {
} catch { setReferralsData({ referrals: [], stats: {} }) } finally { setReferralsLoading(false) }
}
async function handleSavePassword() {
if (!newPassword) { alert('请输入新密码'); return }
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
if (newPassword.length < 6) { alert('密码至少6位'); return }
setIsSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser?.id, password: newPassword })
if (data?.success) { alert('修改成功'); setShowPasswordModal(false) }
else alert('修改失败: ' + (data?.error || ''))
} catch { alert('修改失败') } finally { setIsSaving(false) }
}
// ===== 规则管理 =====
const loadRules = useCallback(async () => {
setRulesLoading(true)
@@ -491,7 +470,13 @@ export function UsersPage() {
</div>
<div>
<div className="flex items-center gap-1.5">
<p className="font-medium text-white">{user.nickname}</p>
<button
type="button"
onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }}
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{user.nickname}
</button>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs"></Badge>}
{user.openId && !user.id?.startsWith('user_') && <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs"></Badge>}
</div>
@@ -545,10 +530,8 @@ export function UsersPage() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserForVip(user); setShowSetVipModal(true) }} className="text-gray-400 hover:text-amber-400 hover:bg-amber-400/10" title="设置 VIP"><Crown className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="查看详情"><Eye className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleChangePassword(user)} className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10" title="修改密码"><Key className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="用户详情"><Eye className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑用户"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(user.id)} title="删除"><Trash2 className="w-4 h-4" /></Button>
</div>
</TableCell>
@@ -784,22 +767,6 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* 修改密码 */}
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Key className="w-5 h-5 text-[#38bdac]" /></DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-[#0a1628] rounded-lg p-3"><p className="text-gray-400 text-sm">{editingUser?.nickname}</p><p className="text-gray-400 text-sm">{editingUser?.phone}</p></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入新密码 (至少6位)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder="请再次输入" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
<Button onClick={handleSavePassword} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">{isSaving ? '保存中...' : '确认修改'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加/编辑规则 */}
<Dialog open={showRuleModal} onOpenChange={setShowRuleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
@@ -871,7 +838,6 @@ export function UsersPage() {
</Dialog>
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
<SetVipModal open={showSetVipModal} onClose={() => { setShowSetVipModal(false); setSelectedUserForVip(null) }} userId={selectedUserForVip?.id ?? null} userNickname={selectedUserForVip?.nickname} onSaved={loadUsers} />
</div>
)
}