更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。
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, type Editor } 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 }: { editor: Editor }) => {
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,10 +10,11 @@ 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,
|
||||
History,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
BookOpen,
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
Save,
|
||||
X,
|
||||
Tag,
|
||||
Zap,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Crown,
|
||||
Key,
|
||||
Navigation,
|
||||
Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post } from '@/api/client'
|
||||
|
||||
@@ -60,6 +68,12 @@ interface UserDetail {
|
||||
vipProject?: string | null
|
||||
vipContact?: string | null
|
||||
vipBio?: string | null
|
||||
vipRole?: string | null
|
||||
// 扩展字段
|
||||
mbti?: string
|
||||
region?: string
|
||||
industry?: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
interface UserTrack {
|
||||
@@ -72,6 +86,14 @@ interface UserTrack {
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
interface ShensheShouData {
|
||||
rfm_score?: number
|
||||
user_level?: string
|
||||
tags?: string[]
|
||||
last_active?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export function UserDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -90,8 +112,41 @@ 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)
|
||||
const [sssError, setSssError] = useState<string | null>(null)
|
||||
const [sssQueryPhone, setSssQueryPhone] = useState('')
|
||||
const [sssQueryWechatId, setSssQueryWechatId] = useState('')
|
||||
const [sssQueryOpenId, setSssQueryOpenId] = useState('')
|
||||
const [batchIngestLoading, setBatchIngestLoading] = useState(false)
|
||||
const [batchIngestResult, setBatchIngestResult] = useState<Record<string, unknown> | null>(null)
|
||||
const [ckbWechatOwner, setCkbWechatOwner] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open && userId) loadUserDetail()
|
||||
if (open && userId) {
|
||||
setActiveTab('info')
|
||||
setSssData(null)
|
||||
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])
|
||||
|
||||
async function loadUserDetail() {
|
||||
@@ -106,24 +161,38 @@ export function UserDetailModal({
|
||||
setUser(u)
|
||||
setEditPhone(u.phone || '')
|
||||
setEditNickname(u.nickname || '')
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
setSssQueryPhone(u.phone || '')
|
||||
setSssQueryWechatId(u.wechatId || '')
|
||||
setSssQueryOpenId(u.openId || '')
|
||||
try {
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
} 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 {
|
||||
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
|
||||
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
|
||||
)
|
||||
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
|
||||
} catch {
|
||||
setTracks([])
|
||||
}
|
||||
} catch { setTracks([]) }
|
||||
// 关系链路
|
||||
try {
|
||||
const refData = await get<{ success?: boolean; referrals?: unknown[] }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
|
||||
} catch {
|
||||
setReferrals([])
|
||||
}
|
||||
} catch { setReferrals([]) }
|
||||
} catch (e) {
|
||||
console.error('Load user detail error:', e)
|
||||
} finally {
|
||||
@@ -132,10 +201,7 @@ export function UserDetailModal({
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) {
|
||||
alert('用户未绑定手机号,无法同步')
|
||||
return
|
||||
}
|
||||
if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return }
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
@@ -143,12 +209,8 @@ export function UserDetailModal({
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('同步成功')
|
||||
loadUserDetail()
|
||||
} else {
|
||||
alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
if (data?.success) { alert('同步成功'); loadUserDetail() }
|
||||
else alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
@@ -190,8 +252,115 @@ export function UserDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setEditTags(editTags.filter((t) => t !== tag))
|
||||
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) {
|
||||
setSssError('请至少输入手机号、微信号或 OpenID 中的一项')
|
||||
return
|
||||
}
|
||||
setSssLoading(true)
|
||||
setSssError(null)
|
||||
setSssData(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (sssQueryPhone) params.set('phone', sssQueryPhone)
|
||||
if (sssQueryOpenId) params.set('openId', sssQueryOpenId)
|
||||
if (sssQueryWechatId) params.set('wechatId', sssQueryWechatId)
|
||||
const data = await get<{ success?: boolean; data?: ShensheShouData; error?: string }>(
|
||||
`/api/admin/shensheshou/query?${params}`,
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
setSssData(data.data)
|
||||
// 自动回填到用户信息
|
||||
if (user) await handleSSSEnrich(data.data)
|
||||
}
|
||||
else setSssError(data?.error || '未查询到数据,该用户可能未在神射手收录')
|
||||
} catch (e) {
|
||||
console.error('SSS query error:', e)
|
||||
setSssError('请求失败,请检查神射手接口配置')
|
||||
} finally {
|
||||
setSssLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询后自动回填用户基础信息
|
||||
async function handleSSSEnrich(_sssResult?: ShensheShouData) {
|
||||
if (!user) return
|
||||
try {
|
||||
await post('/api/admin/shensheshou/enrich', {
|
||||
userId: user.id,
|
||||
phone: sssQueryPhone || user.phone || '',
|
||||
openId: sssQueryOpenId || user.openId || '',
|
||||
wechatId: sssQueryWechatId || user.wechatId || '',
|
||||
})
|
||||
loadUserDetail()
|
||||
} catch (e) {
|
||||
console.error('SSS enrich error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 神射手 - 将当前用户信息推送/同步到神射手
|
||||
async function handleSSSIngest() {
|
||||
if (!user) return
|
||||
setBatchIngestLoading(true)
|
||||
setBatchIngestResult(null)
|
||||
try {
|
||||
const payload = {
|
||||
users: [{
|
||||
phone: user.phone || '',
|
||||
name: user.nickname || '',
|
||||
openId: user.openId || '',
|
||||
tags: editTags,
|
||||
}]
|
||||
}
|
||||
const data = await post<{ success?: boolean; data?: Record<string, unknown>; error?: string }>(
|
||||
'/api/admin/shensheshou/ingest',
|
||||
payload,
|
||||
)
|
||||
if (data?.success && data.data) setBatchIngestResult(data.data)
|
||||
else setBatchIngestResult({ error: data?.error || '推送失败' })
|
||||
} catch (e) {
|
||||
console.error('SSS ingest error:', e)
|
||||
setBatchIngestResult({ error: '请求失败' })
|
||||
} finally {
|
||||
setBatchIngestLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
@@ -204,8 +373,10 @@ export function UserDetailModal({
|
||||
share: Link2,
|
||||
bind_phone: Phone,
|
||||
bind_wechat: MessageCircle,
|
||||
fill_profile: Tag,
|
||||
visit_page: Navigation,
|
||||
}
|
||||
const Icon = icons[action] || History
|
||||
const Icon = icons[action] || Clock
|
||||
return <Icon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
@@ -218,9 +389,8 @@ export function UserDetailModal({
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[#38bdac]" />
|
||||
用户详情
|
||||
{user?.phone && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>
|
||||
)}
|
||||
{user?.phone && <Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>}
|
||||
{user?.isVip && <Badge className="bg-amber-500/20 text-amber-400 border-0">VIP</Badge>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -230,55 +400,66 @@ export function UserDetailModal({
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
|
||||
<div className="flex flex-col h-[75vh]">
|
||||
{/* 用户头部信息 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-3">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac] shrink-0">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
user.nickname?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
|
||||
{user.isAdmin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>
|
||||
)}
|
||||
{user.hasFullBook && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>
|
||||
)}
|
||||
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>}
|
||||
{user.hasFullBook && <Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>}
|
||||
{user.vipRole && <Badge className="bg-amber-500/20 text-amber-400 border-0">{user.vipRole}</Badge>}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
|
||||
{user.wechatId && ` · 💬 ${user.wechatId}`}
|
||||
{user.mbti && ` · ${user.mbti}`}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
ID: {user.id} · 推广码: {user.referralCode ?? '-'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<p className="text-gray-600 text-xs">ID: {user.id.slice(0, 16)}…</p>
|
||||
{user.referralCode && (
|
||||
<p className="text-xs">
|
||||
<span className="text-gray-500">推广码:</span>
|
||||
<code className="text-[#38bdac] bg-[#38bdac]/10 px-1.5 py-0.5 rounded">{user.referralCode}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[#38bdac] font-bold text-lg">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<p className="text-gray-500 text-xs">累计收益</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-4">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-3 flex-wrap h-auto gap-1">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
基础信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
标签体系
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
行为轨迹
|
||||
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
<Navigation className="w-3 h-3 mr-1" />
|
||||
用户旅程
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
关系链路
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shensheshou" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-xs">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
用户资料完善
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ===== 基础信息 ===== */}
|
||||
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -300,6 +481,36 @@ export function UserDetailModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 详细信息展示 */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{user.openId && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">微信 OpenID</p>
|
||||
<p className="text-gray-300 font-mono text-xs break-all">{user.openId}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.region && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-gray-500 text-xs">地区</p>
|
||||
<p className="text-white">{user.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.industry && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">行业</p>
|
||||
<p className="text-white">{user.industry}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.position && (
|
||||
<div className="p-3 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">职位</p>
|
||||
<p className="text-white">{user.position}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">推荐人数</p>
|
||||
@@ -318,6 +529,149 @@ export function UserDetailModal({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作:修改密码 & 设成超级个体 */}
|
||||
<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">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-white font-medium">VIP 信息</span>
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">{user.vipRole || 'VIP'}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{user.vipName && <div><span className="text-gray-500">展示名:</span><span className="text-white">{user.vipName}</span></div>}
|
||||
{user.vipProject && <div><span className="text-gray-500">项目:</span><span className="text-white">{user.vipProject}</span></div>}
|
||||
{user.vipContact && <div><span className="text-gray-500">联系方式:</span><span className="text-white">{user.vipContact}</span></div>}
|
||||
{user.vipExpireDate && <div><span className="text-gray-500">到期时间:</span><span className="text-white">{new Date(user.vipExpireDate).toLocaleDateString()}</span></div>}
|
||||
</div>
|
||||
{user.vipBio && <p className="text-gray-400 text-sm mt-2">{user.vipBio}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* 微信归属(存客宝) */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Smartphone className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">微信归属</span>
|
||||
<span className="text-gray-500 text-xs">该用户归属在哪个微信号下</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="输入归属微信号(如 wxid_xxxx)"
|
||||
value={ckbWechatOwner}
|
||||
onChange={(e) => setCkbWechatOwner(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!ckbWechatOwner || !user) return
|
||||
try {
|
||||
await put('/api/db/users', { id: user.id, wechatId: ckbWechatOwner })
|
||||
alert('已保存微信归属')
|
||||
loadUserDetail()
|
||||
} catch { alert('保存失败') }
|
||||
}}
|
||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 border border-purple-500/30 shrink-0"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" /> 保存
|
||||
</Button>
|
||||
</div>
|
||||
{user.wechatId && (
|
||||
<p className="text-gray-500 text-xs mt-2">当前归属:<span className="text-purple-400">{user.wechatId}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 存客宝同步 */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -331,13 +685,9 @@ export function UserDetailModal({
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...
|
||||
</>
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> 同步数据
|
||||
</>
|
||||
<><RefreshCw className="w-4 h-4 mr-1" /> 同步数据</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -360,54 +710,134 @@ export function UserDetailModal({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 标签体系 ===== */}
|
||||
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">系统标签</span>
|
||||
<span className="text-white font-medium">用户标签</span>
|
||||
<span className="text-gray-500 text-xs">基于《一场 Soul 的创业实验》维度打标</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/* 自动打标说明 */}
|
||||
<div className="mb-3 p-2.5 bg-[#38bdac]/5 border border-[#38bdac]/20 rounded-lg flex items-center gap-2 text-xs text-gray-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||||
命中的标签自动高亮 · 系统根据行为轨迹和填写资料自动打标 · 手动点击补充或取消
|
||||
</div>
|
||||
{/* 预设标签分类 */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{[
|
||||
{
|
||||
category: '身份类型',
|
||||
tags: ['创业者', '打工人', '自由职业', '学生', '投资人', '合伙人'],
|
||||
},
|
||||
{
|
||||
category: '行业背景',
|
||||
tags: ['电商', '内容', '传统行业', '科技/AI', '金融', '教育', '餐饮'],
|
||||
},
|
||||
{
|
||||
category: '痛点标签',
|
||||
tags: ['找资源', '找方向', '找合伙人', '想赚钱', '想学习', '找情感出口'],
|
||||
},
|
||||
{
|
||||
category: '付费意愿',
|
||||
tags: ['高意向', '已付费', '观望中', '薅羊毛'],
|
||||
},
|
||||
{
|
||||
category: 'MBTI',
|
||||
tags: ['ENTJ', 'INTJ', 'ENFP', 'INFP', 'ENTP', 'INTP', 'ESTJ', 'ISFJ'],
|
||||
},
|
||||
].map((group) => (
|
||||
<div key={group.category}>
|
||||
<p className="text-gray-500 text-xs mb-1.5">{group.category}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editTags.includes(tag)) removeTag(tag)
|
||||
else setEditTags([...editTags, tag])
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-xs border transition-all ${
|
||||
editTags.includes(tag)
|
||||
? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]'
|
||||
: 'bg-transparent border-gray-700 text-gray-500 hover:border-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{editTags.includes(tag) ? '✓ ' : ''}{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-500 text-sm">暂无标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="添加新标签"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">
|
||||
添加
|
||||
</Button>
|
||||
<div className="border-t border-gray-700/50 pt-3">
|
||||
<p className="text-gray-500 text-xs mb-2">已选标签</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3 min-h-[32px]">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-600 text-sm">暂未选择标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="自定义标签(回车添加)"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 存客宝标签 */}
|
||||
{user.ckbTags && (
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Tag className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">存客宝标签</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(typeof user.ckbTags === 'string' ? user.ckbTags.split(',') : []).map((tag, i) => (
|
||||
<Badge key={i} className="bg-purple-500/20 text-purple-400 border-0">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tracks" className="flex-1 overflow-auto">
|
||||
{/* ===== 用户旅程(原行为轨迹)===== */}
|
||||
<TabsContent value="journey" className="flex-1 overflow-auto">
|
||||
<div className="mb-3 p-3 bg-[#0a1628] rounded-lg flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-gray-400 text-sm">记录用户从注册到付费的完整行动路径,共 {tracks.length} 条记录</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tracks.length > 0 ? (
|
||||
tracks.map((track) => (
|
||||
tracks.map((track, idx) => (
|
||||
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
</div>
|
||||
{idx < tracks.length - 1 && (
|
||||
<div className="w-0.5 h-4 bg-gray-700/50 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{track.actionLabel}</span>
|
||||
{track.chapterTitle && (
|
||||
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
<p className="text-gray-500 text-xs mt-0.5">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
|
||||
</p>
|
||||
@@ -416,13 +846,15 @@ export function UserDetailModal({
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无行为轨迹</p>
|
||||
<Navigation className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无用户旅程记录</p>
|
||||
<p className="text-gray-600 text-sm mt-1">当用户浏览章节、购买或完善信息时会自动记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 关系链路 ===== */}
|
||||
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -432,7 +864,7 @@ export function UserDetailModal({
|
||||
</div>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">共 {referrals.length} 人</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
<div className="space-y-2 max-h-[250px] overflow-y-auto">
|
||||
{referrals.length > 0 ? (
|
||||
referrals.map((ref: unknown, i: number) => {
|
||||
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
|
||||
@@ -461,9 +893,142 @@ export function UserDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 用户资料完善 ===== */}
|
||||
<TabsContent value="shensheshou" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-5 h-5 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">用户资料完善</span>
|
||||
<span className="text-gray-500 text-xs">通过多维度查询神射手数据,自动回填用户基础信息</span>
|
||||
</div>
|
||||
{/* 多维度查询输入 */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div>
|
||||
<Label className="text-gray-500 text-xs mb-1 block">手机号</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="11位手机号"
|
||||
value={sssQueryPhone}
|
||||
onChange={(e) => setSssQueryPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-500 text-xs mb-1 block">微信号</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="微信 ID"
|
||||
value={sssQueryWechatId}
|
||||
onChange={(e) => setSssQueryWechatId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-gray-500 text-xs mb-1 block">微信 OpenID</Label>
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white"
|
||||
placeholder="openid_xxxx(自动填入)"
|
||||
value={sssQueryOpenId}
|
||||
onChange={(e) => setSssQueryOpenId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSSSQuery}
|
||||
disabled={sssLoading}
|
||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{sssLoading ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 查询并自动回填中...</>
|
||||
) : (
|
||||
<><Search className="w-4 h-4 mr-1" /> 查询并自动完善用户资料</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-gray-600 text-xs mt-2">查询成功后,神射手返回的标签将自动同步到该用户</p>
|
||||
{sssError && (
|
||||
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{sssError}
|
||||
</div>
|
||||
)}
|
||||
{sssData && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">神射手 RFM 分</p>
|
||||
<p className="text-2xl font-bold text-[#38bdac]">{sssData.rfm_score ?? '-'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-1">用户等级</p>
|
||||
<p className="text-2xl font-bold text-white">{sssData.user_level ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{sssData.tags && sssData.tags.length > 0 && (
|
||||
<div className="p-3 bg-[#162840] rounded-lg">
|
||||
<p className="text-gray-500 text-xs mb-2">神射手标签</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sssData.tags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/20">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sssData.last_active && (
|
||||
<div className="text-sm text-gray-500">
|
||||
最近活跃:{sssData.last_active}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 推送到神射手 */}
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">推送用户数据到神射手</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs">将本用户信息(手机号、昵称、标签等)同步至神射手,自动完善用户画像</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSSSIngest}
|
||||
disabled={batchIngestLoading || !user.phone}
|
||||
variant="outline"
|
||||
className="border-purple-500/40 text-purple-400 hover:bg-purple-500/10 bg-transparent shrink-0 ml-4"
|
||||
>
|
||||
{batchIngestLoading ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 推送中</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4 mr-1" /> 推送</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!user.phone && (
|
||||
<p className="text-yellow-500/70 text-xs">⚠ 用户未绑定手机号,无法推送</p>
|
||||
)}
|
||||
{batchIngestResult && (
|
||||
<div className="mt-3 p-3 bg-[#162840] rounded-lg text-sm">
|
||||
{batchIngestResult.error ? (
|
||||
<p className="text-red-400">{String(batchIngestResult.error)}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" /> 推送成功
|
||||
</p>
|
||||
{batchIngestResult.enriched !== undefined && (
|
||||
<p className="text-gray-400">自动补全标签数:{String(batchIngestResult.new_tags_added ?? 0)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-700 mt-4">
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
||||
Reference in New Issue
Block a user