Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	.cursor/agent/软件测试/evolution/索引.md   resolved by yongxu-dev version
#	.cursor/skills/testing/SKILL.md   resolved by yongxu-dev version
#	.gitignore   resolved by yongxu-dev version
#	miniprogram/app.js   resolved by yongxu-dev version
#	miniprogram/app.json   resolved by yongxu-dev version
#	miniprogram/pages/chapters/chapters.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.wxml   resolved by yongxu-dev version
#	miniprogram/pages/match/match.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxml   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxss   resolved by yongxu-dev version
#	miniprogram/pages/read/read.js   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxml   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxss   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.js   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxml   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxss   resolved by yongxu-dev version
#	miniprogram/utils/ruleEngine.js   resolved by yongxu-dev version
#	miniprogram/utils/trackClick.js   resolved by yongxu-dev version
#	soul-admin/dist/index.html   resolved by yongxu-dev version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu-dev version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/api-docs/ApiDocsPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu-dev version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu-dev version
#	soul-api/.env.production   resolved by yongxu-dev version
#	soul-api/internal/database/database.go   resolved by yongxu-dev version
#	soul-api/internal/handler/balance.go   resolved by yongxu-dev version
#	soul-api/internal/handler/book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/ckb_open.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_person.go   resolved by yongxu-dev version
#	soul-api/internal/handler/search.go   resolved by yongxu-dev version
#	soul-api/internal/handler/upload.go   resolved by yongxu-dev version
#	soul-api/internal/router/router.go   resolved by yongxu-dev version
#	soul-api/wechat/info.log   resolved by yongxu-dev version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu-dev version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu-dev version
This commit is contained in:
Alex-larget
2026-03-17 14:23:26 +08:00
231 changed files with 14492 additions and 6576 deletions

View File

@@ -9,9 +9,12 @@ import { getAdminToken } from './auth'
/** 未设置环境变量时使用的默认 API 地址(零配置部署) */
const DEFAULT_API_BASE = 'https://soulapi.quwanzhi.com'
/** 请求超时(毫秒),避免接口无响应时一直卡在加载中 */
/** 默认请求超时(毫秒),避免接口无响应时一直卡在加载中 */
const REQUEST_TIMEOUT = 15000
/** 大请求(如保存长文章)超时 */
export const SAVE_REQUEST_TIMEOUT = 60000
const getBaseUrl = (): string => {
const url = import.meta.env.VITE_API_BASE_URL
if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '')
@@ -25,7 +28,11 @@ export function apiUrl(path: string): string {
return base ? `${base}${p}` : p
}
export type RequestInitWithBody = RequestInit & { data?: unknown }
export type RequestInitWithBody = RequestInit & {
data?: unknown
/** 自定义超时(毫秒),不传则用默认 15s */
timeout?: number
}
/**
* 发起请求。path 为与现网一致的 API 路径(如 /api/admin、/api/orders
@@ -46,8 +53,9 @@ export async function request<T = unknown>(
headers.set('Content-Type', 'application/json')
}
const body = data !== undefined && data !== null ? JSON.stringify(data) : init.body
const timeout = (init as RequestInitWithBody).timeout ?? REQUEST_TIMEOUT
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const timeoutId = setTimeout(() => controller.abort(), timeout)
const res = await fetch(url, {
...init,
headers,
@@ -59,7 +67,17 @@ export async function request<T = unknown>(
const json: T = contentType.includes('application/json')
? ((await res.json()) as T)
: (res as unknown as T)
const maybeTriggerRechargeAlert = (data: unknown) => {
const obj = data as { message?: string; error?: string }
const msg = (obj?.message || obj?.error || '').toString()
if (msg.includes('可提现金额不足') || msg.includes('可提现不足') || msg.includes('余额不足')) {
window.dispatchEvent(new CustomEvent('recharge-alert', { detail: msg }))
}
}
if (!res.ok) {
maybeTriggerRechargeAlert(json)
const err = new Error((json as { error?: string })?.error || `HTTP ${res.status}`) as Error & {
status: number
data: T
@@ -68,6 +86,7 @@ export async function request<T = unknown>(
err.data = json
throw err
}
maybeTriggerRechargeAlert(json)
return json
}
@@ -77,12 +96,12 @@ export function get<T = unknown>(path: string, init?: RequestInit): Promise<T> {
}
/** POST */
export function post<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
export function post<T = unknown>(path: string, data?: unknown, init?: RequestInitWithBody): Promise<T> {
return request<T>(path, { ...init, method: 'POST', data })
}
/** PUT */
export function put<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
export function put<T = unknown>(path: string, data?: unknown, init?: RequestInitWithBody): Promise<T> {
return request<T>(path, { ...init, method: 'PUT', data })
}

View File

@@ -0,0 +1,48 @@
/**
* 可关闭的充值告警条
* 当接口返回「可提现金额不足」「可提现不足」「余额不足」等错误时,由 client.ts 触发 recharge-alert 事件,
* 本组件监听并展示告警,提示管理员充值商户号或核对账户。
*/
import { useState, useEffect } from 'react'
import { AlertCircle, X } from 'lucide-react'
export function RechargeAlert() {
const [visible, setVisible] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<string>).detail
setMessage(detail || '可提现/余额不足,请及时充值商户号')
setVisible(true)
}
window.addEventListener('recharge-alert', handler)
return () => window.removeEventListener('recharge-alert', handler)
}, [])
if (!visible) return null
return (
<div
className="flex items-center justify-between gap-4 px-4 py-3 bg-red-900/80 border-b border-red-600/50 text-red-100"
role="alert"
>
<div className="flex items-center gap-3 min-w-0">
<AlertCircle className="w-5 h-5 shrink-0 text-red-400" />
<span className="text-sm font-medium">
{message}
<span className="ml-2 text-red-300"></span>
</span>
</div>
<button
type="button"
onClick={() => setVisible(false)}
className="shrink-0 p-1 rounded hover:bg-red-800/50 transition-colors"
aria-label="关闭告警"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -84,8 +84,8 @@
.link-remove { background: #374151; color: #9ca3af; }
.rich-editor-content {
min-height: 300px;
max-height: 500px;
min-height: 450px;
max-height: 720px;
overflow-y: auto;
padding: 12px 16px;
color: #e5e7eb;

View File

@@ -1,7 +1,6 @@
import { useEditor, EditorContent, type Editor, Node as TiptapNode, mergeAttributes } from '@tiptap/react'
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } 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'
@@ -9,14 +8,13 @@ import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHand
import {
Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Image as ImageIcon, Link as LinkIcon,
Table as TableIcon, Undo, Redo, Minus, Video, AtSign,
Table as TableIcon, Undo, Redo, Minus,
} from 'lucide-react'
export interface PersonItem {
id: string // token文章 @ 时存此值,小程序用此兑换真实密钥
personId?: string // 管理端编辑/删除用
name: string
aliases?: string // comma-separated alternative names
label?: string
ckbApiKey?: string // 存客宝真实密钥,管理端可见,不对外暴露
ckbPlanId?: number
@@ -32,7 +30,6 @@ export interface PersonItem {
export interface LinkTagItem {
id: string
label: string
aliases?: string // comma-separated alternative labels
url: string
type: 'url' | 'miniprogram' | 'ckb'
appId?: string
@@ -48,127 +45,12 @@ interface RichEditorProps {
content: string
onChange: (html: string) => void
onImageUpload?: (file: File) => Promise<string>
onVideoUpload?: (file: File) => Promise<string>
persons?: PersonItem[]
linkTags?: LinkTagItem[]
onPersonCreate?: (name: string) => Promise<PersonItem | null>
placeholder?: string
className?: string
}
function normalizeMatchKey(value?: string): string {
return (value || '').trim().toLowerCase()
}
function getPersonMatchKeys(person: PersonItem): string[] {
return [person.name, ...(person.aliases ? person.aliases.split(',') : [])]
.map(normalizeMatchKey)
.filter(Boolean)
}
function getLinkTagMatchKeys(tag: LinkTagItem): string[] {
return [tag.label, ...(tag.aliases ? tag.aliases.split(',') : [])]
.map(normalizeMatchKey)
.filter(Boolean)
}
function autoMatchMentionsAndTags(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string {
if (!html || (!persons.length && !linkTags.length) || typeof document === 'undefined') return html
const personMap = new Map<string, PersonItem>()
const linkTagMap = new Map<string, LinkTagItem>()
for (const person of persons) {
for (const key of getPersonMatchKeys(person)) {
if (!personMap.has(key)) personMap.set(key, person)
}
}
for (const tag of linkTags) {
for (const key of getLinkTagMatchKeys(tag)) {
if (!linkTagMap.has(key)) linkTagMap.set(key, tag)
}
}
const container = document.createElement('div')
container.innerHTML = html
const processTextNode = (node: Text) => {
const text = node.textContent || ''
if (!text || (!text.includes('@') && !text.includes('') && !text.includes('#'))) return
const parent = node.parentNode
if (!parent) return
const fragment = document.createDocumentFragment()
const regex = /([@][^\s@##]+|#[^\s@##]+)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const [full] = match
const index = match.index
if (index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
}
if (full.startsWith('@') || full.startsWith('')) {
const person = personMap.get(normalizeMatchKey(full.slice(1)))
if (person) {
const span = document.createElement('span')
span.setAttribute('data-type', 'mention')
span.setAttribute('data-id', person.id)
span.setAttribute('data-label', person.name)
span.className = 'mention-tag'
span.textContent = `@${person.name}`
fragment.appendChild(span)
} else {
fragment.appendChild(document.createTextNode(full))
}
} else {
const tag = linkTagMap.get(normalizeMatchKey(full.slice(1)))
if (tag) {
const span = document.createElement('span')
span.setAttribute('data-type', 'linkTag')
span.setAttribute('data-url', tag.url || '')
span.setAttribute('data-tag-type', tag.type || 'url')
span.setAttribute('data-tag-id', tag.id || '')
span.setAttribute('data-page-path', tag.pagePath || '')
span.setAttribute('data-app-id', tag.appId || '')
if (tag.type === 'miniprogram' && tag.appId) {
span.setAttribute('data-mp-key', tag.appId)
}
span.className = 'link-tag-node'
span.textContent = `#${tag.label}`
fragment.appendChild(span)
} else {
fragment.appendChild(document.createTextNode(full))
}
}
lastIndex = index + full.length
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
}
parent.replaceChild(fragment, node)
}
const walk = (node: globalThis.Node) => {
if (node.nodeType === globalThis.Node.ELEMENT_NODE) {
const el = node as HTMLElement
if (el.matches('[data-type="mention"], [data-type="linkTag"], a, code, pre, script, style')) return
Array.from(el.childNodes).forEach(walk)
return
}
if (node.nodeType === globalThis.Node.TEXT_NODE) processTextNode(node as Text)
}
Array.from(container.childNodes).forEach(walk)
return container.innerHTML
}
function htmlToMarkdown(html: string): string {
if (!html) return ''
let md = html
@@ -207,9 +89,7 @@ function markdownToHtml(md: string): string {
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
@@ -235,7 +115,7 @@ function markdownToHtml(md: string): string {
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
* 解决insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
*/
const LinkTagExtension = TiptapNode.create({
const LinkTagExtension = Node.create({
name: 'linkTag',
group: 'inline',
inline: true,
@@ -282,26 +162,15 @@ const LinkTagExtension = TiptapNode.create({
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (
personsRef: React.RefObject<PersonItem[]>,
onPersonCreateRef: React.RefObject<((name: string) => Promise<PersonItem | null>) | undefined>
): any => ({
items: ({ query }: { query: string }) => {
const persons = personsRef.current || []
const q = query.toLowerCase().trim()
const filtered = persons.filter(p => {
if (p.name.toLowerCase().includes(q) || p.id.includes(q)) return true
if (p.aliases) {
return p.aliases.split(',').some(a => a.trim().toLowerCase().includes(q))
}
return false
}).slice(0, 8)
// 当 query 有内容且无精确名称匹配时,追加「新增人物」选项
if (q.length >= 1 && !persons.some(p => p.name.toLowerCase() === q)) {
filtered.push({ id: '__new__', name: `+ 新增「${query.trim()}`, _newName: query.trim() } as PersonItem & { _newName: string })
}
return filtered
},
function escapeHtml(s: string): string {
const div = document.createElement('div')
div.textContent = s
return div.innerHTML
}
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
@@ -309,32 +178,18 @@ const MentionSuggestion = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let command: ((p: { id: string; label: string }) => void) | null = null
const selectItem = async (idx: number) => {
const item = items[idx]
if (!item || !command) return
const newItem = item as PersonItem & { _newName?: string }
if (newItem.id === '__new__' && newItem._newName && onPersonCreateRef.current) {
try {
const created = await onPersonCreateRef.current(newItem._newName)
if (created) command({ id: created.id, label: created.name })
} catch (_) { /* 创建失败,不插入 */ }
} else {
command({ id: item.id, label: item.name })
}
}
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 as PersonItem & { _newName?: string }).id === '__new__' ? '' : (item.label || item.id)}</span>
<span class="mention-name">@${escapeHtml(item.name)}</span>
<span class="mention-id">${escapeHtml(item.label || item.id)}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-index') || '0')
selectItem(idx)
if (command && items[idx]) command({ id: items[idx].id, label: items[idx].name })
})
})
}
@@ -374,7 +229,7 @@ const MentionSuggestion = (
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') { selectItem(selectedIndex); 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
},
@@ -383,47 +238,128 @@ const MentionSuggestion = (
},
})
/** 从剪贴板提取图片文件(粘贴截图、复制图片时) */
function getImageFilesFromClipboard(event: ClipboardEvent): File[] {
const files: File[] = []
const items = event.clipboardData?.items
if (!items) return files
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile()
if (file) files.push(file)
}
}
return files
}
const BASE64_IMG_RE = /src=["'](data:image\/([^;"']+);base64,([A-Za-z0-9+/=]+))["']/gi
/** 将 base64 转为 File */
function base64ToFile(b64: string, mime: string): File {
const ext = { png: '.png', jpeg: '.jpg', jpg: '.jpg', gif: '.gif', webp: '.webp' }[mime.toLowerCase()] || '.png'
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return new File([new Blob([arr], { type: `image/${mime}` })], `image${ext}`, { type: `image/${mime}` })
}
/** 将 HTML 中的 base64 图片替换为上传后的 URL支持多个、去重同图只传一次 */
async function replaceBase64ImagesInHtml(
html: string,
onImageUpload: (file: File) => Promise<string>,
): Promise<string> {
const matches = [...html.matchAll(BASE64_IMG_RE)]
if (matches.length === 0) return html
const urlCache = new Map<string, string>() // full dataURL -> 上传后的 url同图只传一次
let result = html
for (const m of matches) {
const full = m[1]
const mime = m[2]
const b64 = m[3]
let url = urlCache.get(full)
if (!url) {
try {
const file = base64ToFile(b64, mime)
url = await onImageUpload(file)
urlCache.set(full, url)
} catch (e) {
console.error('base64 图片上传失败', e)
continue
}
}
// 替换该 base64 的所有出现(同一张图可能被引用多次)
result = result.split(`src="${full}"`).join(`src="${url}"`).split(`src='${full}'`).join(`src="${url}"`)
}
return result
}
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
onImageUpload,
onVideoUpload,
persons = [],
linkTags = [],
onPersonCreate,
placeholder = '开始编辑内容...',
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const videoInputRef = useRef<HTMLInputElement>(null)
const [videoUploading, setVideoUploading] = useState(false)
const editorRef = useRef<Editor | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [imageUploading, setImageUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const initialContent = useRef(autoMatchMentionsAndTags(markdownToHtml(content), persons, linkTags))
const initialContent = useRef(markdownToHtml(content))
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const personsRef = useRef(persons)
personsRef.current = persons
const linkTagsRef = useRef(linkTags)
linkTagsRef.current = linkTags
const onPersonCreateRef = useRef(onPersonCreate)
onPersonCreateRef.current = onPersonCreate
const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
const handlePaste = useCallback(
(_view: unknown, event: ClipboardEvent) => {
const ed = editorRef.current
if (!ed || !onImageUpload) return false
// 1. 粘贴截图/复制图片:剪贴板直接有 image 文件
const imageFiles = getImageFilesFromClipboard(event)
if (imageFiles.length > 0) {
event.preventDefault()
;(async () => {
for (const file of imageFiles) {
try {
const url = await onImageUpload(file)
if (url) ed.chain().focus().setImage({ src: url }).run()
} catch (e) {
console.error('粘贴图片上传失败', e)
}
}
})()
return true
}
// 2. 粘贴 HTML含 base64 图片):复制编辑器内容再粘贴时,自动上传 base64 转为 URL
const html = event.clipboardData?.getData('text/html')
if (html && /data:image\/[^;"']+;base64,/i.test(html)) {
event.preventDefault()
const { from, to } = ed.state.selection
;(async () => {
try {
const processed = await replaceBase64ImagesInHtml(html, onImageUpload)
ed.chain().focus().insertContentAt({ from, to }, processed).run()
} catch (e) {
console.error('粘贴 HTML 内 base64 转换失败', e)
}
})()
return true
}
return false
},
[onImageUpload],
)
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({
link: { openOnClick: false, HTMLAttributes: { class: 'rich-link' } },
}),
Image.configure({ inline: true, allowBase64: true }),
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: {
...MentionSuggestion(personsRef, onPersonCreateRef),
allowedPrefixes: null,
},
suggestion: MentionSuggestion(persons),
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
@@ -432,54 +368,39 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
],
content: initialContent.current,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
const currentHtml = ed.getHTML()
const linkedHtml = autoMatchMentionsAndTags(currentHtml, personsRef.current || [], linkTagsRef.current || [])
if (linkedHtml !== currentHtml) {
ed.commands.setContent(linkedHtml, { emitUpdate: false })
onChangeRef.current(linkedHtml)
return
}
onChangeRef.current(currentHtml)
}, 300)
onChange(ed.getHTML())
},
editorProps: {
attributes: { class: 'rich-editor-content' },
handlePaste,
},
})
useEffect(() => {
editorRef.current = editor ?? null
}, [editor])
useImperativeHandle(ref, () => ({
getHTML: () => editor?.getHTML() || '',
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),
}))
useEffect(() => {
if (!editor) return
const html = autoMatchMentionsAndTags(markdownToHtml(content), personsRef.current || [], linkTagsRef.current || [])
if (html !== editor.getHTML()) {
editor.commands.setContent(html, { emitUpdate: false })
if (editor && content !== editor.getHTML()) {
const html = markdownToHtml(content)
if (html !== editor.getHTML()) {
editor.commands.setContent(html)
}
}
}, [content, editor, persons, linkTags])
// 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) {
setImageUploading(true)
setUploadProgress(10)
const progressTimer = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 15, 90))
}, 300)
try {
const url = await onImageUpload(file)
clearInterval(progressTimer)
setUploadProgress(100)
if (url) editor.chain().focus().setImage({ src: url }).run()
} finally {
clearInterval(progressTimer)
setTimeout(() => { setImageUploading(false); setUploadProgress(0) }, 500)
}
const url = await onImageUpload(file)
if (url) editor.chain().focus().setImage({ src: url }).run()
} else {
const reader = new FileReader()
reader.onload = () => {
@@ -490,37 +411,6 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
e.target.value = ''
}, [editor, onImageUpload])
const handleVideoUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onVideoUpload) {
setVideoUploading(true)
setUploadProgress(5)
const progressTimer = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 8, 90))
}, 500)
try {
const url = await onVideoUpload(file)
clearInterval(progressTimer)
setUploadProgress(100)
if (url) {
editor.chain().focus().insertContent(
`<p><video src="${url}" controls style="max-width:100%;border-radius:8px"></video></p>`
).run()
}
} finally {
clearInterval(progressTimer)
setTimeout(() => { setVideoUploading(false); setUploadProgress(0) }, 500)
}
}
e.target.value = ''
}, [editor, onVideoUpload])
const triggerMention = useCallback(() => {
if (!editor) return
editor.chain().focus().insertContent('@').run()
}, [editor])
const insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
// 通过自定义扩展节点插入,确保 data-* 属性不被 TipTap schema 丢弃
@@ -572,11 +462,8 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
<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" title="插入图片"><ImageIcon className="w-4 h-4" /></button>
<input ref={videoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" onChange={handleVideoUpload} className="hidden" />
<button onClick={() => videoInputRef.current?.click()} disabled={videoUploading || !onVideoUpload} type="button" title="插入视频" className={videoUploading ? 'opacity-50' : ''}><Video className="w-4 h-4" /></button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button" title="插入链接"><LinkIcon className="w-4 h-4" /></button>
<button onClick={triggerMention} type="button" title="@ 指定人物" className="mention-trigger-btn"><AtSign className="w-4 h-4" /></button>
<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" />
@@ -618,14 +505,6 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
<button onClick={() => { editor.chain().focus().unsetLink().run(); setShowLinkInput(false) }} className="link-remove" type="button"></button>
</div>
)}
{(imageUploading || videoUploading) && (
<div className="upload-progress-bar">
<div className="upload-progress-track">
<div className="upload-progress-fill" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="upload-progress-text">{videoUploading ? '视频' : '图片'} {uploadProgress}%</span>
</div>
)}
<EditorContent editor={editor} />
</div>
)

View File

@@ -105,6 +105,7 @@ export function UserDetailModal({
const [user, setUser] = useState<UserDetail | null>(null)
const [tracks, setTracks] = useState<UserTrack[]>([])
const [referrals, setReferrals] = useState<unknown[]>([])
const [balanceData, setBalanceData] = useState<{ balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } | null>(null)
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
const [saving, setSaving] = useState(false)
@@ -122,7 +123,12 @@ export function UserDetailModal({
// 设成超级个体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 [adjustBalanceOpen, setAdjustBalanceOpen] = useState(false)
const [adjustAmount, setAdjustAmount] = useState('')
const [adjustRemark, setAdjustRemark] = useState('')
const [adjustLoading, setAdjustLoading] = useState(false)
// 用户资料完善(神射手)
const [sssLoading, setSssLoading] = useState(false)
@@ -184,7 +190,7 @@ export function UserDetailModal({
// 行为轨迹(用户旅程)
try {
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
`/api/admin/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
)
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
} catch { setTracks([]) }
@@ -195,6 +201,13 @@ export function UserDetailModal({
)
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
} catch { setReferrals([]) }
try {
const balData = await get<{ success?: boolean; data?: { balance: number; transactions: Array<{ id: string; type: string; amount: number; orderId?: string; createdAt: string }> } }>(
`/api/admin/users/${encodeURIComponent(userId)}/balance`,
)
if (balData?.success && balData.data) setBalanceData(balData.data)
else setBalanceData(null)
} catch { setBalanceData(null) }
} catch (e) {
console.error('Load user detail error:', e)
} finally {
@@ -223,6 +236,10 @@ export function UserDetailModal({
async function handleSave() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) {
toast.error('开启 VIP 请填写有效到期日')
return
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
@@ -230,6 +247,14 @@ export function UserDetailModal({
phone: editPhone || undefined,
nickname: editNickname || undefined,
tags: JSON.stringify(editTags),
// 超级个体/VIP 相关字段一并保存
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) {
@@ -269,25 +294,27 @@ export function UserDetailModal({
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
}
async function handleSaveVip() {
async function handleAdjustBalance() {
if (!user) return
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { toast.error('开启 VIP 请填写有效到期日'); return }
setVipSaving(true)
const amt = parseFloat(adjustAmount)
if (Number.isNaN(amt) || amt === 0) { toast.error('请输入有效金额(正数增加、负数扣减)'); return }
setAdjustLoading(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 res = await post<{ success?: boolean; error?: string }>(`/api/admin/users/${user.id}/balance/adjust`, {
amount: amt,
remark: adjustRemark || undefined,
})
if (res?.success) {
toast.success('余额已调整')
setAdjustBalanceOpen(false)
setAdjustAmount('')
setAdjustRemark('')
loadUserDetail()
onUserUpdated?.()
} else {
toast.error('调整失败: ' + (res?.error || ''))
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) { toast.success('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
else toast.error('保存失败: ' + (data?.error || ''))
} catch { toast.error('保存失败') } finally { setVipSaving(false) }
} catch { toast.error('调整失败') } finally { setAdjustLoading(false) }
}
// 用户资料完善查询(支持多维度)
@@ -385,6 +412,7 @@ export function UserDetailModal({
if (!open) return null
return (
<>
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
@@ -471,7 +499,11 @@ export function UserDetailModal({
placeholder="输入手机号"
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
disabled={!!user?.phone}
/>
{user?.phone && (
<p className="text-xs text-gray-500"></p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
@@ -513,7 +545,7 @@ export function UserDetailModal({
</div>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white">{user.referralCount ?? 0}</p>
@@ -524,6 +556,22 @@ export function UserDetailModal({
¥{(user.pendingEarnings ?? 0).toFixed(2)}
</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg flex flex-col justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac]">
¥{(balanceData?.balance ?? 0).toFixed(2)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="mt-2 border-[#38bdac]/50 text-[#38bdac] hover:bg-[#38bdac]/10 text-xs"
onClick={() => { setAdjustAmount(''); setAdjustRemark(''); setAdjustBalanceOpen(true) }}
>
</Button>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-sm text-white">
@@ -610,14 +658,33 @@ export function UserDetailModal({
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 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.vipProject}
onChange={(e) => setVipForm((f) => ({ ...f, vipProject: e.target.value }))}
/>
</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.vipContact}
onChange={(e) => setVipForm((f) => ({ ...f, vipContact: e.target.value }))}
/>
</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.vipBio}
onChange={(e) => setVipForm((f) => ({ ...f, vipBio: e.target.value }))}
/>
</div>
</div>
</div>
</div>
@@ -798,20 +865,32 @@ export function UserDetailModal({
</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>
{/* 存客宝标签(与用户标签共用 ckb_tags兼容 JSON 与逗号分隔) */}
{(() => {
const raw = user.tags || user.ckbTags || ''
let arr: string[] = []
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw || '[]') : []
arr = Array.isArray(parsed) ? parsed : (typeof raw === 'string' ? raw.split(',') : [])
} catch {
arr = typeof raw === 'string' ? raw.split(',') : []
}
const tags = arr.map((t) => String(t).trim()).filter(Boolean)
if (tags.length === 0) return null
return (
<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">
{tags.map((tag, i) => (
<Badge key={i} className="bg-purple-500/20 text-purple-400 border-0">{tag}</Badge>
))}
</div>
</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>
{/* ===== 用户旅程(原行为轨迹)===== */}
@@ -1050,5 +1129,44 @@ export function UserDetailModal({
)}
</DialogContent>
</Dialog>
<Dialog open={adjustBalanceOpen} onOpenChange={setAdjustBalanceOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="正数增加,负数扣减,如 10 或 -5"
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
/>
</div>
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="如:活动补偿"
value={adjustRemark}
onChange={(e) => setAdjustRemark(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAdjustBalanceOpen(false)} className="border-gray-600 text-gray-300">
</Button>
<Button onClick={handleAdjustBalance} disabled={adjustLoading} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
{adjustLoading ? '提交中...' : '确认调整'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken, getAdminToken } from '@/api/auth'
import { RechargeAlert } from '@/components/RechargeAlert'
// 主菜单5 项平铺,按 Mycontent-temp 新规范)
const primaryMenuItems = [
@@ -35,27 +36,25 @@ export function AdminLayout() {
if (!mounted) return
setAuthChecked(false)
let cancelled = false
const token = getAdminToken()
if (!token) {
navigate('/login', { replace: true, state: { from: location.pathname } })
return () => {
cancelled = true
}
// 鉴权优化:先检查 token无 token 直接跳登录,避免无效请求
if (!getAdminToken()) {
navigate('/login', { replace: true })
return
}
get<{ success?: boolean }>('/api/admin')
.then((data) => {
if (cancelled) return
if (data?.success === true) {
if (data && (data as { success?: boolean }).success !== false) {
setAuthChecked(true)
} else {
clearAdminToken()
navigate('/login', { replace: true })
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
.catch(() => {
if (!cancelled) {
clearAdminToken()
navigate('/login', { replace: true })
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
return () => {
@@ -110,20 +109,22 @@ export function AdminLayout() {
</Link>
)
})}
<div className="pt-4 mt-4 border-t border-gray-700/50">
<Link
to="/settings"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
location.pathname === '/settings'
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<Settings className="w-5 h-5 shrink-0" />
<span className="text-sm"></span>
</Link>
</div>
</nav>
<div className="p-4 border-t border-gray-700/50 space-y-1">
<Link
to="/settings"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
location.pathname === '/settings'
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<Settings className="w-5 h-5 shrink-0" />
<span className="text-sm"></span>
</Link>
<button
type="button"
onClick={handleLogout}
@@ -135,8 +136,9 @@ export function AdminLayout() {
</div>
</div>
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0">
<div className="w-full min-w-[1024px] min-h-full">
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0 flex flex-col">
<RechargeAlert />
<div className="w-full min-w-[1024px] min-h-full flex-1">
<Outlet />
</div>
</div>

View File

@@ -1,6 +1,7 @@
/**
* API 接口完整文档页 - 内容管理相关接口
* 深色主题,与 Admin 整体风格一致
* 来源new-soul/soul-admin
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key } from 'lucide-react'

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,10 @@ export function DashboardPage() {
const formatOrderProduct = (p: OrderRow) => {
const type = p.productType || ''
const desc = p.description || ''
if (type === 'balance_recharge') {
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
return { title: `余额充值 ¥${amount}`, subtitle: '余额充值' }
}
if (desc) {
if (type === 'section' && desc.includes('章节')) {
if (desc.includes('-')) {
@@ -241,6 +245,9 @@ export function DashboardPage() {
if (type === 'fullbook' || desc.includes('全书')) {
return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
}
if (type === 'vip' || desc.includes('VIP')) {
return { title: '超级个体开通费用', subtitle: '超级个体' }
}
if (type === 'match' || desc.includes('伙伴')) {
return { title: '找伙伴匹配', subtitle: '功能服务' }
}
@@ -251,6 +258,7 @@ export function DashboardPage() {
}
if (type === 'section') return { title: `章节 ${p.productId || ''}`, subtitle: '单章购买' }
if (type === 'fullbook') return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
if (type === 'vip') return { title: '超级个体开通费用', subtitle: '超级个体' }
if (type === 'match') return { title: '找伙伴匹配', subtitle: '功能服务' }
return { title: '未知商品', subtitle: type || '其他' }
}

View File

@@ -6,6 +6,7 @@ import {
TrendingUp,
Clock,
Wallet,
Gift,
Search,
RefreshCw,
CheckCircle,
@@ -109,6 +110,7 @@ interface Order {
bookName?: string
chapterTitle?: string
sectionTitle?: string
description?: string
amount: number
status: string
paymentMethod?: string
@@ -123,7 +125,7 @@ interface Order {
}
export function DistributionPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'>(
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'giftPay' | 'settings'>(
'overview',
)
const [orders, setOrders] = useState<Order[]>([])
@@ -145,6 +147,25 @@ export function DistributionPage() {
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
const [giftPayRequests, setGiftPayRequests] = useState<Array<{
id: string
requestSn: string
initiatorUserId: string
initiatorNick?: string
productType: string
productId: string
amount: number
description: string
status: string
payerUserId?: string
payerNick?: string
orderId?: string
expireAt: string
createdAt: string
}>>([])
const [giftPayPage, setGiftPayPage] = useState(1)
const [giftPayTotal, setGiftPayTotal] = useState(0)
const [giftPayStatusFilter, setGiftPayStatusFilter] = useState('')
useEffect(() => {
loadInitialData()
@@ -162,7 +183,10 @@ export function DistributionPage() {
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
loadTabData(activeTab, true)
}
}, [page, pageSize, statusFilter, searchTerm])
if (activeTab === 'giftPay') {
loadTabData('giftPay', true)
}
}, [page, pageSize, statusFilter, searchTerm, giftPayPage, giftPayStatusFilter])
async function loadInitialData() {
setError(null)
@@ -285,6 +309,30 @@ export function DistributionPage() {
}
break
}
case 'giftPay': {
try {
const params = new URLSearchParams({
page: String(giftPayPage),
pageSize: '20',
...(giftPayStatusFilter && { status: giftPayStatusFilter }),
})
const res = await get<{ success?: boolean; data?: typeof giftPayRequests; total?: number }>(
`/api/admin/gift-pay-requests?${params}`,
)
if (res?.success && res.data) {
setGiftPayRequests(res.data)
setGiftPayTotal(res.total ?? res.data.length)
} else {
setGiftPayRequests([])
setGiftPayTotal(0)
}
} catch (e) {
console.error(e)
setError('加载代付请求失败')
setGiftPayRequests([])
}
break
}
}
setLoadedTabs((prev) => new Set(prev).add(tab))
} catch (e) {
@@ -471,6 +519,7 @@ export function DistributionPage() {
{ key: 'orders', label: '订单管理', icon: DollarSign },
{ key: 'bindings', label: '绑定管理', icon: Link2 },
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
{ key: 'giftPay', label: '代付请求', icon: Gift },
{ key: 'settings', label: '推广设置', icon: Settings },
].map((tab) => (
<button
@@ -480,6 +529,10 @@ export function DistributionPage() {
setActiveTab(tab.key as typeof activeTab)
setStatusFilter('all')
setSearchTerm('')
if (tab.key === 'giftPay') {
setGiftPayStatusFilter('')
setGiftPayPage(1)
}
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
@@ -839,6 +892,14 @@ export function DistributionPage() {
<p className="text-white text-sm">
{(() => {
const type = order.productType || order.type
const desc = order.description || ''
const pid = String(order.productId || order.sectionId || '')
const isVip = type === 'vip' || desc.includes('VIP') || desc.toLowerCase().includes('vip') || pid.toLowerCase().includes('vip')
if (type === 'balance_recharge') {
const amount = typeof order.amount === 'number' ? order.amount.toFixed(2) : parseFloat(String(order.amount || '0')).toFixed(2)
return `余额充值 ¥${amount}`
}
if (isVip) return '超级个体开通费用'
if (type === 'fullbook')
return `${order.bookName || '《底层逻辑》'} - 全本`
if (type === 'match') return '匹配次数购买'
@@ -848,6 +909,11 @@ export function DistributionPage() {
<p className="text-gray-500 text-xs">
{(() => {
const type = order.productType || order.type
const desc = order.description || ''
const pid = String(order.productId || order.sectionId || '')
const isVip = type === 'vip' || desc.includes('VIP') || desc.toLowerCase().includes('vip') || pid.toLowerCase().includes('vip')
if (type === 'balance_recharge') return '余额充值'
if (isVip) return '超级个体'
if (type === 'fullbook') return '全书解锁'
if (type === 'match') return '功能权益'
return order.chapterTitle || '单章购买'
@@ -861,9 +927,11 @@ export function DistributionPage() {
<td className="p-4 text-gray-300">
{order.paymentMethod === 'wechat'
? '微信支付'
: order.paymentMethod === 'alipay'
? '支付'
: order.paymentMethod || '微信支付'}
: order.paymentMethod === 'balance'
? '余额支付'
: order.paymentMethod === 'alipay'
? '支付宝'
: order.paymentMethod || '微信支付'}
</td>
<td className="p-4">
{order.status === 'refunded' ? (
@@ -1199,6 +1267,97 @@ export function DistributionPage() {
</Card>
</div>
)}
{activeTab === 'giftPay' && (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="text-white"></CardTitle>
<div className="flex gap-2 items-center">
<select
className="bg-[#0a1628] border border-gray-700 text-white rounded px-3 py-1.5 text-sm"
value={giftPayStatusFilter}
onChange={(e) => { setGiftPayStatusFilter(e.target.value); setGiftPayPage(1) }}
>
<option value=""></option>
<option value="pending"></option>
<option value="paid"></option>
<option value="cancelled"></option>
<option value="expired"></option>
</select>
<Button size="sm" variant="outline" onClick={() => loadTabData('giftPay', true)} className="border-gray-600 text-gray-300">
<RefreshCw className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700/50">
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400">/</th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{giftPayRequests.map((r) => (
<tr key={r.id} className="hover:bg-[#0a1628]">
<td className="p-4 font-mono text-xs text-gray-400">{r.requestSn}</td>
<td className="p-4">
<p className="text-white text-sm">{r.initiatorNick || r.initiatorUserId}</p>
</td>
<td className="p-4">
<p className="text-white">{r.productType} · ¥{r.amount.toFixed(2)}</p>
{r.description && <p className="text-gray-500 text-xs">{r.description}</p>}
</td>
<td className="p-4 text-gray-400">{r.payerNick || (r.payerUserId ? r.payerUserId : '-')}</td>
<td className="p-4">
<Badge
className={
r.status === 'paid'
? 'bg-green-500/20 text-green-400 border-0'
: r.status === 'pending'
? 'bg-amber-500/20 text-amber-400 border-0'
: 'bg-gray-500/20 text-gray-400 border-0'
}
>
{r.status === 'paid' ? '已支付' : r.status === 'pending' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
</Badge>
</td>
<td className="p-4 text-gray-400 text-sm">
{r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{giftPayRequests.length === 0 && !loading && (
<p className="text-center py-8 text-gray-500"></p>
)}
{giftPayTotal > 20 && (
<div className="mt-4 flex justify-center">
<Pagination
page={giftPayPage}
totalPages={Math.ceil(giftPayTotal / 20)}
total={giftPayTotal}
pageSize={20}
onPageChange={setGiftPayPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</CardContent>
</Card>
</div>
)}
</>
)}

View File

@@ -20,7 +20,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Smartphone, Plus, Pencil, Trash2 } from 'lucide-react'
import { Smartphone, Plus, Pencil, Trash2, RefreshCw } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface LinkedMpItem {
@@ -149,7 +149,16 @@ export function LinkedMpPage() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<div className="flex justify-end gap-2 mb-4">
<Button
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 hover:bg-gray-700/50"
onClick={() => loadList()}
title="刷新"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
onClick={openAdd}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"

View File

@@ -41,6 +41,9 @@ interface Purchase {
productType?: string
description?: string
refundReason?: string
giftPayRequestId?: string
payerUserId?: string
payerNickname?: string
}
interface UsersItem {
@@ -114,6 +117,10 @@ export function OrdersPage() {
const formatProduct = (order: Purchase) => {
const type = order.productType || order.type || ''
const desc = order.description || ''
if (type === 'balance_recharge') {
const amount = Number(order.amount || 0).toFixed(2)
return { name: `余额充值 ¥${amount}`, type: '余额充值' }
}
if (desc) {
if (type === 'section' && desc.includes('章节')) {
if (desc.includes('-')) {
@@ -128,7 +135,7 @@ export function OrdersPage() {
return { name: '《一场Soul的创业实验》', type: '全书购买' }
}
if (type === 'vip' || desc.includes('VIP')) {
return { name: 'VIP年度会员', type: 'VIP' }
return { name: '超级个体开通费用', type: '超级个体' }
}
if (type === 'match' || desc.includes('伙伴')) {
return { name: '找伙伴匹配', type: '功能服务' }
@@ -137,7 +144,7 @@ export function OrdersPage() {
}
if (type === 'section') return { name: `章节 ${order.productId || order.sectionId || ''}`, type: '单章' }
if (type === 'fullbook') return { name: '《一场Soul的创业实验》', type: '全书' }
if (type === 'vip') return { name: 'VIP年度会员', type: 'VIP' }
if (type === 'vip') return { name: '超级个体开通费用', type: '超级个体' }
if (type === 'match') return { name: '找伙伴匹配', type: '功能' }
return { name: '未知商品', type: type || '其他' }
}
@@ -182,7 +189,7 @@ export function OrdersPage() {
getUserPhone(p.userId),
product.name,
Number(p.amount || 0).toFixed(2),
p.paymentMethod === 'wechat' ? '微信支付' : p.paymentMethod === 'alipay' ? '支付宝' : p.paymentMethod || '微信支付',
p.paymentMethod === 'wechat' ? '微信支付' : p.paymentMethod === 'balance' ? '余额支付' : p.paymentMethod === 'alipay' ? '支付宝' : p.paymentMethod || '微信支付',
p.status === 'refunded' ? '已退款' : p.status === 'paid' || p.status === 'completed' ? '已完成' : p.status === 'pending' || p.status === 'created' ? '待支付' : '已失败',
p.status === 'refunded' && p.refundReason ? p.refundReason : '-',
p.referrerEarnings ? Number(p.referrerEarnings).toFixed(2) : '-',
@@ -305,8 +312,18 @@ export function OrdersPage() {
</TableCell>
<TableCell>
<div>
<p className="text-white text-sm">{getUserNickname(purchase)}</p>
<p className="text-white text-sm flex items-center gap-2">
{getUserNickname(purchase)}
{purchase.payerUserId && (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
</Badge>
)}
</p>
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
{purchase.payerUserId && purchase.payerNickname && (
<p className="text-amber-400/80 text-xs mt-0.5">{purchase.payerNickname}</p>
)}
</div>
</TableCell>
<TableCell>
@@ -315,7 +332,7 @@ export function OrdersPage() {
{product.name}
{(purchase.productType || purchase.type) === 'vip' && (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
VIP
</Badge>
)}
</p>
@@ -328,9 +345,11 @@ export function OrdersPage() {
<TableCell className="text-gray-300">
{purchase.paymentMethod === 'wechat'
? '微信支付'
: purchase.paymentMethod === 'alipay'
? '支付'
: purchase.paymentMethod || '微信支付'}
: purchase.paymentMethod === 'balance'
? '余额支付'
: purchase.paymentMethod === 'alipay'
? '支付宝'
: purchase.paymentMethod || '微信支付'}
</TableCell>
<TableCell>
{purchase.status === 'refunded' ? (
@@ -363,7 +382,8 @@ export function OrdersPage() {
{new Date(purchase.createdAt).toLocaleString('zh-CN')}
</TableCell>
<TableCell>
{(purchase.status === 'paid' || purchase.status === 'completed') && (
{(purchase.status === 'paid' || purchase.status === 'completed') &&
purchase.paymentMethod !== 'balance' && (
<Button
variant="outline"
size="sm"

View File

@@ -34,6 +34,7 @@ import {
Smartphone,
ShieldCheck,
Link2,
FileText,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
@@ -62,7 +63,6 @@ interface FeatureConfig {
matchEnabled: boolean
referralEnabled: boolean
searchEnabled: boolean
aboutEnabled: boolean
}
interface MpConfig {
@@ -74,10 +74,10 @@ interface MpConfig {
interface OssConfig {
endpoint?: string
accessKeyId?: string
accessKeySecret?: string
bucket?: string
region?: string
accessKeyId?: string
accessKeySecret?: string
}
const defaultMpConfig: MpConfig = {
@@ -104,19 +104,10 @@ const defaultSettings: LocalSettings = {
ckbLeadApiKey: '',
}
const defaultOssConfig: OssConfig = {
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
bucket: '',
region: '',
}
const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
searchEnabled: true,
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
@@ -130,7 +121,7 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>(defaultOssConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -233,13 +224,15 @@ export function SettingsPage() {
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
},
ossConfig: {
endpoint: ossConfig.endpoint || '',
accessKeyId: ossConfig.accessKeyId || '',
accessKeySecret: ossConfig.accessKeySecret || '',
bucket: ossConfig.bucket || '',
region: ossConfig.region || '',
},
ossConfig: Object.keys(ossConfig).length
? {
endpoint: ossConfig.endpoint ?? '',
bucket: ossConfig.bucket ?? '',
region: ossConfig.region ?? '',
accessKeyId: ossConfig.accessKeyId ?? '',
accessKeySecret: ossConfig.accessKeySecret ?? '',
}
: undefined,
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
@@ -306,7 +299,7 @@ export function SettingsPage() {
value="api-docs"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<BookOpen className="w-4 h-4 mr-2" />
<FileText className="w-4 h-4 mr-2" />
API
</TabsTrigger>
</TabsList>
@@ -577,85 +570,6 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">Endpoint</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou.aliyuncs.com"
value={ossConfig.endpoint ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou"
value={ossConfig.region ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="LTAI5t..."
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey Secret</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="********"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">Bucket </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="my-soul-bucket"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
</div>
<div className={`p-3 rounded-lg ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'bg-green-500/10 border border-green-500/30' : 'bg-amber-500/10 border border-amber-500/30'}`}>
<p className={`text-xs ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'text-green-300' : 'text-amber-300'}`}>
{ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId
? `✅ OSS 已配置(${ossConfig.bucket}.${ossConfig.endpoint}),上传将自动使用云端存储`
: '⚠ 未配置 OSS当前上传存储在本地服务器。填写以上信息并保存后自动启用云端存储'}
</p>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -710,7 +624,7 @@ export function SettingsPage() {
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"
@@ -719,23 +633,6 @@ export function SettingsPage() {
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">访</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
@@ -744,6 +641,78 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
</CardTitle>
<CardDescription className="text-gray-400">
endpointbucketaccessKey /
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">Endpoint</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou.aliyuncs.com"
value={ossConfig.endpoint ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Bucket</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="bucket 名称"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou"
value={ossConfig.region ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey ID"
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey Secret</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey Secret"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>

View File

@@ -267,7 +267,7 @@ export function UsersPage() {
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, phone: formData.phone || undefined, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, nickname: formData.nickname, password: formData.password, isAdmin: formData.isAdmin })
@@ -1169,7 +1169,7 @@ export function UsersPage() {
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入手机号" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} disabled={!!editingUser} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入手机号" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入昵称" value={formData.nickname} onChange={(e) => setFormData({ ...formData, nickname: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300">{editingUser ? '新密码 (留空则不修改)' : '密码'}</Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder={editingUser ? '留空则不修改' : '请输入密码'} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} /></div>
<div className="flex items-center justify-between"><Label className="text-gray-300"></Label><Switch checked={formData.isAdmin} onCheckedChange={(c) => setFormData({ ...formData, isAdmin: c })} /></div>