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:
196
soul-admin/src/components/RichEditor.css
Normal file
196
soul-admin/src/components/RichEditor.css
Normal 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; }
|
||||
335
soul-admin/src/components/RichEditor.tsx
Normal file
335
soul-admin/src/components/RichEditor.tsx
Normal 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, '')
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '')
|
||||
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = ``
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user