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

774
soul-admin/dist/assets/index-DALKpE2i.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-kWgz5G0L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jScXDwzJ.css">
<script type="module" crossorigin src="/assets/index-DALKpE2i.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-z9Ciz_la.css">
</head>
<body>
<div id="root"></div>

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>
)
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}

View File

@@ -51,6 +51,15 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
log.Printf("database: user_rules migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Chapter{}); err != nil {
log.Printf("database: chapters migrate (hot_score_override) warning: %v", err)
}
seedDefaultRules(db)
log.Println("database: connected")
return nil

View File

@@ -332,6 +332,7 @@ func DBBookAction(c *gin.Context) {
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
NewID string `json:"newId"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
@@ -444,6 +445,9 @@ func DBBookAction(c *gin.Context) {
if body.HotScore != nil {
updates["hot_score_override"] = *body.HotScore
}
if body.NewID != "" && body.NewID != body.ID {
updates["id"] = body.NewID
}
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})

View File

@@ -0,0 +1,130 @@
package handler
import (
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
func DBPersonList(c *gin.Context) {
var rows []model.Person
if err := database.DB().Order("name ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "persons": rows})
}
func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Label string `json:"label"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Name == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
return
}
if body.PersonID == "" {
body.PersonID = fmt.Sprintf("%s_%d", body.Name, time.Now().UnixMilli())
}
db := database.DB()
var existing model.Person
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Label = body.Label
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
return
}
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label}
if err := db.Create(&p).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "person": p})
}
func DBPersonDelete(c *gin.Context) {
pid := c.Query("personId")
if pid == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
return
}
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func DBLinkTagList(c *gin.Context) {
var rows []model.LinkTag
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
}
func DBLinkTagSave(c *gin.Context) {
var body struct {
TagID string `json:"tagId"`
Label string `json:"label"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
PagePath string `json:"pagePath"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.TagID == "" || body.Label == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 和 label 必填"})
return
}
if body.Type == "" {
body.Type = "url"
}
db := database.DB()
var existing model.LinkTag
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
existing.Label = body.Label
existing.URL = body.URL
existing.Type = body.Type
existing.AppID = body.AppID
existing.PagePath = body.PagePath
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
}
func DBLinkTagDelete(c *gin.Context) {
tid := c.Query("tagId")
if tid == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 tagId"})
return
}
if err := database.DB().Where("tag_id = ?", tid).Delete(&model.LinkTag{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,28 @@
package model
import "time"
type Person struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Name string `gorm:"column:name;size:100" json:"name"`
Label string `gorm:"column:label;size:200" json:"label"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Person) TableName() string { return "persons" }
type LinkTag struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
Label string `gorm:"column:label;size:200" json:"label"`
URL string `gorm:"column:url;size:500" json:"url"`
Type string `gorm:"column:type;size:20" json:"type"`
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (LinkTag) TableName() string { return "link_tags" }

View File

@@ -153,6 +153,12 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/persons", handler.DBPersonList)
db.POST("/persons", handler.DBPersonSave)
db.DELETE("/persons", handler.DBPersonDelete)
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/mentors", handler.DBMentorsList)
db.POST("/mentors", handler.DBMentorsAction)
db.PUT("/mentors", handler.DBMentorsAction)

File diff suppressed because it is too large Load Diff

View File

@@ -465,3 +465,89 @@ vercel --prod
| AI 获客数据首页重构 | 已完成 | 数据统计页拆为「找伙伴数据 / AI 获客数据」,已提交线索和有联系方式可点开 |
| 本地测试数据插入能力 | 已完成 | 新增 /api/db/match-records/test资源对接/团队招募页可一键插入测试记录 |
| Dashboard 增加匹配概览 | 已完成 | 首页数据概览新增「匹配次数」「匹配收益」 |
---
## 十四、内容管理深度优化2026-03-07 ~ 2026-03-09
### 14.1 排名算法可配置化03-07
| 任务 | 状态 | 说明 |
|------|------|------|
| 排名算法权重可编辑 | 已完成 | 阅读/新度/付款三权重可在后台直接修改,权重存 system_config |
| 数据填充(点击量/付款数) | 已完成 | reading_progress + orders 表关联,排行榜显示点击量、付款数、热度 |
| 批量移动修复 | 已完成 | 修复「移动失败缺少ID」问题SectionIds 正确传递 |
| 2026每日派对干货板块一致性 | 已完成 | 新建/删除/编辑功能与其他板块保持一致 |
| 后台整体优化 | 已完成 | 界面美化、交互优化、暗色主题深度定制 |
### 14.2 内容管理五项修改03-08 第一批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 删除「钩子设置」Tab → 新增「内容排行榜」Tab | 已完成 | 排行榜按热度排序分页10节/页,显示点击数据 |
| 拖拽排序与后端同步修复 | 已完成 | 章节树拖拽排序结果正确写入数据库 |
| 未付费预览比例可配置 | 已完成 | system_config 存 unpaid_preview_percent后台可修改 |
| 排名权重可编辑 + 精选推荐/首页置顶 | 已完成 | 置顶用 Star 图标标识pinned_section_ids 存配置 |
| 合并预览/编辑按钮 + 章节ID可编辑 | 已完成 | 单按钮打开编辑弹窗ID 字段可直接修改 |
### 14.3 内容管理五项修改03-08 第二批)
| 任务 | 状态 | 说明 |
|------|------|------|
| Tab 顺序调整 | 已完成 | 章节管理 → 内容排行榜 → 内容搜索 |
| 置顶状态全局显示Star图标 | 已完成 | 章节树、排行榜、搜索结果均显示 Star |
| 排名积分逻辑细化 | 已完成 | 最近更新30分递减/阅读量20分递减/付款数20分递减 + 手动覆盖 |
| 富文本编辑器升级 | 已完成 | TipTap 编辑器,支持格式化/图片/表格/@提及/#链接标签 |
| 人物列表 + 链接标签管理 | 已完成 | persons/link_tags 表 CRUD后台管理界面 |
### 14.4 内容管理三项修改03-09 第三批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 排行榜操作改为「编辑文章」 | 已完成 | 原「付款记录」按钮移入编辑弹窗底部 |
| 章节ID修改确保保存成功 | 已完成 | 前端 originalId 机制 + 后端 newId 字段支持 |
| 付款记录用户ID/订单ID可点击跳转 | 已完成 | 用户名截短显示,点击跳转用户详情/订单详情 |
### 14.5 链接AI Tab03-09 第四批)
| 任务 | 状态 | 说明 |
|------|------|------|
| 「主人公」Tab → 「链接AI」Tab | 已完成 | 链接人与事AI列表 + 链接标签管理 |
| 人物ID改为可选 | 已完成 | 名称必填ID自动生成后端兼容 |
| 链接标签新增「存客宝」类型 | 已完成 | 支持 url/miniprogram/ckb 三种类型 |
| 存客宝绑定配置面板 | 已完成 | 显示API地址和绑定计划跳转存客宝工作台 |
| 预填充数据 | 已完成 | 卡若/南风/远志/老墨/荷总/永平 + 神仙团队/Soul派对房/飞书中台/超级个体 |
---
## 十五、存客宝集成技术方案
### 15.1 概述
存客宝CKB是第三方获客工具通过 API 上报线索到微信生态中实现自动加好友/拉群。本项目在以下场景集成:
1. **找伙伴功能**:匹配成功 → 上报存客宝场景 → 自动加好友
2. **内容管理「链接AI」**:文章内 @人物 / #标签 → 点击跳转存客宝链接 → 进入流量池
### 15.2 核心 API
| 接口 | 方法 | 地址 | 说明 |
|------|------|------|------|
| 场景获客 | POST | `https://ckbapi.quwanzhi.com/v1/api/scenarios` | 上报线索(手机号/微信号等) |
| 线索查询 | GET | `/v1/api/lead/query` | 按手机号/微信号查询状态(待开发) |
| 批量统计 | GET | `/v1/api/lead/stats` | 时间段内线索统计(待确认) |
| 自动加好友 | POST | `/v1/api/lead/add-friend` | 匹配后自动发起好友申请(待确认) |
### 15.3 后台配置
- **存客宝场景配置**`找伙伴 → 存客宝工作台` 中管理 apiUrl/apiKey/source/tags 等
- **内容链接绑定**`内容管理 → 链接AI → 存客宝绑定` 面板配置计划绑定
- **链接标签类型 = ckb**link_tags 表 type 支持 `url`/`miniprogram`/`ckb`
### 15.4 数据库表
- `persons`AI人物列表person_id, name, label
- `link_tags`链接标签tag_id, label, url, type[url/miniprogram/ckb], app_id, page_path
- `system_config`存客宝相关配置ckb_config.routes, ckb_api_key 等)
详细协作需求见 `存客宝协作需求.md`

View File

@@ -0,0 +1,14 @@
完成这个所有的这三个需求,然后确保每一个填空的所有的数值都是可以保存的,然后你不着急帮我部署到服务器,先本地帮我做好把所有功能检测清晰可用
功能三付款记录
![](images/2026-03-09-05-25-52.png)
付款季度这边的话,显示用户的名称 ID 太长了,那点击之后是可以直接跳转到这个 ID 里面的,那点击订单也是直接可以跳转到这个相应的订单里面。这个是那个内容管理里面的这个付款记录这个地方。
功能二
![](images/2026-03-09-05-22-19.png)
![](images/2026-03-09-05-23-53.png)
![](images/2026-03-09-05-24-19.png)内蒙古排行榜这边的话,操作的话应该是点击的是编辑文章,而不是付款记录,这应该放到这个内容的这个编辑里面。应该放到内容的编辑里面,章节的这个编辑里面。然后这个确保,并且确保这个章节 ID 修改成功。直接所有的那个参数修改,在数据库里面修改成功,这个一定要帮我确保清楚。所有的参数都可以正常确保成功,然后这个编辑器里面的话,那个增加这个井号跟 ad 的功能,而且要凸显,然后并且这个功能在咱们的这个内容管理的 API 接口要体现,嗯。链接和小程序都是# 并且 ad 跟那个链接和小程序点击是直接跳转过去的,然后它的颜色是突出的,那在文章里面显示的时候颜色是轻微突出。
功能一
![](images/2026-03-09-05-18-28.png)
表哥列记标签的这么一个功能放到这个,这一块的功能放到章节管理跟内容管理,还有那个 ad第四个标签就是 ad 那个。你的撸管。爱的相关人物的这么一个功能就相关。主人公功能就叫做主人公功能,把这个功能帮我列出来,弄到这个。如何弄到这个里面来?就是一个独立的一个功能,并且帮我把里面的这个人物列表编辑器内可以艾特的这个功能也放到编辑器里面,直接可以选择我,我直接可以选择相关已经配置好的那个已经配置好的人物跟配置好的一个链接,然后你现在帮我添加几个的这个链接,就是里面有提到的。比如卡洛,我们数据库里面的用户卡洛南风,对吧?还有远志这些已经提到的,并且那个超级个体如果成为超级个体,这个利表单也直接在这个里面数据帮我填写好已经是超级个体了,然后链接跟标题标签,比如神仙团队这些有提到的链接跟标签,你帮我写出来,帮我填写进去。
编辑器副文本的那个编辑器里面,你把这两个 @ 跟人物列表的跟那个链接的。链接的这个是加上# 号的,这么加减号或者他已经是有家眷的这个功能就直接那个写上去,嗯。

View File

@@ -0,0 +1,10 @@
不用直接部署,直接修改,用中文回复我
需求一:
需要存克宝这篇提供的整链接下全站的需要存克宝这篇提供的相关的那个功能跟技术也写上去写到那个文档里面然后把这个相关的这个内容这几天这两天。7号、8号、9号修改了内容跟完成的内容都到这个项目管理的进度里面。然后把整个这个项目上传到 Git Hub 上面gitea 跟 GitHub 上面
i git Hub 上面把这个打开这个落地推荐表的页面以及存课宝的页面发给我。直接打开,直接在浏览器打开这两个 GitHub 上面上传成功之后这两个页面
功能一
![](images/2026-03-09-05-35-24.png)
![](images/2026-03-09-05-36-51.png)这个主人公,这一个的话改一下,他这个就是链接的一一个功能,链接的一个功能。就是 ad 跟减号链接。链接人和事。链接 AI 跟这个名字应该改一下,链接 AI 跟事情。链接AI链接 AI 主人公,改成链接 AI。然后这里的话主人公这边的话输入之后这个 ID 是可选项的,然后把那些默认 VIP 就默认到这个里面,这个变成一个标签,并且在那个副文本框是可以直接调取的。然后在小程序的前方也可以直接显示。然后这一个的话在主人公这个是属于 AI 列表,不叫主人公列表,这边就叫 AI 列表,然后这里面就写的是相应的等人物 ID然后以及配置后端需要有一个配置一个存客宝哪一个存客宝的那个手机要么就是存克保指定的那个手。它配置跟纯克宝这边的那个计划是绑定的捆绑的计划帮我把这个绑定的计划帮我艾特完之后就是像我们首页链接卡洛一样点击就直接可以那个添加过去进到这个流量池里面来。然后这个点击的这个链接的这一个内容的流量词的列表有多少个人点击链接它这里面有一个列表出来和链接出来这个列表底下然后把这个上面像 VIP 跟那个几个刚刚提到的,上方提到的几个都优化迭代一下,把这个加上去。

View File

@@ -1,39 +0,0 @@
# 需求文档标题
> 创建日期YYYY-MM-DD
> 文档格式Markdown支持图片粘贴 + 预览)
---
## 一、背景与目标
(在此输入文字,可直接粘贴图片)
---
## 二、功能点
### 2.1 功能一
(文字 + 可粘贴的截图、原型图)
示例图片引用:`![描述](./images/截图1.png)`
### 2.2 功能二
---
## 三、补充说明
(可继续粘贴图片和文字)
---
## 使用提示
- **粘贴图片**:在 Cursor 中安装「Paste Image」扩展后直接 Ctrl+V / Cmd+V 即可将剪贴板图片保存到 `images/` 并自动插入引用
- **预览**`Cmd+Shift+V` 或右侧「Open Preview」查看排版效果

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB