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:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
48
soul-admin/src/components/RechargeAlert.tsx
Normal file
48
soul-admin/src/components/RechargeAlert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 || '其他' }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
endpoint、bucket、accessKey 等,用于图片/文件上传
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user