fix: 注册 user-rules 和 shensheshou 路由,修复规则配置保存失败

- 将 /api/db/user-rules 的 GET/POST/PUT/DELETE 路由注册到 db 组
- 将 /api/admin/shensheshou/* 的 4 个路由注册到 admin 组
- 包含上次对话的头像 URL 修复(normalizeImageUrl 全栈)

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 19:31:07 +08:00
parent 708547d0dd
commit 64db4927ea
116 changed files with 1969 additions and 435 deletions

View File

@@ -0,0 +1,34 @@
# 派对 AI 开发规则
## 强制:使用派对 AI 进行开发
所有开发操作**优先使用派对 AI**(路径:`/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/派对AI/`),派对 AI 优先于卡若 AI 读取使用。
### 启动顺序
1. 读取 `派对AI/BOOTSTRAP.md` — 了解项目 AI 身份与团队
2. 读取 `派对AI/SKILL_REGISTRY.md` — 查找对应技能
3. 按匹配的 SKILL.md 执行
### 开发复盘推送飞书群
每次开发完成后,**必须将复盘内容推送到 Soul 创业派对开发资料群**
```
飞书 Webhook: https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494
```
推送格式:
```json
{
"msg_type": "text",
"content": {
"text": "[开发复盘] 日期时间\n\n🎯 目标:...\n📌 结果:...\n💡 关键判断:...\n📝 遗留:...\n▶ 下一步:..."
}
}
```
### 复盘格式
使用卡若 AI 标准复盘格式(🎯📌💡📝▶ 五块齐全),带日期+时间。

View File

@@ -6,6 +6,7 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
@@ -512,6 +513,17 @@ Page({
}
}
})
// 记录匹配行为到 user_tracks
const uid = app.globalData.userInfo?.id
if (uid) {
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId: uid, action: 'match', target: matchedUser?.id || '', extraData: { matchType: this.data.selectedType } },
silent: true
}).catch(() => {})
}
// 匹配后规则:引导填写 MBTI/行业信息
checkAndExecute('after_match', this)
} catch (e) {
console.log('上报匹配失败:', e)
}

View File

@@ -1,6 +1,7 @@
import accessManager from '../../utils/chapterAccessManager'
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
Page({
data: {
@@ -128,6 +129,17 @@ Page({
if (typeof p.initUserStatus === 'function') p.initUserStatus()
else if (typeof p.updateUserStatus === 'function') p.updateUserStatus()
})
// 记录购买行为到 user_tracks
const uid = app.globalData.userInfo?.id
if (uid) {
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId: uid, action: 'purchase', target: 'vip_annual', extraData: { amount: this.data.price } },
silent: true
}).catch(() => {})
}
// 购买后规则:引导填写完整信息
checkAndExecute('after_pay', this)
} catch (e) {
console.error('[VIP] 支付后同步失败:', e)
}

View File

@@ -151,6 +151,15 @@ const hideLoading = () => {
wx.hideLoading()
}
// 修复图片 URL 中 protocol 缺少冒号的问题(如 "https//..." → "https://..."
const normalizeImageUrl = (url) => {
if (!url || typeof url !== 'string') return ''
let s = url.trim()
if (!s) return ''
s = s.replace(/^(https?)\/\//, '$1://')
return s
}
// 显示确认框
const showConfirm = (title, content) => {
return new Promise((resolve) => {
@@ -174,6 +183,7 @@ module.exports = {
isValidWechat,
deepClone,
getQueryParams,
normalizeImageUrl,
storage,
showToast,
showLoading,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -19,6 +19,7 @@ import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { ApiDocsPage } from './pages/api-docs/ApiDocsPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
@@ -47,6 +48,7 @@ function App() {
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
<Route path="api-docs" element={<ApiDocsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -205,3 +205,35 @@
}
.bubble-menu button:hover { background: #1f2937; color: #d1d5db; }
.bubble-menu button.is-active { color: #38bdac; }
/* @ 按钮高亮 */
.mention-trigger-btn { color: #38bdac !important; }
.mention-trigger-btn:hover { background: rgba(56, 189, 172, 0.2) !important; }
/* 上传进度条 */
.upload-progress-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f1d32;
border-bottom: 1px solid #374151;
}
.upload-progress-track {
flex: 1;
height: 4px;
background: #1f2937;
border-radius: 2px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: linear-gradient(90deg, #38bdac, #4ae3ce);
border-radius: 2px;
transition: width 0.3s ease;
}
.upload-progress-text {
font-size: 11px;
color: #38bdac;
white-space: nowrap;
}

View File

@@ -1,4 +1,4 @@
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
import { useEditor, EditorContent, type Editor, Node as TiptapNode, mergeAttributes } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
@@ -9,13 +9,14 @@ 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,
Table as TableIcon, Undo, Redo, Minus, Video, AtSign,
} 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
@@ -31,6 +32,7 @@ export interface PersonItem {
export interface LinkTagItem {
id: string
label: string
aliases?: string // comma-separated alternative labels
url: string
type: 'url' | 'miniprogram' | 'ckb'
appId?: string
@@ -46,12 +48,127 @@ 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
@@ -118,7 +235,7 @@ function markdownToHtml(md: string): string {
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
* 解决insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
*/
const LinkTagExtension = Node.create({
const LinkTagExtension = TiptapNode.create({
name: 'linkTag',
group: 'inline',
inline: true,
@@ -165,10 +282,25 @@ const LinkTagExtension = Node.create({
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (personsRef: React.RefObject<PersonItem[]>): 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 || []
return persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8)
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
},
render: () => {
let popup: HTMLDivElement | null = null
@@ -177,18 +309,32 @@ const MentionSuggestion = (personsRef: React.RefObject<PersonItem[]>): any => ({
// 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.label || item.id}</span>
<span class="mention-id">${(item as PersonItem & { _newName?: string }).id === '__new__' ? '' : (item.label || item.id)}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-index') || '0')
if (command && items[idx]) command({ id: items[idx].id, label: items[idx].name })
selectItem(idx)
})
})
}
@@ -228,7 +374,7 @@ const MentionSuggestion = (personsRef: React.RefObject<PersonItem[]>): any => ({
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'ArrowUp') { selectedIndex = Math.max(0, selectedIndex - 1); update(); return true }
if (props.event.key === 'ArrowDown') { selectedIndex = Math.min(items.length - 1, selectedIndex + 1); update(); return true }
if (props.event.key === 'Enter') { if (command && items[selectedIndex]) command({ id: items[selectedIndex].id, label: items[selectedIndex].name }); return true }
if (props.event.key === 'Enter') { selectItem(selectedIndex); return true }
if (props.event.key === 'Escape') { popup?.remove(); popup = null; return true }
return false
},
@@ -241,20 +387,30 @@ 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 [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const [imageUploading, setImageUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const initialContent = useRef(autoMatchMentionsAndTags(markdownToHtml(content), persons, linkTags))
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 editor = useEditor({
@@ -264,7 +420,10 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(personsRef),
suggestion: {
...MentionSuggestion(personsRef, onPersonCreateRef),
allowedPrefixes: null,
},
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
@@ -275,7 +434,14 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
onChangeRef.current(ed.getHTML())
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)
},
editorProps: {
@@ -289,21 +455,31 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
}))
useEffect(() => {
if (editor && content !== editor.getHTML()) {
const html = markdownToHtml(content)
if (html !== editor.getHTML()) {
editor.commands.setContent(html)
}
if (!editor) return
const html = autoMatchMentionsAndTags(markdownToHtml(content), personsRef.current || [], linkTagsRef.current || [])
if (html !== editor.getHTML()) {
editor.commands.setContent(html, { emitUpdate: false })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content])
}, [content, editor, persons, linkTags])
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onImageUpload) {
const url = await onImageUpload(file)
if (url) editor.chain().focus().setImage({ src: url }).run()
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)
}
} else {
const reader = new FileReader()
reader.onload = () => {
@@ -314,6 +490,37 @@ 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 丢弃
@@ -365,8 +572,11 @@ 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"><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={() => 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={() => 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" />
@@ -408,6 +618,14 @@ 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

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Dialog,
@@ -72,13 +72,13 @@ export function SetVipModal({
let cancelled = false
setLoading(true)
Promise.all([
get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles'),
get<{ success?: boolean; data?: VipRole[]; roles?: VipRole[] }>('/api/db/vip-roles'),
userId ? get<{ success?: boolean; user?: Record<string, unknown> }>(`/api/db/users?id=${encodeURIComponent(userId)}`) : Promise.resolve(null),
]).then(([rolesRes, userRes]) => {
if (cancelled) return
const rolesList = (rolesRes as { success?: boolean; data?: VipRole[] })?.success && (rolesRes as { data?: VipRole[] }).data ? (rolesRes as { data?: VipRole[] }).data! : []
setRoles(rolesList)
const u = userRes && (userRes as { user?: Record<string, unknown> }).user ? (userRes as { user?: Record<string, unknown> }).user! : null
const rolesList = rolesRes?.data || rolesRes?.roles || []
setRoles(rolesList as VipRole[])
const u = userRes?.user || null
if (u) {
const vipRole = String(u.vipRole ?? '')
const inRoles = rolesList.some((r: VipRole) => r.name === vipRole)

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import {
Dialog,
@@ -385,7 +386,7 @@ export function UserDetailModal({
return (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
@@ -401,12 +402,12 @@ export function UserDetailModal({
<span className="ml-2 text-gray-400">...</span>
</div>
) : user ? (
<div className="flex flex-col h-[75vh]">
<div className="flex flex-col min-h-0 flex-1 overflow-hidden">
{/* 用户头部信息 */}
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-3">
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac] shrink-0">
{user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
<img src={normalizeImageUrl(user.avatar)} className="w-full h-full rounded-full object-cover" alt="" />
) : (
user.nickname?.charAt(0) || '?'
)}
@@ -1029,7 +1030,7 @@ export function UserDetailModal({
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3">
<div className="flex justify-end gap-2 pt-3 border-t border-gray-700 mt-3 shrink-0">
<Button
variant="outline"
onClick={onClose}

View File

@@ -99,22 +99,20 @@ 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}

View File

@@ -4,3 +4,16 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 修复图片 URL 中 protocol 缺少冒号的问题(如 `https//` → `https://`)。
* 同时处理 OSS 签名 URL 中 objectKey 的 %2F 编码,保证浏览器能正确解析。
*/
export function normalizeImageUrl(url: string | null | undefined): string {
if (!url) return ''
let s = url.trim()
if (!s) return ''
// 修复 "https//..." 或 "http//..." → "https://..." / "http://..."
s = s.replace(/^(https?)\/\//, '$1://')
return s
}

View File

@@ -0,0 +1,442 @@
/**
* API 接口完整文档页 - 内容管理相关接口
* 深色主题,与 Admin 整体风格一致
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key } from 'lucide-react'
interface EndpointBlockProps {
method: string
url: string
desc?: string
headers?: string[]
body?: string
response?: string
}
function EndpointBlock({ method, url, desc, headers, body, response }: EndpointBlockProps) {
const methodColor =
method === 'GET'
? 'text-emerald-400'
: method === 'POST'
? 'text-amber-400'
: method === 'PUT'
? 'text-blue-400'
: method === 'DELETE'
? 'text-rose-400'
: 'text-gray-400'
return (
<div className="rounded-lg bg-[#0a1628]/60 border border-gray-700/50 p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-mono font-semibold ${methodColor}`}>{method}</span>
<code className="text-sm text-[#38bdac] break-all">{url}</code>
</div>
{desc && <p className="text-gray-400 text-sm">{desc}</p>}
{headers && headers.length > 0 && (
<div>
<p className="text-gray-500 text-xs mb-1">Headers</p>
<pre className="text-xs text-gray-300 font-mono overflow-x-auto p-2 rounded bg-black/30">
{headers.join('\n')}
</pre>
</div>
)}
{body && (
<div>
<p className="text-gray-500 text-xs mb-1">Request Body (JSON)</p>
<pre className="text-xs text-green-400/90 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{body}
</pre>
</div>
)}
{response && (
<div>
<p className="text-gray-500 text-xs mb-1">Response Example</p>
<pre className="text-xs text-amber-200/80 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{response}
</pre>
</div>
)}
</div>
)
}
export function ApiDocsPage() {
const baseHeaders = ['Authorization: Bearer {token}', 'Content-Type: application/json']
return (
<div className="p-8 w-full bg-[#0a1628] text-white">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">API </h1>
<p className="text-gray-400 mt-1">
· RESTful · /api · Bearer Token
</p>
</div>
{/* 1. Authentication */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Key className="w-5 h-5 text-[#38bdac]" />
1. Authentication
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="POST"
url="/api/admin"
desc="登录,返回 JWT token"
headers={['Content-Type: application/json']}
body={`{
"username": "admin",
"password": "your_password"
}`}
response={`{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-03-16T12:00:00Z"
}`}
/>
</CardContent>
</Card>
{/* 2. Chapters */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-[#38bdac]" />
2. (Chapters)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=chapters"
desc="获取章节树"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "part-1", "title": "第一篇", "children": [...] },
{ "id": "section-1", "title": "第1节", "price": 1.0, "isFree": false }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/book?action=section&id={id}"
desc="获取单篇内容"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"id": "section-1",
"title": "标题",
"content": "正文...",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"chapterId": "ch-1"
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="新建章节 (action=create-section)"
headers={baseHeaders}
body={`{
"action": "create-section",
"title": "新章节标题",
"content": "正文内容",
"price": 0,
"isFree": true,
"partId": "part-1",
"chapterId": "ch-1",
"partTitle": "第一篇",
"chapterTitle": "第1章"
}`}
response={`{
"success": true,
"data": { "id": "section-new-id", "title": "新章节标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="更新章节内容 (action=update-section)"
headers={baseHeaders}
body={`{
"action": "update-section",
"id": "section-1",
"title": "更新后的标题",
"content": "更新后的正文",
"price": 1.0,
"isFree": false
}`}
response={`{
"success": true,
"data": { "id": "section-1", "title": "更新后的标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="删除章节 (action=delete-section)"
headers={baseHeaders}
body={`{
"action": "delete-section",
"id": "section-1"
}`}
response={`{
"success": true,
"message": "已删除"
}`}
/>
<EndpointBlock
method="POST"
url="/api/admin/content/upload"
desc="上传图片(管理端)"
headers={baseHeaders}
body={`FormData: file (binary)`}
response={`{
"success": true,
"url": "/uploads/images/xxx.jpg",
"data": { "url", "fileName", "size", "type" }
}`}
/>
</CardContent>
</Card>
{/* 3. Persons */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
3. (@Mentions)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/persons"
desc="人物列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "personId": "p1", "label": "张三", "aliases": ["老张"], ... }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/person?personId={id}"
desc="人物详情"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"personId": "p1",
"label": "张三",
"aliases": ["老张"],
"description": "..."
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/persons"
desc="新增/更新人物(含 aliases 字段)"
headers={baseHeaders}
body={`{
"personId": "p1",
"label": "张三",
"aliases": ["老张", "张三丰"],
"description": "可选描述"
}`}
response={`{
"success": true,
"data": { "personId": "p1", "label": "张三", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/persons?personId={id}"
desc="删除人物"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 4. LinkTags */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Tag className="w-5 h-5 text-[#38bdac]" />
4. (#LinkTags)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/link-tags"
desc="标签列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "tagId": "t1", "label": "官网", "aliases": [], "type": "url", "url": "https://..." }
]
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/link-tags"
desc="新增/更新标签(含 aliases, type: url/miniprogram/ckb"
headers={baseHeaders}
body={`{
"tagId": "t1",
"label": "官网",
"aliases": ["官方网站"],
"type": "url",
"url": "https://example.com"
}
// type 可选: url | miniprogram | ckb`}
response={`{
"success": true,
"data": { "tagId": "t1", "label": "官网", "type": "url", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/link-tags?tagId={id}"
desc="删除标签"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 5. Search */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Search className="w-5 h-5 text-[#38bdac]" />
5.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/search?q={keyword}"
desc="搜索(标题优先 3 条 + 内容匹配)"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"titleMatches": [{ "id": "s1", "title": "...", "snippet": "..." }],
"contentMatches": [{ "id": "s2", "title": "...", "snippet": "..." }]
}
}`}
/>
</CardContent>
</Card>
{/* 6. Ranking */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Trophy className="w-5 h-5 text-[#38bdac]" />
6.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=ranking"
desc="排行榜数据"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "s1", "title": "...", "clickCount": 100, "payCount": 50, "hotScore": 120, "hotRank": 1 }
]
}`}
/>
</CardContent>
</Card>
{/* 7. Miniprogram */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
7.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/miniprogram/book/all-chapters"
desc="全部章节(小程序用)"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": [ { "id": "s1", "title": "...", "price": 1.0, "isFree": false }, ... ]
}`}
/>
<EndpointBlock
method="GET"
url="/api/miniprogram/balance?userId={id}"
desc="查余额"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": { "balance": 100.50, "userId": "xxx" }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift"
desc="代付"
headers={['Content-Type: application/json']}
body={`{
"userId": "xxx",
"amount": 10.00,
"remark": "可选备注"
}`}
response={`{
"success": true,
"data": { "balance": 110.50 }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift/redeem"
desc="领取代付"
headers={['Content-Type: application/json']}
body={`{
"code": "GIFT_XXXX"
}`}
response={`{
"success": true,
"data": { "amount": 10.00, "balance": 120.50 }
}`}
/>
</CardContent>
</Card>
<p className="text-gray-500 text-xs mt-6">
使 /api/admin/*/api/db/*使 /api/miniprogram/* soul-api
</p>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -256,7 +257,7 @@ export function AuthorSettingsPage() {
{config.avatarImg && (
<div className="mt-2">
<img
src={config.avatarImg.startsWith('http') ? config.avatarImg : apiUrl(config.avatarImg)}
src={normalizeImageUrl(config.avatarImg.startsWith('http') ? config.avatarImg : apiUrl(config.avatarImg))}
alt="头像预览"
className="w-20 h-20 rounded-full object-cover border border-gray-600"
/>

View File

@@ -45,9 +45,8 @@ import {
Hash,
ExternalLink,
Pencil,
Smartphone,
Users,
} from 'lucide-react'
import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal'
@@ -121,31 +120,48 @@ interface EditingSection {
hotScore?: number
editionStandard?: boolean
editionPremium?: boolean
previewPercent?: number | null
}
// 在保存前自动把纯文本中的 @用户 / #标签 转成带 token / 配置的节点
function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string {
if (!html || (!html.includes('@') && !html.includes('#'))) return html
if (!html || (!html.includes('@') && !html.includes('') && !html.includes('#'))) return html
if (typeof document === 'undefined') return html
const container = document.createElement('div')
container.innerHTML = html
const matchPerson = (name: string): PersonItem | undefined =>
persons.find((p) => p.name === name)
const normalizeKey = (value: string) => value.trim().toLowerCase()
const matchTag = (label: string): LinkTagItem | undefined =>
linkTags.find((t) => t.label === label)
const personMap = new Map<string, PersonItem>()
for (const person of persons) {
const keys = [person.name, ...(person.aliases ? person.aliases.split(',') : [])]
.map((item) => normalizeKey(item))
.filter(Boolean)
for (const key of keys) {
if (!personMap.has(key)) personMap.set(key, person)
}
}
const tagMap = new Map<string, LinkTagItem>()
for (const tag of linkTags) {
const keys = [tag.label, ...(tag.aliases ? tag.aliases.split(',') : [])]
.map((item) => normalizeKey(item))
.filter(Boolean)
for (const key of keys) {
if (!tagMap.has(key)) tagMap.set(key, tag)
}
}
const processTextNode = (node: Text) => {
const text = node.textContent || ''
if (!text || (!text.includes('@') && !text.includes('#'))) return
if (!text || (!text.includes('@') && !text.includes('') && !text.includes('#'))) return
const parent = node.parentNode
if (!parent) return
const frag = document.createDocumentFragment()
const regex = /(@[^\s@#]+|#[^\s@#]+)/g
const regex = /([@][^\s@##]+|#[^\s@##]+)/g
let lastIndex = 0
let match: RegExpExecArray | null
@@ -157,13 +173,13 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
frag.appendChild(document.createTextNode(text.slice(lastIndex, index)))
}
if (full.startsWith('@')) {
const name = full.slice(1)
const person = matchPerson(name)
if (full.startsWith('@') || full.startsWith('')) {
const person = personMap.get(normalizeKey(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}`
frag.appendChild(span)
@@ -171,8 +187,7 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
frag.appendChild(document.createTextNode(full))
}
} else if (full.startsWith('#')) {
const label = full.slice(1)
const tag = matchTag(label)
const tag = tagMap.get(normalizeKey(full.slice(1)))
if (tag) {
const span = document.createElement('span')
span.setAttribute('data-type', 'linkTag')
@@ -325,7 +340,7 @@ export function ContentPage() {
const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null)
const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false)
const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false)
const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 })
const [rankingWeights, setRankingWeights] = useState({ readWeight: 50, recencyWeight: 30, payWeight: 20 })
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
const [rankingPage, setRankingPage] = useState(1)
@@ -343,6 +358,7 @@ export function ContentPage() {
const [newLinkTag, setNewLinkTag] = useState({
tagId: '',
label: '',
aliases: '',
url: '',
type: 'url' as 'url' | 'miniprogram' | 'ckb',
appId: '',
@@ -351,6 +367,16 @@ export function ContentPage() {
const [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
const richEditorRef = useRef<RichEditorRef>(null)
// CKB 获客统计
const [ckbLeadCounts, setCkbLeadCounts] = useState<Record<string, number>>({})
const [ckbLeadDetailOpen, setCkbLeadDetailOpen] = useState(false)
const [ckbLeadDetailToken, setCkbLeadDetailToken] = useState('')
const [ckbLeadDetailName, setCkbLeadDetailName] = useState('')
const [ckbLeadRecords, setCkbLeadRecords] = useState<{ id: number; userId: string; nickname: string; phone: string; wechatId: string; name: string; source: string; createdAt: string }[]>([])
const [ckbLeadTotal, setCkbLeadTotal] = useState(0)
const [ckbLeadPage, setCkbLeadPage] = useState(1)
const [ckbLeadLoading, setCkbLeadLoading] = useState(false)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
@@ -462,9 +488,9 @@ export function ContentPage() {
const d = data && (data as { success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }).data
if (d && typeof d.readWeight === 'number' && typeof d.recencyWeight === 'number' && typeof d.payWeight === 'number') {
setRankingWeights({
readWeight: Math.max(0, Math.min(1, d.readWeight)),
recencyWeight: Math.max(0, Math.min(1, d.recencyWeight)),
payWeight: Math.max(0, Math.min(1, d.payWeight)),
readWeight: Math.round(d.readWeight * 100),
recencyWeight: Math.round(d.recencyWeight * 100),
payWeight: Math.round(d.payWeight * 100),
})
}
} catch {
@@ -481,20 +507,21 @@ export function ContentPage() {
const handleSaveRankingWeights = async () => {
const { readWeight, recencyWeight, payWeight } = rankingWeights
const sum = readWeight + recencyWeight + payWeight
if (Math.abs(sum - 1) > 0.001) {
toast.error('三个权重之和必须等于 1')
if (Math.abs(sum - 100) > 1) {
toast.error('三个权重之和必须等于 100%')
return
}
setRankingWeightsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'article_ranking_weights',
value: { readWeight, recencyWeight, payWeight },
value: { readWeight: readWeight / 100, recencyWeight: recencyWeight / 100, payWeight: payWeight / 100 },
description: '文章排名算法权重',
})
if (res && (res as { success?: boolean }).success !== false) {
toast.success('排名权重已保存')
toast.success('排名权重已保存,排行榜已刷新')
loadList()
loadRanking()
} else {
toast.error('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
@@ -524,6 +551,7 @@ export function ContentPage() {
personId: string
token?: string
name: string
aliases?: string
label?: string
ckbApiKey?: string
ckbPlanId?: number
@@ -544,6 +572,7 @@ export function ContentPage() {
id: p.token ?? p.personId ?? '',
personId: p.personId,
name: p.name,
aliases: p.aliases ?? '',
label: p.label ?? '',
ckbApiKey: p.ckbApiKey ?? '',
ckbPlanId: p.ckbPlanId,
@@ -560,17 +589,72 @@ export function ContentPage() {
} catch { /* ignore */ }
}, [])
const handlePersonCreate = useCallback(async (name: string): Promise<PersonItem | null> => {
const trimmed = name?.trim()
if (!trimmed) return null
try {
const res = await post<{
success?: boolean
error?: string
person?: { personId?: string; token?: string; name?: string; label?: string }
}>('/api/db/persons', { name: trimmed })
if (!res?.success || !res.person) return null
const p = res.person
loadPersons()
return {
id: p.token ?? p.personId ?? '',
personId: p.personId,
name: p.name ?? trimmed,
label: p.label ?? '',
}
} catch {
toast.error('创建人物失败')
return null
}
}, [loadPersons])
const loadCkbLeadCounts = useCallback(async () => {
try {
const data = await get<{ success?: boolean; byPerson?: { token: string; total: number }[] }>('/api/db/ckb-person-leads')
if (data?.success && data.byPerson) {
const m: Record<string, number> = {}
for (const item of data.byPerson) m[item.token] = item.total
setCkbLeadCounts(m)
}
} catch { /* ignore */ }
}, [])
const openCkbLeadDetail = useCallback(async (token: string, name: string, page = 1) => {
setCkbLeadDetailToken(token)
setCkbLeadDetailName(name)
setCkbLeadDetailOpen(true)
setCkbLeadPage(page)
setCkbLeadLoading(true)
try {
const data = await get<{ success?: boolean; records?: typeof ckbLeadRecords; total?: number; personName?: string }>(
`/api/db/ckb-person-leads?token=${encodeURIComponent(token)}&page=${page}&pageSize=20`
)
if (data?.success) {
setCkbLeadRecords(data.records || [])
setCkbLeadTotal(data.total || 0)
if (data.personName) setCkbLeadDetailName(data.personName)
}
} catch { /* ignore */ }
finally { setCkbLeadLoading(false) }
}, [])
const loadLinkTags = useCallback(async () => {
try {
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: { tagId: string; label: string; aliases?: string; url: string; type: string; appId?: string; pagePath?: string }[]
}>('/api/db/link-tags')
if (data?.success && data.linkTags) {
setLinkTags(
data.linkTags.map((t) => ({
id: t.tagId,
label: t.label,
aliases: t.aliases ?? '',
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
appId: t.appId || '',
@@ -583,30 +667,6 @@ export function ContentPage() {
}
}, [])
const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([])
const [mpSearchQuery, setMpSearchQuery] = useState('')
const [mpDropdownOpen, setMpDropdownOpen] = useState(false)
const mpDropdownRef = useRef<HTMLDivElement>(null)
const loadLinkedMps = useCallback(async () => {
try {
const res = await get<{ success?: boolean; data?: { key: string; name: string; appId: string; path?: string }[] }>(
'/api/admin/linked-miniprograms',
)
if (res?.success && Array.isArray(res.data)) {
setLinkedMps(res.data.map((m) => ({ ...m, key: m.key })))
}
} catch { /* ignore */ }
}, [])
const filteredLinkedMps = linkedMps.filter(
(m) =>
!mpSearchQuery.trim() ||
m.name.toLowerCase().includes(mpSearchQuery.toLowerCase()) ||
(m.key && m.key.toLowerCase().includes(mpSearchQuery.toLowerCase())) ||
m.appId.toLowerCase().includes(mpSearchQuery.toLowerCase()),
)
const handleTogglePin = async (sectionId: string) => {
const next = pinnedSectionIds.includes(sectionId)
? pinnedSectionIds.filter((id) => id !== sectionId)
@@ -653,8 +713,8 @@ export function ContentPage() {
loadPreviewPercent()
loadPersons()
loadLinkTags()
loadLinkedMps()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadLinkedMps])
loadCkbLeadCounts()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts])
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
@@ -676,11 +736,11 @@ export function ContentPage() {
const handleReadSection = async (section: Section & { filePath?: string }) => {
setIsLoadingContent(true)
try {
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string; editionStandard?: boolean; editionPremium?: boolean }; error?: string }>(
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string; editionStandard?: boolean; editionPremium?: boolean; previewPercent?: number | null }; error?: string }>(
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
)
if (data?.success && data.section) {
const sec = data.section as { isNew?: boolean; editionStandard?: boolean; editionPremium?: boolean }
const sec = data.section as { isNew?: boolean; editionStandard?: boolean; editionPremium?: boolean; previewPercent?: number | null }
const isPremium = sec.editionPremium === true
setEditingSection({
id: section.id,
@@ -695,6 +755,7 @@ export function ContentPage() {
hotScore: section.hotScore ?? 0,
editionStandard: isPremium ? false : (sec.editionStandard ?? true),
editionPremium: isPremium,
previewPercent: sec.previewPercent ?? null,
})
} else {
setEditingSection({
@@ -710,6 +771,7 @@ export function ContentPage() {
hotScore: section.hotScore ?? 0,
editionStandard: true,
editionPremium: false,
previewPercent: null,
})
if (data && !(data as { success?: boolean }).success) {
toast.error('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
@@ -746,6 +808,8 @@ export function ContentPage() {
const originalId = editingSection.originalId || editingSection.id
const idChanged = editingSection.id !== originalId
const prevVal = editingSection.previewPercent
const hasPreviewOverride = prevVal != null && prevVal !== 0 && Number(prevVal) >= 1 && Number(prevVal) <= 100
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: originalId,
...(idChanged ? { newId: editingSection.id } : {}),
@@ -757,6 +821,7 @@ export function ContentPage() {
hotScore: editingSection.hotScore,
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
editionPremium: editingSection.editionPremium ?? false,
...(hasPreviewOverride ? { previewPercent: Number(prevVal) } : { clearPreviewPercent: true }),
saveToFile: true,
})
const effectiveId = idChanged ? editingSection.id : originalId
@@ -1370,8 +1435,17 @@ export function ContentPage() {
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
onVideoUpload={async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-videos')
const res = await fetch(apiUrl('/api/upload/video'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
persons={persons}
linkTags={linkTags}
onPersonCreate={handlePersonCreate}
placeholder="开始编辑内容... 输入 @ 可链接AI人物工具栏可插入 #链接标签"
/>
</div>
@@ -1660,59 +1734,61 @@ export function ContentPage() {
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-400"> = × + × + × 1</p>
<p className="text-sm text-gray-400">201=2020=1 100%</p>
{rankingWeightsLoading ? (
<p className="text-gray-500">...</p>
) : (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Label className="text-gray-400 text-xs"> (%)</Label>
<Input
type="number"
step="0.1"
step="5"
min="0"
max="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.readWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(100, parseInt(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Label className="text-gray-400 text-xs"> (%)</Label>
<Input
type="number"
step="0.1"
step="5"
min="0"
max="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.recencyWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(100, parseInt(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Label className="text-gray-400 text-xs"> (%)</Label>
<Input
type="number"
step="0.1"
step="5"
min="0"
max="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.payWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(100, parseInt(e.target.value) || 0)) }))}
/>
</div>
</div>
<p className="text-xs text-gray-500">: {(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight).toFixed(1)}</p>
<p className={`text-xs ${Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 100) > 1 ? 'text-red-400' : 'text-green-400'}`}>
: {rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight}%{Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 100) > 1 ? '(须为 100%' : ' ✓'}
</p>
<ul className="list-disc list-inside space-y-1 text-xs text-gray-400">
<li> 20 1=202=19...20=1</li>
<li> 30 1=302=29...30=1</li>
<li> 20 1=202=19...20=1</li>
<li></li>
<li>201=20 20=10</li>
<li>20201</li>
<li>20201</li>
<li> = × % + × % + × %</li>
</ul>
<Button
onClick={handleSaveRankingWeights}
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 1) > 0.001}
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 100) > 1}
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
>
{rankingWeightsSaving ? '保存中...' : '保存权重'}
@@ -1920,6 +1996,26 @@ export function ContentPage() {
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (%)</Label>
<Input
type="number"
min="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="默认使用全局设置"
value={editingSection.previewPercent != null && editingSection.previewPercent >= 1 && editingSection.previewPercent <= 100 ? String(editingSection.previewPercent) : ''}
onChange={(e) => {
const v = e.target.value.trim()
const num = v === '' ? null : parseInt(v, 10)
setEditingSection({
...editingSection,
previewPercent: (v !== '' && num !== null && !isNaN(num) && num >= 1 && num <= 100) ? num : null,
})
}}
/>
<span className="text-xs text-gray-500"> N% 使</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
@@ -1959,8 +2055,17 @@ export function ContentPage() {
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
onVideoUpload={async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-videos')
const res = await fetch(apiUrl('/api/upload/video'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
const data = await res.json()
return data?.data?.url || data?.url || ''
}}
persons={persons}
linkTags={linkTags}
onPersonCreate={handlePersonCreate}
placeholder="开始编辑内容... 输入 @ 可链接AI人物工具栏可插入 #链接标签"
/>
)}
@@ -2025,10 +2130,6 @@ export function ContentPage() {
<Hash className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="linkedmp" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400">
<Smartphone className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="chapters" className="space-y-4">
@@ -2125,39 +2226,73 @@ export function ContentPage() {
{searchResults.length > 0 && (
<div className="space-y-2 mt-4">
<p className="text-gray-400 text-sm"> {searchResults.length} </p>
{searchResults.map((result) => (
<div
key={result.id}
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
onClick={() =>
handleReadSection({
id: result.id,
title: result.title,
price: result.price ?? 1,
filePath: '',
})
}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-mono text-xs">{result.id}</span>
<span className="text-white">{result.title}</span>
{pinnedSectionIds.includes(result.id) && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
{searchResults.filter(r => r.matchType === 'title').length > 0 && (
<div>
<p className="text-amber-400 text-sm font-medium mb-2"></p>
{searchResults.filter(r => r.matchType === 'title').slice(0, 3).map((result) => (
<div
key={result.id}
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors mb-2"
onClick={() =>
handleReadSection({
id: result.id,
title: result.title,
price: result.price ?? 1,
filePath: '',
})
}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-mono text-xs">{result.id}</span>
<span className="text-white">{result.title}</span>
{pinnedSectionIds.includes(result.id) && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
</div>
</div>
{(result.partTitle || result.chapterTitle) && (
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
)}
</div>
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
</Badge>
</div>
{result.snippet && (
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
)}
{(result.partTitle || result.chapterTitle) && (
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
)}
))}
</div>
))}
)}
{searchResults.filter(r => r.matchType === 'content').length > 0 && (
<div className="mt-4">
<p className="text-gray-400 text-sm font-medium mb-2"></p>
{searchResults.filter(r => r.matchType === 'content').map((result) => (
<div
key={result.id}
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors mb-2"
onClick={() =>
handleReadSection({
id: result.id,
title: result.title,
price: result.price ?? 1,
filePath: '',
})
}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-mono text-xs">{result.id}</span>
<span className="text-white">{result.title}</span>
{pinnedSectionIds.includes(result.id) && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
</div>
</div>
{result.snippet && (
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
)}
{(result.partTitle || result.chapterTitle) && (
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
)}
</div>
))}
</div>
)}
</div>
)}
</CardContent>
@@ -2334,16 +2469,22 @@ export function ContentPage() {
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{persons.length > 0 && (
<div className="flex items-center gap-4 px-3 py-1.5 text-xs text-gray-500 border-b border-gray-700/50">
<span className="w-[280px] shrink-0">token</span>
<span className="w-[200px] shrink-0">token</span>
<span className="w-24 shrink-0">@的人</span>
<span className="w-28 shrink-0"></span>
<span className="w-16 shrink-0 text-center"></span>
<span></span>
</div>
)}
{persons.map(p => (
{persons.map(p => {
const leadCount = ckbLeadCounts[p.id] || 0
return (
<div key={p.id} className="bg-[#0a1628] rounded px-3 py-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm min-w-0">
<span className="text-gray-400 text-xs font-mono shrink-0 w-[280px]" title="32位token">{p.id}</span>
<span className="text-gray-400 text-xs font-mono shrink-0 w-[200px] truncate" title={p.id}>{p.id}</span>
<span className="text-amber-400 shrink-0 w-24 truncate" title="@的人">{p.name}</span>
<span className="text-gray-500 shrink-0 w-28 truncate text-xs" title="别名">{p.aliases || '-'}</span>
<span className={`shrink-0 w-16 text-center text-xs font-bold ${leadCount > 0 ? 'text-green-400' : 'text-gray-600'}`} title="获客数">{leadCount}</span>
<span className="text-white truncate" title="获客计划活动名">SOUL链接人与事-{p.name}</span>
</div>
<div className="flex items-center gap-1">
@@ -2362,6 +2503,7 @@ export function ContentPage() {
id: d.token ?? d.personId,
personId: d.personId,
name: d.name,
aliases: (d as { aliases?: string }).aliases ?? '',
label: d.label ?? '',
ckbApiKey: d.ckbApiKey ?? '',
remarkType: d.remarkType,
@@ -2385,6 +2527,9 @@ export function ContentPage() {
>
<Pencil className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-green-400 h-6 px-2" title="查看获客详情" onClick={() => openCkbLeadDetail(p.id, p.name)}>
<Users className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-amber-400 h-6 px-2" title="编辑计划(跳转存客宝)" onClick={() => {
const planId = (p as { ckbPlanId?: number }).ckbPlanId
if (planId) {
@@ -2404,7 +2549,8 @@ export function ContentPage() {
</Button>
</div>
</div>
))}
)
})}
{persons.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">AI人物 @链接</div>}
</div>
</CardContent>
@@ -2475,6 +2621,10 @@ export function ContentPage() {
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 神仙团队" value={newLinkTag.label} onChange={e => setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="逗号分隔" value={newLinkTag.aliases} onChange={e => setNewLinkTag({ ...newLinkTag, aliases: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Select value={newLinkTag.type} onValueChange={v => setNewLinkTag({ ...newLinkTag, type: v as 'url' | 'miniprogram' | 'ckb' })}>
@@ -2490,62 +2640,17 @@ export function ContentPage() {
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs">
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序(选密钥)'}
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : 'AppID'}
</Label>
{newLinkTag.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative w-44">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder="搜索名称或密钥"
value={mpDropdownOpen ? mpSearchQuery : newLinkTag.appId}
onChange={(e) => {
const v = e.target.value
setMpSearchQuery(v)
setMpDropdownOpen(true)
if (!linkedMps.some((m) => m.key === v)) setNewLinkTag({ ...newLinkTag, appId: v })
}}
onFocus={() => {
setMpSearchQuery(newLinkTag.appId)
setMpDropdownOpen(true)
}}
onBlur={() => setTimeout(() => setMpDropdownOpen(false), 150)}
/>
{mpDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-700 bg-[#0a1628] shadow-lg z-50">
{filteredLinkedMps.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-xs"></div>
) : (
filteredLinkedMps.map((m) => (
<button
key={m.key}
type="button"
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#38bdac]/20 flex flex-col gap-0.5"
onMouseDown={(e) => {
e.preventDefault()
setNewLinkTag({ ...newLinkTag, appId: m.key, pagePath: m.path || '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
}}
>
<span>{m.name}</span>
<span className="text-xs text-gray-400 font-mono">{m.key}</span>
</button>
))
)}
</div>
)}
</div>
) : (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : '关联小程序的32位密钥'}
value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId}
onChange={(e) => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
}}
/>
)}
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : 'wx...'}
value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId}
onChange={(e) => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
}}
/>
</div>
{newLinkTag.type === 'miniprogram' && (
<div className="space-y-2">
@@ -2564,7 +2669,7 @@ export function ContentPage() {
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram') payload.url = ''
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setNewLinkTag({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
setEditingLinkTagId(null)
loadLinkTags()
}}
@@ -2584,6 +2689,7 @@ export function ContentPage() {
setNewLinkTag({
tagId: t.id,
label: t.label,
aliases: t.aliases ?? '',
url: t.url,
type: t.type,
appId: t.appId ?? '',
@@ -2626,6 +2732,7 @@ export function ContentPage() {
setNewLinkTag({
tagId: t.id,
label: t.label,
aliases: t.aliases ?? '',
url: t.url,
type: t.type,
appId: t.appId ?? '',
@@ -2644,7 +2751,7 @@ export function ContentPage() {
await del(`/api/db/link-tags?tagId=${t.id}`)
if (editingLinkTagId === t.id) {
setEditingLinkTagId(null)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setNewLinkTag({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
}
loadLinkTags()
}}
@@ -2659,10 +2766,6 @@ export function ContentPage() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="linkedmp" className="space-y-4">
<LinkedMpPage />
</TabsContent>
</Tabs>
<PersonAddEditModal
@@ -2673,6 +2776,7 @@ export function ContentPage() {
const payload = {
personId: data.personId || (data.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)),
name: data.name,
aliases: data.aliases || undefined,
label: data.label,
ckbApiKey: data.ckbApiKey || undefined,
greeting: data.greeting || undefined,
@@ -2716,6 +2820,56 @@ export function ContentPage() {
}
}}
/>
{/* CKB 获客详情弹窗 */}
<Dialog open={ckbLeadDetailOpen} onOpenChange={setCkbLeadDetailOpen}>
<DialogContent className="max-w-2xl bg-[#0f2137] border-gray-700">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-green-400" />
{ckbLeadDetailName} {ckbLeadTotal}
</DialogTitle>
</DialogHeader>
<div className="max-h-[450px] overflow-y-auto space-y-2">
{ckbLeadLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : ckbLeadRecords.length === 0 ? (
<div className="text-gray-500 text-sm py-8 text-center"></div>
) : (
<>
<div className="grid grid-cols-[60px_1fr_100px_100px_80px_120px] gap-2 px-3 py-1.5 text-xs text-gray-500 border-b border-gray-700/50">
<span>#</span>
<span>/</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{ckbLeadRecords.map((r, i) => (
<div key={r.id} className="grid grid-cols-[60px_1fr_100px_100px_80px_120px] gap-2 px-3 py-2 bg-[#0a1628] rounded text-sm">
<span className="text-gray-500 text-xs">{(ckbLeadPage - 1) * 20 + i + 1}</span>
<span className="text-white truncate">{r.nickname || r.name || r.userId || '-'}</span>
<span className="text-gray-300 text-xs">{r.phone || '-'}</span>
<span className="text-gray-300 text-xs truncate">{r.wechatId || '-'}</span>
<span className="text-gray-500 text-xs">{r.source === 'article_mention' ? '文章@' : r.source === 'index_lead' ? '首页' : r.source || '-'}</span>
<span className="text-gray-500 text-xs">{r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</span>
</div>
))}
</>
)}
</div>
{ckbLeadTotal > 20 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button size="sm" variant="outline" disabled={ckbLeadPage <= 1} onClick={() => openCkbLeadDetail(ckbLeadDetailToken, ckbLeadDetailName, ckbLeadPage - 1)} className="border-gray-600 text-gray-300 bg-transparent h-7 px-3"></Button>
<span className="text-gray-400 text-xs">{ckbLeadPage} / {Math.ceil(ckbLeadTotal / 20)}</span>
<Button size="sm" variant="outline" disabled={ckbLeadPage >= Math.ceil(ckbLeadTotal / 20)} onClick={() => openCkbLeadDetail(ckbLeadDetailToken, ckbLeadDetailName, ckbLeadPage + 1)} className="border-gray-600 text-gray-300 bg-transparent h-7 px-3"></Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -28,6 +28,7 @@ import { getCkbDevices, type CkbDevice } from '@/api/ckb'
export interface PersonFormData {
personId: string
name: string
aliases: string
label: string
sceneId: number
ckbApiKey: string
@@ -46,6 +47,7 @@ const SCENE_ID_API = 11 // API获客固定
const defaultForm: PersonFormData = {
personId: '',
name: '',
aliases: '',
label: '',
sceneId: SCENE_ID_API,
ckbApiKey: '',
@@ -65,6 +67,7 @@ interface PersonAddEditModalProps {
editingPerson?: {
personId?: string
name: string
aliases?: string
label?: string
ckbApiKey?: string
remarkType?: string
@@ -105,6 +108,7 @@ export function PersonAddEditModal({
setForm({
personId: editingPerson.personId ?? editingPerson.name ?? '',
name: editingPerson.name ?? '',
aliases: editingPerson.aliases ?? '',
label: editingPerson.label ?? '',
sceneId: SCENE_ID_API,
ckbApiKey: editingPerson.ckbApiKey ?? '',
@@ -234,6 +238,15 @@ export function PersonAddEditModal({
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">@ </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如 卡卡, 若若"
value={form.aliases}
onChange={(e) => setForm((f) => ({ ...f, aliases: e.target.value }))}
/>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
@@ -391,7 +392,7 @@ export function DashboardPage() {
<div className="flex items-start gap-3 flex-1">
{p.userAvatar ? (
<img
src={p.userAvatar}
src={normalizeImageUrl(p.userAvatar)}
alt={buyer}
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
onError={(e) => {

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import {
Users,
@@ -1108,7 +1109,7 @@ export function DistributionPage() {
<div className="flex items-center gap-2">
{withdrawal.userAvatar ? (
<img
src={withdrawal.userAvatar}
src={normalizeImageUrl(withdrawal.userAvatar)}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -48,7 +49,7 @@ export function MatchRecordsTab() {
const UserCell = ({ userId, nickname, avatar }: { userId: string; nickname?: string; avatar?: string }) => (
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => setDetailUserId(userId)}>
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
{avatar ? <img src={avatar} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
{avatar ? <img src={normalizeImageUrl(avatar)} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={avatar ? 'hidden' : ''}>{(nickname || userId || '?').charAt(0)}</span>
</div>
<div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
@@ -145,7 +146,7 @@ export function MatchRecordsPage() {
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
{r.userAvatar ? (
<img
src={r.userAvatar}
src={normalizeImageUrl(r.userAvatar)}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
@@ -170,7 +171,7 @@ export function MatchRecordsPage() {
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
{r.matchedUserAvatar ? (
<img
src={r.matchedUserAvatar}
src={normalizeImageUrl(r.matchedUserAvatar)}
alt=""
className="w-full h-full object-cover"
onError={(e) => {

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -363,7 +364,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
{form.avatar && (
<div className="mt-2">
<img
src={form.avatar.startsWith('http') ? form.avatar : apiUrl(form.avatar)}
src={normalizeImageUrl(form.avatar.startsWith('http') ? form.avatar : apiUrl(form.avatar))}
alt="头像预览"
className="w-20 h-20 rounded-full object-cover border border-gray-600"
/>

View File

@@ -34,10 +34,12 @@ import {
Smartphone,
ShieldCheck,
Link2,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
interface AuthorInfo {
name?: string
@@ -70,6 +72,14 @@ interface MpConfig {
minWithdraw?: number
}
interface OssConfig {
endpoint?: string
accessKeyId?: string
accessKeySecret?: string
bucket?: string
region?: string
}
const defaultMpConfig: MpConfig = {
appId: 'wxb8bbb2b10dec74aa',
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
@@ -94,6 +104,14 @@ const defaultSettings: LocalSettings = {
ckbLeadApiKey: '',
}
const defaultOssConfig: OssConfig = {
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
bucket: '',
region: '',
}
const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
@@ -101,7 +119,7 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
@@ -112,6 +130,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 [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -135,12 +154,15 @@ export function SettingsPage() {
featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string }
mpConfig?: Partial<MpConfig>
ossConfig?: Partial<OssConfig>
}>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
const s = res.siteSettings
setLocalSettings((prev) => ({
@@ -211,6 +233,13 @@ 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 || '',
},
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
@@ -273,6 +302,13 @@ export function SettingsPage() {
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
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" />
API
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
@@ -541,6 +577,85 @@ 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">
@@ -639,6 +754,10 @@ export function SettingsPage() {
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
<TabsContent value="api-docs" className="mt-0">
<ApiDocsPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -652,7 +653,7 @@ export function UsersPage() {
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
<img src={normalizeImageUrl(user.avatar)} className="w-full h-full rounded-full object-cover" alt="" />
) : user.nickname?.charAt(0) || '?'}
</div>
<div>
@@ -1016,7 +1017,7 @@ export function UsersPage() {
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={m.avatar}
src={normalizeImageUrl(m.avatar)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
@@ -87,7 +88,7 @@ export function VipRolesPage() {
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={m.avatar}
src={normalizeImageUrl(m.avatar)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (

View File

@@ -1,4 +1,5 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -380,7 +381,7 @@ export function WithdrawalsPage() {
<div className="flex items-center gap-2">
{w.userAvatar ? (
<img
src={w.userAvatar}
src={normalizeImageUrl(w.userAvatar)}
alt={w.userName ?? ''}
className="w-8 h-8 rounded-full object-cover"
/>

View File

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

View File

@@ -6,8 +6,8 @@ GIN_MODE=debug
APP_VERSION=0.0.0
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soul.quwanzhi.com

View File

@@ -17,6 +17,7 @@ require (
)
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@@ -2,6 +2,8 @@ github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBj
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

View File

@@ -108,6 +108,15 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.Chapter{}); err != nil {
log.Printf("database: chapters migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
log.Printf("database: user_rules migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
log.Printf("database: user_tracks migrate warning: %v", err)
}
seedDefaultRules(db)
seedHistoryTracks(db)
fixBrokenImageUrls(db)
log.Println("database: connected")
return nil
}
@@ -116,3 +125,74 @@ func Init(dsn string) error {
func DB() *gorm.DB {
return db
}
func seedDefaultRules(d *gorm.DB) {
var count int64
d.Model(&model.UserRule{}).Count(&count)
if count > 0 {
return
}
defaults := []model.UserRule{
{Title: "注册完成 → 填写头像", Description: "用户完成注册后,引导填写头像和昵称", Trigger: "注册", Sort: 10, Enabled: true},
{Title: "完成匹配 → 补充个人资料", Description: "完成派对房匹配后,引导填写 MBTI、行业、职位", Trigger: "完成匹配", Sort: 20, Enabled: true},
{Title: "首次浏览章节 → 绑定手机号", Description: "点击阅读收费章节时,引导绑定手机号", Trigger: "点击收费章节", Sort: 30, Enabled: true},
{Title: "付款 ¥1980 → 填写完整信息", Description: "购买全书后,需填写完整信息以进入 VIP 群", Trigger: "完成付款", Sort: 40, Enabled: true},
{Title: "加入派对房 → 填写项目介绍", Description: "进入派对房前,引导填写项目介绍和核心需求", Trigger: "加入派对房", Sort: 50, Enabled: true},
{Title: "浏览 5 个章节 → 分享推广", Description: "累计阅读 5 个章节后,触发分享引导", Trigger: "累计浏览5章节", Sort: 60, Enabled: true},
{Title: "绑定微信 → 开启分销", Description: "绑定微信后,引导开启分销功能", Trigger: "绑定微信", Sort: 70, Enabled: true},
{Title: "收益达到 ¥50 → 申请提现", Description: "累计分销收益超过 50 元时引导提现", Trigger: "收益满50元", Sort: 80, Enabled: true},
{Title: "完善存客宝信息 → 进入流量池", Description: "引导授权存客宝信息同步,进入微信流量池", Trigger: "手动触发", Sort: 90, Enabled: true},
{Title: "浏览导师主页 → 预约咨询", Description: "浏览导师详情页超过 30 秒,引导预约咨询", Trigger: "浏览导师页", Sort: 100, Enabled: true},
}
if err := d.CreateInBatches(&defaults, len(defaults)).Error; err != nil {
log.Printf("database: seed user_rules warning: %v", err)
}
}
// fixBrokenImageUrls 修复数据库中 URL 缺少冒号的脏数据("https//..." → "https://..."
func fixBrokenImageUrls(d *gorm.DB) {
cols := []struct{ table, col string }{
{"users", "avatar"},
{"users", "vip_avatar"},
{"author_config", "avatar_img"},
{"mentors", "avatar"},
}
for _, c := range cols {
res := d.Exec(
"UPDATE "+c.table+" SET "+c.col+" = REPLACE("+c.col+", 'https//', 'https://') WHERE "+c.col+" LIKE 'https//%'",
)
if res.RowsAffected > 0 {
log.Printf("database: fixed %d broken URL(s) in %s.%s", res.RowsAffected, c.table, c.col)
}
res = d.Exec(
"UPDATE "+c.table+" SET "+c.col+" = REPLACE("+c.col+", 'http//', 'http://') WHERE "+c.col+" LIKE 'http//%'",
)
if res.RowsAffected > 0 {
log.Printf("database: fixed %d broken http URL(s) in %s.%s", res.RowsAffected, c.table, c.col)
}
}
}
func seedHistoryTracks(d *gorm.DB) {
var trackCount int64
d.Model(&model.UserTrack{}).Count(&trackCount)
if trackCount > 5 {
return
}
// 为所有已有用户回填 register track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_reg_', id), id, 'register', created_at FROM users
WHERE id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'register')`)
// 为已绑定手机的用户回填 bind_phone track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_phone_', id), id, 'bind_phone', updated_at FROM users
WHERE phone IS NOT NULL AND phone != ''
AND id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'bind_phone')`)
// 为有订单的用户回填 purchase track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_pay_', o.user_id), o.user_id, 'purchase', MIN(o.created_at)
FROM orders o WHERE o.status IN ('paid','success','completed')
AND o.user_id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'purchase')
GROUP BY o.user_id`)
log.Println("database: seeded history tracks from existing data")
}

View File

@@ -137,7 +137,7 @@ func MiniprogramLogin(c *gin.Context) {
"id": user.ID,
"openId": getStringValue(user.OpenID),
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"avatar": getUrlValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
@@ -215,7 +215,7 @@ func MiniprogramDevLoginAs(c *gin.Context) {
"id": user.ID,
"openId": openID,
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"avatar": getUrlValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
@@ -251,6 +251,18 @@ func getStringValue(ptr *string) string {
return *ptr
}
// getUrlValue 取字符串指针值并修复缺少冒号的 URL"https//..." → "https://..."
func getUrlValue(ptr *string) string {
s := getStringValue(ptr)
if strings.HasPrefix(s, "https//") {
return "https://" + s[7:]
}
if strings.HasPrefix(s, "http//") {
return "http://" + s[6:]
}
return s
}
func getBoolValue(ptr *bool) bool {
if ptr == nil {
return false
@@ -730,6 +742,9 @@ func MiniprogramPhone(c *gin.Context) {
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
// 记录绑定手机行为
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
// 绑定手机号后,异步调用神射手自动完善标签
AdminShensheShouAutoTag(req.UserID, phoneNumber)
}
@@ -892,7 +907,7 @@ func MiniprogramUsers(c *gin.Context) {
item := gin.H{
"id": user.ID,
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"avatar": getUrlValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"vipName": getStringValue(user.VipName),
@@ -931,7 +946,7 @@ func MiniprogramUsers(c *gin.Context) {
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getStringValue(u.Avatar),
"avatar": getUrlValue(u.Avatar),
"is_vip": uvip,
})
}

View File

@@ -84,7 +84,18 @@ func ossUploadFile(file multipart.File, folder, filename string) (string, error)
}
return host + "/" + objectKey, nil
}
return signedURL, nil
return normalizeOSSUrl(signedURL), nil
}
// normalizeOSSUrl 修复 OSS SDK 可能返回的缺少冒号的 URL如 "https//..." → "https://..."
func normalizeOSSUrl(u string) string {
if strings.HasPrefix(u, "https//") {
return "https://" + u[7:]
}
if strings.HasPrefix(u, "http//") {
return "http://" + u[6:]
}
return u
}
func ossUploadBytes(data []byte, folder, filename, contentType string) (string, error) {
@@ -133,5 +144,5 @@ func ossUploadBytes(data []byte, folder, filename, contentType string) (string,
}
return host + "/" + objectKey, nil
}
return signedURL, nil
return normalizeOSSUrl(signedURL), nil
}

View File

@@ -283,7 +283,7 @@ func ReferralData(c *gin.Context) {
activeUsers = append(activeUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"avatar": getUrlValue(referee.Avatar),
"daysRemaining": daysRemaining,
"hasFullBook": getBoolValue(referee.HasFullBook),
"bindingDate": b.BindingDate,
@@ -312,7 +312,7 @@ func ReferralData(c *gin.Context) {
convertedUsers = append(convertedUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"avatar": getUrlValue(referee.Avatar),
"commission": commission,
"orderAmount": orderAmount,
"purchaseCount": getIntValue(b.PurchaseCount),
@@ -336,7 +336,7 @@ func ReferralData(c *gin.Context) {
expiredUsers = append(expiredUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getStringValue(referee.Avatar),
"avatar": getUrlValue(referee.Avatar),
"bindingDate": b.BindingDate,
"expiryDate": b.ExpiryDate,
"status": "expired",
@@ -366,7 +366,7 @@ func ReferralData(c *gin.Context) {
"productId": getStringValue(e.ProductID),
"description": getStringValue(e.Description),
"buyerNickname": getStringValue(buyer.Nickname),
"buyerAvatar": getStringValue(buyer.Avatar),
"buyerAvatar": getUrlValue(buyer.Avatar),
"payTime": e.PayTime,
})
}

View File

@@ -286,12 +286,9 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if name == "" {
name = "创业者"
}
avatar := ""
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
}
if avatar == "" && u.VipAvatar != nil && *u.VipAvatar != "" {
avatar = *u.VipAvatar
avatar := getUrlValue(u.Avatar)
if avatar == "" {
avatar = getUrlValue(u.VipAvatar)
}
project := getStringValue(u.VipProject)
if project == "" {

View File

@@ -87,6 +87,10 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
admin.GET("/balance/summary", handler.BalanceSummary)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
admin.POST("/shensheshou/batch", handler.AdminShensheShouBatchQuery)
}
// ----- 鉴权 -----
@@ -180,6 +184,10 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.CKBPersonLeadStats)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)
db.POST("/user-rules", handler.DBUserRulesAction)
db.PUT("/user-rules", handler.DBUserRulesAction)
db.DELETE("/user-rules", handler.DBUserRulesAction)
}
// ----- 分销 -----

View File

@@ -0,0 +1,4 @@
-- 为 chapters 表添加 preview_percent 列章节级预览比例NULL 表示使用全局 unpaid_preview_percent
-- 执行: mysql -u user -p db < soul-api/scripts/add-chapters-preview-percent.sql
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS preview_percent INT NULL COMMENT '章节级预览比例(%)NULL 表示使用全局设置' AFTER hot_score;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -37036,3 +37036,25 @@
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 11 Mar 2026 06:58:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08B3A5C4CD0610CE0418F7CC8C5820D6C20428DABB02-0\r\nServer: nginx\r\nWechatpay-Nonce: f30d77e59498accda290d38f8606fa42\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: uV0fwT0jxeme73f39fNQwNgnuoYy5WC7dOv4q8iQ15N0eYAMq9RLuMA/fpMVu3+a0sJscocYueGa3ySKfFyKiYBzKxAsduudohBIePDZZ8lMJUdsBUgbDpgMg+wP3bKJG3RoHX0NcEMH5cEpIf5A+JpuVe2Kkwh4qpOsVV2Tm6w1vEeeYSX0vcusyhkzmXWTNlG8tJ89TP3wQohOAZqWJiLRDwYdLGBcrQQgrham1QHCdlIF9h3RcKLKDiZw5IYTo+PaSlWWkdycT5fI0PHAWC39O4WfsnT3LUEu8zbUnBukZxJujnSxYJNdFiTLkkrjRyKMt8cVw9oni63UOQhb0A==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773212339\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260309104414306664\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260309174649961512?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"K4AubFG6LBIplNcqAmdNCaYijgIeIpEl\",timestamp=\"1773212340\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"lxBWrCsMGjfx5CmdJfBNAo+5uPQqEONe3Tan0kQ28RdwhJlCAv0PoxuQ9yMpsh0jzTUypZLvt5FcUXGI2kLQ9liIuX4KiTQ2vFWztc0iiSF2FfDe4L9qxvcLhQIOwW+4c+RPIiZwZ5FRu0jLM/n4z+MrxaFh7Lj/rng/1y8TeHH1iBsK3JKQL4ZLDqxLWpaKnxPU6aVuqlZCH/EtUSppITmQaAC6T4oSSCBC7Iy1Vhh9zwPSkYwTxb5D8c0WGneZ0RG/bR2yRVGRGCAYdAryZbnjntVoyyUrsj6foX8JSuPqDf5/Rrj1Gv7qoe+KPhJpSGVcqvSEr887BcnbNswFpw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 11 Mar 2026 06:58:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08B3A5C4CD0610CC0618DD9C85AB0120D4FA1628BAD605-0\r\nServer: nginx\r\nWechatpay-Nonce: 1b90fd05c10ad4982ea8a0883207c8a8\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: VAuHofjAteRYLAXA9ORZG3MbnmMN3P4kHf4zOEAyrOvIBVIQOHism5yLUY8Hqz0uu52D3+2wfUo8oUFWGOzs3MaRXwfkdJIX/ls304pCtVExop53F+edQV06tS7wfWY1Pe4rkFzP/qinCYiUfwh/yWcAMwyLh9g0wUvToLUQ2IP8ujFNIx+zuReX7Kk68OsfNZSFvP4Yfr8KfRYeElXhYgOenkWrOa02apbByykArWHrJ+1Mhcb2lnmoLzaIoKGwMZsZlW6Tkn6bzZSZpTY2aurni4AWcxUO7+UzhXIahYJaWBAhcYZTXsYc1kAtvdn6ajfcy4VzQ09rvaJ5KUUwHg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773212339\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260309174649961512\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T14:11:55+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315141037944611?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"iWx7OH51YJvYclYjRCMcbuw8Js76kR1q\",timestamp=\"1773555115\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"jjzQ9YgQcL+PMQmgWcUmKFauIX/NaIv0l+Aw0CFIFQ4NNDckGtyn/DVNTCukBw2Mr7gNfqAeuWZCOOrFiVxW7XVQ9QCS8Jr/0iaSr7T6pKnmkkqP5J0p5ZaVSstz1jLQ6havaPYkclaMJ/0xGsGCMe+6Jykovz9h9oVpAQ/0xyewGu00Dm9XUXU5axedTq7MDjxVlvUN1H+hwFi7KN0aKPbEQp31SODLGcrSpZMPeOnDNmUObCM1toN8ngRjUNVcWsT+IMURhEaLSwYIY/PAAoOFHPYvUmcUJ3OPY2U5oHUfnzW6p64eoNHbZWcV4IUkVAcvCvFVoy0ZB1JwovwdJg==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T14:11:55+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 06:11:55 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08AB9BD9CD0610BD02189082F8AF0120889A10289BE301-0\r\nServer: nginx\r\nWechatpay-Nonce: c178787d3b3c26816a03d7be7c920c7a\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: jBO3SX+HRbleBSBWBDVp6JA5sexewzf5aIE4UmuosNKxbGMv8H/1wpOSbSmuv7RkEIZ0DdBJtvcAeIXzfc8HgPJgrdwviczNUoEuJ6G8nXNd0nqtcnNCu8WHI3NaMQHgTxi05yBIyTEcqGH8oP5C7ARoTzH8ukT2kMfiJJk7zCYdf6iotZviPpq73hBBFG/6m/fpsCeWhivhmCy9DuGFS6aq3wA8KBro/VhIvnz1i2pCR6C1Saro+Xx6dy2MEWs/uTJDnwBDnY/0+5+VLHHAwQeXlZ+TxD1KBHT5A6Qh3OvPNJmrk95qEQ80PwUvbABueN8lnH/lgWrX0W0/FQqKbg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773555115\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315141037944611\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T14:20:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315141037944611?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"f96nzpuPx0ZY9Gfypj7m6OZkRDVcR2zp\",timestamp=\"1773555623\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"fU9SNRj2o4Y5j+m+ZCt2sUiJcjUvKROj6Rp25yb4W6Ir2dZINqLMb+wWKWHl8g86K8syuJHfdPaHCbaE7zW+9NVS6cu2Q0+LGHdR3yIlzvG797JijefPjqRTgrFnHO7ovVpDCIB+cTdZmnB2VpsVp6IJmTeX2IksPom0/xEnVEu7rCeKMwlDy9eFI+Fga9OtSpOAxEPBvCMW7JdFJlhWQva3k82hLcAhfbKO0qKY8srB+ik37Q2Rxm/3r6vjXXHO+UL825/4jhs0y8q8pJ6aJgvpXKeOXnuyHuakvJ/zSZQql/fvNkp0+gzYlYrlClgJ6Mqs7b80bBPbyOCICD+Iig==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T14:20:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 06:20:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08A89FD9CD06106818BDADDD5C20D69E11288D8304-0\r\nServer: nginx\r\nWechatpay-Nonce: 62476cb6ae510433a636dc4b95730e21\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: e2xIXN3wYfKwYOHrSsoB8WVBmO6YamU970H7zHd4Zu517h4BAE7TxxWW4mvyBx7SY2liIdSfagbBDNFqE51IPsxUn4QCfFeYJgywtvhztNIWJkuV0QSDJ+6xeB8xLUYI+nlDSS5TS4r/df0d7NdGPsNNRYkiguD3B8dFbyBxbyv+eUhLgDzArKBKql0qo5GzSq3hfru6RA4Irl6C6nenRJt3fMigKZlv29DUS4zSslunKE0Oq84T3EhYAjHJDvgyWHEmR/dtm0h9wUpIuoMN9WMsfwoU+YkwQ4Nltv7BE4rof4qNQbxwjTlcIoLymHYb/yYOskZwrm8qhUKkdJ0f7g==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773555624\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315141037944611\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T14:30:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315141037944611?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"hZvllHIFlPla4AKx1yfoz0YGsrZU50VF\",timestamp=\"1773556224\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"cZNNjRZFsEo0kc6o1lJ64xsdWaaM98anf1csM7w0xz6PYwvSRpURGTXoNyrjK95XBGWjO59IO5S48MeMRiEBlzrdNiFPMpgk34Xrcj9zcUFMweXQf+zhcsUQRJTXmc8oZeJSgIQkm6MOmm7goSt8IuojhclQVolHEI+x2h+4aBvdnNtpJyA2ThKPDU6EcZuEHkrwoCDj0U4zuW9Uqjaz13XRQPkpruY/q1c1Gfnh1znjLhzC/l+qCOX9w542QKiIDlEc+Ok76kY1HXbLcbJPolV4Z9lajwZIRrNeoflGGEmSZE3/TMuIFjuxfaDqqAroPn0a606Skvb8794rotpInw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T14:30:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 06:30:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0880A4D9CD0610940318F2E6EE5C2084DC3728D8BD05-0\r\nServer: nginx\r\nWechatpay-Nonce: 2b14ea720f4eb13de7bfb54b648101d2\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: ncq3XWh8HW8Q5RkiwVLzOst96bKTicI2kugP6TcXm5euzf8M2AJNDaAjlj7TuB4ivbv+Af9FPx7lF5kuKC+X3qt2LqNgCZD5/whWUjE5YWGlomdVmW9DaNfY43aij/qYdXrYL2BgU0bMxyZV6XoHaVqN6STplIGzy0X3v4Q67Cs2bKOsqXuLWJpKCb0rQ7dnJxGp4orrnzLfzq175wSF++aLXEBCotwZ7FqI8bCT5WkpNvoDo8OSZVN3SAjZ9w1qY/obVgQ+Mlr9cqb6PTObdc748r/fInW0zDZ2xF+KUGLjTmtDezqsx9lNRtela/XcChDfUQT70CvaarpHQwpT9w==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773556224\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315141037944611\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T14:35:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315141037944611?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"q4QvA4bjIf9hWrNX079AmtyLvVY5p9Pa\",timestamp=\"1773556523\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"UM3H1asM5bOcvRzRo7AmIrTmqqZveAgAn3VeIWi0gn1ohV6mKLAPG5hFtpBGiMgGze0en6g8XZnIeHlwjBAkP7jrrMEeEhr0rSqChPqDaKfxgDQgzNSqXc4GlfjH7kiRpxl/dYwB+MInFjfr31F5fj3KsG61cvzuxYyMaJ+VN9x2+3EWe2ZugIjSMZ2Vp/5xc1R0AMBbG1qCdPP9DNLYoJdygG9ZPOkiGaQRDTMGIKOhGQ9JdAlEO23o98hbo8lJ2lHDJQnoWlkbSelE3Uezb1hxRlczMzjgcsSsYF7IAz6RI84GmqVUuom68v6zOS90xnzP53jU0guSAVuU2EntdA==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T14:35:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 06:35:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08ACA6D9CD06105118E6B2F8AF0120E6AA0328CCDF03-0\r\nServer: nginx\r\nWechatpay-Nonce: 5d9fc4d662621401c218223ca1629cb9\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: Hh+4OuRpHi2xJa0DaVXxck6iDV8TOcRdH5rLjX7NvxQ9sghbbo5yIz11c6DTcRc0y8wmInZtXijrB5IO5/pDDDIGySKYtSyS+X9ScxizDjM3geqvyqpeniLZKt1MGt9Xwcg66uIlyOwNBwBH/iTzCSTc3IfDJj5pOsG54lwJfiEu9YYONxViAHQvwz8AgbKucm0lwKSpxSyIyx9ELj71BQIoVsxlP+r6FKZO268wmPRIJixubXIruTWRpehLMIkPfdOTs9phSoO4Q3Q7P2Wod82RRaEDFidGJwddQuVMzldojd4FJ2fp4g8Mj+yFtrFDKIT6WBUqC2z034YdXmRmzA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773556524\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315141037944611\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T14:40:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315141037944611?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"tTR3vHoi89SfasJHfP7Kr5IIhoGx8ORH\",timestamp=\"1773556823\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"BNTHdNgVzNxuiX/KDj4HKi5Vo56cCNyzForuzUF8XaB5kRhDdLqiWcRtELnCgmLalkXFbFgEbDQebkK3eff2SKYsmS3HWpXA6+fnG9kO1ll257iuC95Ibuv1dR1+c4Xt9Uvv8fmylX/e2VQgIFxXu/wk/WSpdDfMkFpg9E/YQXYuDQdSG9aE9gMWHANbm4eMb6yLwYJMQX0tXIx1RTZfat15KC4KO1iCPeY+TSOBhjbQ70gvph3PnENANCurq8lbUC5e1nC7ph7fNxIJU8AQlSFABEZEJDW/GaycZ94pBaHdEK9CaeARJnV9OExQlKvj6Ja3g/pXIj0C7u9/DTnWnA==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T14:40:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 06:40:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08D8A8D9CD06104918F7ACB1A80120E6C61928AC72-0\r\nServer: nginx\r\nWechatpay-Nonce: d86186d813e4c53e3893fc87ef0d6210\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: mi7hDCDMBJwUWxqeAl5HYNDJD0nYXqYUN2JT+GDS8+wwHl0HZedw3sRmAsXWqf+JiTGGHS+J7lS3jdCixAbArzLywgovtPKPInq6ND3J1bZc7a+DcaoSf1dIsUTXvN1EAwfeM9xlQL3hspUivIBdM0cdHVoKmXJDcnX6L1F4FWFYu6o4fp7eeQ4Dpw2PvN5F2K1n2Jb0xSGsnkehoc+mHiJaJLBW82Q2x7JSkwwJV4IjP5wVAhMET7VISdmD/9O17ZNxy+blLTT/S+XkvAoQv/80d6Dhr7fwEygW7iEhbUpqWCGpoGRgXdoagi5931GO0JasAdjcV0pj1D7H2aAqyw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773556824\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315141037944611\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:00:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"FDi6T5ZfC4PznIj81ez0j6zZo09ROb6t\",timestamp=\"1773572424\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"BnUlxGWS8P5haaHe9OG2lJLkwyhGTuwE66Yaa3gB6MOWgXGpD8eagkNqG5F3FpPAjNCeNqNpvRt4ImdoheG6Y1jKtNO82ilIDbEhmXCfMa06Cvt/SAUvPI+nNisDJfgmxZzrBweeAKucQbLWnZqoN5b1WwiHH7TvNJjwlGCLOliUCAVw5fV5LxpFyVhhpoWY0MSfUb5KK+CZ4N5RNVs7FBASKGv99R4c9f/Bumc5gbxVJR3VE1EODyqLEYPvR9iE25PdYw8TzqEIJsL2Y+pwoYdQom5z0fKzpfBjiK9BzcgMSpQwwu5WEOGhvyq/ARKiIFH82leRPQkcToUPBzidHA==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:00:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:00:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08C8A2DACD06109B0218AFCBC055209AA21528C7D305-0\r\nServer: nginx\r\nWechatpay-Nonce: f387513c5bd46d404178ce1fe2e8da09\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: cnldNIlBCuhHaYwgYWBEOTe60ZJq0G//3N1JEIDdnCPjL074fFVsJ0x1xiV0wYnRnn6JnA0x3fnM7M2NVsIEzoT3DCkGskzx0GND4OJDGA4BvA5xLGkTqkUIKDKGxktQDCDL9E93BMqx6LIgZAeFPpEpeYEdaQmTc66PDxhM9hwYcd8k+dfYESryG9/pUn384GGHhBoTrsMwkJ6FG1CQ65UL6qngqPcWrnQZYWYRm+vhoMfNbU43DuJDGSu/PNV6gZxMQFpMvGptRrH8EyXRX1JSWgdmkqUT3tmnBJrEl7t9El4PpD8tUL/deS2FM6urtABplmApHE1GMVMIKDNCZg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773572424\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:05:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"i4NBj8zjZu8txGtH8q2MPlgEXQEKdsG1\",timestamp=\"1773572724\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"ILHz0rHmwcUvxfRvLLHNLim5nNPl8k1JH+yTcFrUp3fLSte0dg3WYL4OzXauT+1tfX3DsfGxczuj5bBUi5g9JDRJCqrPb1BS5EZfS5WKisVxCIlHn+www5F/o0uTSsRClAehoNI37hFvy0hDpHxcSaYIm4S6j0C68Hlwb+nlKuk8xm46f0VYtbEsPG1zkGbtdUV55uC9VSAVnDuJ9Ry5PMIiZV5WKPXUOG2eoYd2fTM8m/+eBsvTBeEoi0O3WsLXjZ7LLuHusKX7ncIlyQx5KGVobZKbariMq9B/iwyd4RcP+u1gk1ZLrYKNiq2M0U5BUlHoVPFOidyGhe2Ez0XdFA==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:05:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:05:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F4A4DACD0610E90418F7E6F8AF012088D52A28DF8602-0\r\nServer: nginx\r\nWechatpay-Nonce: 19c346111b94c33fc58ea8a2830f2629\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: pUsztK1edtMB+RDxsyLADwLJvn4DGoeyGP5qXYnCecqlgKOkmHxPFDG1Bety7M+zLClIswkdZsIMb0baj/97phW45jzLLc09htL5EBuFvqlgDeM26bg0IAK6aeb55A2k+MDVflXRag2gnxI94YRb7WJtIZew0A+dpVBS5pCvB+IlM9V3nm3a55P2TShGdqWCtK095mi1990rphd3rsP9clrny6eyzgIe2S0Peso6Axgw2XOWp47g53SgKzqafPIOb2ypTxXEudy5QQtOv6UudRQ//IgXCniEvvsXuU0LLJvO9GTWSaloJvsjYlTQ1/g74oJljVaePYk0MjoPm0iSWw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773572724\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:10:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"DvRanrWZn9MTVhOjfDr2vEl0mtogpM3a\",timestamp=\"1773573024\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"4TSL4NYEhGMBXMwmCxrXc9/mEHGmFob0mtH1bqEnkcNIKXrI+e1YjrbN4FgjxFVOdEemjWpsWEuMruBOw7Nr1dPMo6h/2bk08tldEYC5oa4P1dZwqyaCsshvhdb3kj0EmHJF4NqcirS+H0U8R1cOnWdbtDIkYmOABuvlas/506cp3QsUHnEEMlUADBvk5M+c6ZggMLoZ1r6qNgwZy1tPE8V2pCBaQpFQCZEVc0mK853OGMH0H+bQGkkvW5yHtBQ5JZzYGxwop4v/+b10afZLrxQVkeNrJXrxTVawdug9miznqfREXONEs/IzoADtftQBqaCVE5XfLlnBjEux5V8qSg==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:10:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:10:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08A0A7DACD0610CE01189EC98C58209AA40628F0DA04-0\r\nServer: nginx\r\nWechatpay-Nonce: 3b89331b2c09bfef8d9bbab1c79a4f40\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: h8I37Pxegw8jAsRX8SM8xH1cUQWk5B8vgcibi42YvDogbuFWNhy2WJt77rZJvwUMLScTLCW+nooVLRgeVfdzRvTgNHTmiTszZNX9mZG/3MXzeIVFvIDHSN4+XCWh5dOSeM3Metn39ScHyc+z99MCCl0uys3yBHp5GVopCtA7Sz+yOxqwzTsplZCJ4FBRTCHb5CP9HGCEjOWgZoxforpYAi3cjJjDE2EUhEt0PbftqtjBFQIkBnuGLGIkoscAnYFCsO24twdtkW49LljJJdQdjqfBmg0OiB+EXhSGu/g8OpzrmaXgETRW9V1aunsu4dfl+Wf2cm/UjXGGL5+wnrk6/g==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773573024\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:15:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"FMhGPGrBDo0lYbxvtrjHncgkOZY42GSL\",timestamp=\"1773573324\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"ssM0BVDz2SPRoJzuXvju6A14u7NREbRA3K5xBGlXUXNZd1ZZmxE+p1FETp/sK0Grp9efcCC58bvI7aAA1uwK4m+XLDnCwXQsgfM+IYOFLyMCaCkelkITwnSxsGURGejQXV8z1SwjHVf/SQC5se2CvtnQD7FvlyuJeUSjz/ImFBYwwfkQYIvzVGJuH2f+GTsN006m8k5Q1wYZ3L12WAl35LANB0X4DaPY6E1voui5F3O35Np+IllWrFWtibN6KCjsxRpU+z34/1Nl5t+yLIcD1X82N3Ei4eOv8p/o5BATR+ZWPW8MnqpoVGQjbsHeA5THGrlhCzMUFeMjGllaDsy2Eg==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:15:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:15:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08CCA9DACD0610E2011892E7F8AF0120B6B91728F0B305-0\r\nServer: nginx\r\nWechatpay-Nonce: 2e1979b64386a6714158d7acf47fe9f6\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: c2yr1+eezL8dEuLmSeFwFrGsLq6c/4ZLhHVV1H2wb1cNLsTDAU9pARVTjXv3TweAK/6U4LkJpUlH9FTSDBjnsEGWHnWNDrRROQl1j4CTWrt3kTN8w+7UizCyrD+iHtRyUP5BCXC194BGWNKnT2B8PPli/VkmClwUAwctyo2Q/JtrOqezgddYXHnaQsYB/xJlOEta+0lmkQu3+yaGhcSDBS2iyBENpeN7DcWfM2GDBPkQBSM8fI9zIWfeqWe5YtJS28o+4EHqTc+rugPPE2B8Whfdt/ZcnbuA0apGNLuk5yjbmEQa6LQu1zFMuPtUuyjV6Z83ekm0krkVztmvvzoHzA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773573324\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:20:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Accept:*/*Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"ENQIRZeT5P92d0AsPlfItENoT6Nk35iZ\",timestamp=\"1773573624\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"6edmtb1e3zrRl5t6JaJG6JR2fveMSizcEr/7rRefBvceF1Y8++R7I5lRdv2lgbetK0CzwH3W3QVB8RmlTsUHvnoCcBpCgBuN5E4HqOFl7dgG6NthlTH5TNkc7XLVlA6DfpHBsouL6Q8be/ilsVehZgRNg1HD+O2WxSJsxm8AR5/TdczSNkC8EXrC3e94o1Tg+VpTB3Th/ATsWqYwUB3SHw4lfkh80P/m7VOqLrkr7o7zaP+pI2KGbteHTtq5vqa01UcNh1steba+ylqdgoSFY2ZIdfglAiqOooGzZmreNNA2PUBBbIGKNwj3XYctfpRP6k6zjlbmLakDick5ojnmJQ==\"} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:20:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:20:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F8ABDACD0610E30118E4BAC0552094A31228CEC602-0\r\nServer: nginx\r\nWechatpay-Nonce: b8040b9d45d038f2b5102c93eb9dee3f\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: j6Yvct7Leqk+Ou+zcDjz6P3LBecIKxnVbscTrG5qo4pLw0g+HM8HPbXMny88ws48FjNk6KTcX+a4nq1Ucf6yz/fDvwoZ5QrkQMRMZfSoK2jLqepBtpDOqRaVHQFy//OTPNCUOxIZr3y3BuvPslW4vyraBbI5Ty2nAoBRWuzSqasG8tW8nccPG9c/sqzJTzvo8QShrP9rUoj+s4pS8NiN9UJynYTAYxZmfo7tp8bjNjO8U8A5FKZrzMalUkOWi23w7FqG0aI6ByfrlqhC/wYL1obPk1Z4fR+SKQ+jsNjJHGFiD9+cX9zyo5ghhDAXE6HA4CT/opCcC+avw4y9L5t5qw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773573624\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-15T19:30:24+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260315185745222552?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"MTqVkziwhssIyW1M8iCrF16pkrtygMLn\",timestamp=\"1773574224\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"2VzE4uvRxrlkaV/WPapgk/zITa5Lk+ybPg6W9WK9faktT0411dQ2+JMvvDQTUnF/LmMAzvN6OKhojwhJ70u/AHRdbUjaX7pgleHCfBaSAtW7IbfLsx6PGPuevPoJVI2jtAIZztpYcLoaRhqclE4amdhsHDfjIzNPBZAC93JWwZDJVSeDsBrRrX1ndCqc4DcuX8O3UigUnKcpZwH454L4lJZCYCmB1TePBplyT6cYWjUv7x6v9dUxKBrzQEiZs8dJIfSsNSb+mMtyBcK3dWZRtjwQwRTHYGFlAuJt8z9GFrfwJDTA9wxyprbsTy1fP6AR1qpvTx3pAAcgYc7wFf56Xw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-15T19:30:24+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sun, 15 Mar 2026 11:30:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08D0B0DACD0610C40418BEE7F8AF012094991B28D3E801-0\r\nServer: nginx\r\nWechatpay-Nonce: e4708ff592ab6ae526a0db8185683a9b\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: dA7O8RH0g0BSHPG5VAvVRoUU0TTBkeEiWVLx3hSALD55aDJdbrFXvy+5qQBrI0XpQmzfJ1wOVlynanlme2IMjWjtk+zy5cy7GNiYDywpdVxEx+V03iDmZPuyplho8l1B4O2exKpkVNrd5YM1nX9v/hkfjeRCqMvrBPV/fhKszJnXtvURXA6obnaiiQ0Q9P5VF7MwMKLa2CwPo3tDuj9eqqbR0vZfKglyvTZprF1pOkT88G8FsEMFgc9pBq+PpmfRy0ec8rJtSDK6ou5Idbc6wOWdQHSwvG2MQ4g27g5aIqMuFx/tuu7lhl5fAShU1HEJVzmqZ+pMnhYvHbNP/gGCWg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773574224\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260315185745222552\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}

View File

@@ -0,0 +1,29 @@
功能五:
![](images/2026-03-15-13-48-10.png)这个 API 文档就整合到系统设置里面了那系统设置放到正下方退出登录的这个位置。跟退出登录这里保持一致API 文档整合到系统设置里面。
功能四:默认所有文章阅读比例
帮我检查一下。没有这回事,除非自己设置
完善一下这个编辑器的这个图片上传的功能就是我的标点在哪那个光标在哪图片就上传到哪并且得有一个进度以及这个链接编辑的功能链接上传以及编辑的功能这个得完善一下。默认预览比例是20%这个只有唯一的默认的预览比例唯一的一个条件没有如果这文章属性没有改就千万不要改这个是只有唯一的一个条件。那个是前500个字不是不要那个50%是怎么回事?确保所有的功能都是生效的,并且有微信。
[](images/2026-03-15-13-45-21.png)
功能四内容api接口
![](images/2026-03-15-13-41-54.png)这个内容 API 接口API 文档是跟内容管理的 API 接口,这两个做一个整合,是用来那个使用这个内容的。那我以后传 MD 文档更新一下这个文章的时候,文章发送的那个不直接发送到数据库,而是通过接口发送到咱们的那个编辑器里面来。
功能三:编辑器
![](images/2026-03-15-13-42-55.png)
那个上传的那个编辑器里面的得有一个进度条。上传视频和图片,如果上传比较慢,这个进度条也显示出来。
![](images/2026-03-15-13-46-08.png)那个有文章有修改,要及时检测,如果检测到 ad 跟减号的时候,包括接口进来的时候都是一样有传数据,通过接口传数据检测到有 @跟 #号,就要直接跟咱们后台的这个那个功能和的这个参数相匹配,嗯。这个是内容方面的一个
对,一个问题。
功能二:热度分
![](images/2026-03-15-13-38-39.png)
![](images/2026-03-15-13-39-09.png)
热度粉里面的那个热度那个不匹配。不匹配,那都分它,这个不在这个里面不匹配,那帮我修正一下这个热度分,以及这里面的那个编辑,这边插入链接旁边应该多一个 add 爱的指定人的一个列表,嗯,这个也没有,嗯,也帮我把这个完成一下。你帮我完成一下这个 @的放在这边@的指定的人放到这个界面的下面
功能一:
![](images/2026-03-15-13-35-04.png)热度的分数是按这三个维度阅读群众的底下这个算法叠加的那这个阅读权重这里展示的应该是百分之几10%、40%、50%,这个权重不是零点几,那这里面的分数这边热度是等于这几个值排序的值。那叠加出来的一个热度,这个排序特别注意一下。

View File

@@ -0,0 +1,6 @@
功能一:文章排名规则
这个文章排名算法权重是有问题的。文章排名算法的权重是有问题的。这排名的算法应该是这一个。排名的算法。文章的这个排名算法是有问题的文章的排名算法那个在上面的话就是直接就是第那个。首先是按排名来计算就是现在有三个维度一个是阅读权重一个是那个新建的权重一个是付款的权重分别那个阅读的排名就是。三十分。阅读的排名都取排名的前30比如阅读是30那个全面的话是20篇不是30篇。20篇那阅读的话20篇第一名就是20分第二名19第三名18第四名17第五名。石榴按照这种形式来进行那个它排名之后的一个排分第二个的话那个新新发表的排排分那新发表的排分也是一样最近新发表的是20分然后第二发表的是19第三37第64那个14那16第66。然后15 14这样那付款的也是同理从20 19 18开始。然后这里面的这个分数这三个维度的分数排分。这是然后默认排名的话是按这几个然后这里面的话有一个是当他的这三个每一篇文章有这三个维度加起来的总分数。加起来的总分数。就每一个排名的一个分数乘以相应的权重相加起来的作为一个总分数比如阅读和心度的权重跟付款的权重它乘以那个权重的比例。然后就是他们文章加起来的总分数来进行排序嗯。
![](images/2026-03-15-14-00-57.png)并且检查好文章内的热度,分男的都分的一个,留存文章内的一个热度分。在数据库里面都可以添加这个热度分。得清晰,

View File

@@ -0,0 +1,16 @@
功能四:文章内容解锁问题
加一个规则就是那个20%是不能超过500个字不是固定。前面还有一个规则就是那个如果20%超过500个字也是只显示500个字默认的不一定是20%是默认的这个百分比超过500个字。除非是手动测试的情况不然都是默认就是不能超过。500个字。
功能三:存客宝获取优化,迁移到前台
![](images/2026-03-15-11-48-00.png)可把这边的 TOKEN 都连接上,那这里的话是跳到乘客保证这页面就不要了,尽可能的把乘客保证的参数配置都配置在咱们的这里的这一个里面,场景获客的这个功能都配置到这个里面,并且把这个列表跟获客情况都列清楚。每一条、每一个人链接的的有多少个人?多少的获客把这些参数跟数据都放到这个页面里面,可以直接看看得到,可以在这个新增一个这个编辑旁边新增一个按钮来看这个功能。
功能二:
![](images/2026-03-15-11-38-40.png)
![](images/2026-03-15-11-39-07.png)a 内脏这个章节底下这个热度算法和第几名热度算法是有问题的,检查一下这个热度算法的一些情况,热度算法有问题,帮我处理一下这个热度算法相关的功能,然后包括这里的话热内容的排行榜都来排行,跟这个排名算法、排兵算法这一个没有生效。帮我处理一下这个问题。这一都没有生效,这个热度有问题帮我处理一下。
![](images/2026-03-15-11-40-29.png)
然后第二个的话就是编辑。编辑文章这里的话有一个那个热度分也是没有生效的,帮我全面的去检查一下,这个热度分也没有生效。然后编辑框这里增加一个 add 的功能,以及上传视频的一个功能,非上传视频支持上传视频的功能,视频的话上传到 OSS阿里的那 OSS 上面。iOS 在后台直接可以配置用卡洛的那个卡洛 AI 拿那个 OS然后检查一下 OS 的地址,在后台也可以直接配置,图片跟视频都传到 OS s 上面,阿里云的 OS s 上面

View File

@@ -0,0 +1,13 @@
功能二:用户旅程
![](images/2026-03-15-15-52-30.png)
长沙现有的数据库的行为里面直接完善。直接完善,然后把这个用户旅程给我,帮我弄好写清楚。
![](images/2026-03-15-15-53-00.png)确保用户旅程跟规则配置里面要有相应的数据,不要不是显示重新加载你这个数据得帮我显示出来,现在看不到直接帮我处理掉。
功能一:![](images/2026-03-15-15-51-08.png)
are if in 的算法,那个放到那个右上角这里,然后把这个里面咱们的相应的能计算的所有用户,关于用户行为能计算的和用户旅程能计算的都用 AI你来整理一下归类到 if、in 这三个维度来进行相应的那个 rf。m 的估值的一个评分,以及它可以对接存客宝这边的一个算法进行估值的一个评分。那这个是 FM 的估值的一个形式。
并且确保我这边估值的评分是有这个估值的评分的。然后并且这个算法可以按跟那个内容的排序形式一样,是可以做一个那个排名算法,就放到这个上面去 IFM 的估值的算法一样放到这个上面去,左右上角上面

View File

@@ -0,0 +1,12 @@
功能二:填写手机
能。使用匹配功能的话,一定要注册好手机号,一定要自己要注册手机号的那个田琪手机号和获得头像,那这个才能做匹配的功能,整体点匹配的功能
![](images/2026-03-15-15-55-33.png)
要确保后面提交的线索上面有那个手机或者微信号。
功能一:
![](images/2026-03-15-11-17-27.png)
匹配功能右上角有一个那个设置的这个标签,帮我把它去掉右上角这个设置的标签去掉。
所有的匹配功能。所有的匹配功能都是点一下开始匹配资源然后默认填写好这个。但点匹配功能的时候那个资源对接的时候就需要填一下自己的那个诉求完善资料点击匹配自由完善资料。完善资料跟头像那这个点击匹配默认都是那个3~10秒的时间再弹出来。

View File

@@ -0,0 +1,12 @@
功能一:数据统计
功能四:
这个网站上面的标签都需要有一个统计,然后在后台能实时能看到然后那个后台的那个小程序上面的美的标签的那个美的每一个点击标签的那个都做一次统计,看一下哪一个标签点击的统计的一个排行,那后台里面要看得到小程序上面每一个标签,每一这个动作的一个点击的排行,然后用按照我们分类的模块来进行分类。能知道每一个按钮的点击次数。以及这个是前端的,然后后端的也是一样,每一个按钮的按摩块的一个点击次数的一个统计。这个功能在数据概览的分类标签的功能底下。
![](images/2026-03-15-11-54-06.png)然后在这充值就没有把充值赠送这个去掉充值赠送的这一块去掉然后这个里面的话是您能充值之后那个。可以直接申请退款的那分别选择了金额就是。10块钱30 50。1,000把100改成1,000然后这个交易记录就是谁看看了这篇文章他能看得到这个叫那个阅读那个消费记录这个是我我的余额包括自己的一些交易记录也放上来。要确保这个接口小程序是可以直接支付的。
功能二vip会员
![](images/2026-03-15-12-00-17.png)
那个卡路创业派对的这一个界面的。界面的话第一个解锁全部章节 VIP 默认就是加入会员就解锁全部章节然后第二个的话那个。链接资源和匹配小伙伴放第一社交权利就是匹配创业伙伴然后第二个的话就是链接资源第三个才是那个加入创业老板排行。第4个才是专属的那个。VIP 标识。然后内容的权利的话就是解锁全部章节一年,然后。加入那个创业项目团队。第三个的话就是每日纪要的阅读权限。美乐派那个派对纪要的阅读。然后第四个的话,就是那个。那个自己就是项目,自己下有机就是项目。认可的项目可加入那个文章,获得合作伙伴的内容权益,然后加入考诺的创业派对,改成加入考诺创业派对 VIP 会员。然后下面那句一字加入尊享那个去掉,删除掉。不要有下面这一句话。

View File

@@ -0,0 +1,14 @@
功能二:
![](images/2026-03-15-15-45-31.png)老伙伴包括 AI 拓客数据,这里的话有点多,找伙伴和 AI 混合数据统计,这一个变成简洁整合一些,简洁整合起来一些。那把这些用户数据统计这一个变得更简洁一点,然后。更简洁一点,功能性变得更有效一些。但做一些优化迭代的用户统计的这个地方,那我能一目了然知道整个网站的一个情况。那把 AI。
![](images/2026-03-15-15-47-07.png)
其他的几个数据统计也是一样,统计的数据。统计的数据集中在一页,然后简洁一点,数据清晰并且有关联性能描述整个的整个网站以及这个用户体验,体现在用户那个使用那整个踏盘的一个数据上面。
功能一:统计
![](images/2026-03-15-15-43-56.png)
开户统计变成一个小标签,统计一下就可以了,代付统计的一个数值在那个收入里面。对,一个特殊的一个标签,然后里面统计的那个充值的余额在咱们的这个用户管理里面体现就可以了,还有多少余额就在用户管理底下,那提现有多少余额就好了,不要再不需要再额外在首页上面新增一个。
![](images/2026-03-15-15-44-38.png)今日点击这里的话,显示的就是今日点击这个就是点击数量,但是这个是按月的统计的点击数量。

View File

@@ -0,0 +1,45 @@
功能十用户rfm估值
![](images/2026-03-15-15-38-25.png)然后以各个的用户旅程跟用户习惯和标签和我们的那个 RF m 的那个估值的一个算法,对这个用户的 RF m 进行一个那个小程序上的一些估值,然后按我们的一个估值的把这个估值的一个估值的分值的一个形式。按这个的分类,同时把这个估值的算法放到用户管理的右上角,这个估值的一个算法 RFM 估值的算法放到这边,并且这一个有可以跟存那个神色手这边对接一整个的一个接口也帮我写清楚。
功能九:全量优化
然后全力思考这个用户,那个用户管理的整个的这个模块,然后看一下整个用户管理的模块的一个湿滑度,然后跟整个项目的一个方向帮我做一些在上面修改,并且保证所有的功能可用的情况下,那帮我做整体的一个。修改。整体的一个修改,并且。并且把这个。权力的思考,并且做整体的一个优化修改,在这方面叠加我的一些想法,以及那个帮我搜索最佳的一个方案策略,全网搜索一下,来帮我思考这个和我这个书的一个方向的一个优质的一个解决方案,那继续帮我优质和优化,并且在检测优化完之后检测没有任何 bug。前端后端以数据库是完善的情况下来做
![](images/2026-03-15-15-38-44.png)碰到所有的错误,直接帮我解决掉,嗯。
功能八:超级个体
然后这个超级个体的列表就是我们在超级个体是可以置顶到首页有四个位置四个位置是超级个体成为超级个体的话就可以直接在这个四个位置那这个后台咱们是可以直接置顶的只有四个位置不能超过四个。然后这4个的话是要成为 v 那个 VIP 会员才可以直接使用的。这超级个体的这一块,然后也可以去做字典。
功能七:规则配置
![](images/2026-03-15-15-32-18.png)体的那个规则配置里面,规则配置我们前期有设置的一些规则配置,比如它注册需要点击头像,并且这些规则配置需要在整个的网站上面做好锚点,一注册判断做好规则,它一有这个用户点击的什么内容,就需要执行什么操作,比如那个添加头像写名字。以及他需要是的各个的行为的一些规则配置。嗯,帮帮我把这个完善整个规则,并且把规则写清楚。嗯。把我们的规则写清楚,完善清楚,并且直接可以生效。设置好一定是可以生效的,跟咱们整个网站深度的理解一下。
功能六:用户旅程
![](images/2026-03-15-15-31-01.png)
然后这里的整个的用户旅程的规则,就我们就用户旅程的这些说做所有的一个汇总,然后这边点击进去都是每一个旅程都是可以看到那个具体的一些客户点击进去有完善的,有多少的用户都能直接看到并且分类。然后这些整个行为有操作的,各个的行为锚点尽可能去丰富以及统计。这权利的去理解,并且帮我深度的那个和用户的旅程绑定和用锚点绑定。这里主要是统计用户的习惯用的这个用户旅程主要统计用户的习惯用以及这些用户的那个成交程的一些细节。
功能五:标签体系
![](images/2026-03-15-15-28-26.png)
![](images/2026-03-15-15-29-05.png)用户标签体系的话是需要去清晰的去了解标签体系,嗯,添加这个用户有留存到的时候,实时推送到存客宝里面的标用户词的一个标签体系里面。那后面一个人的这个用户旅程,这里都需要有记录,从他的注册到他成为会员哥哥的那个用户旅程的标签,咱们整个的网站上面都需要把这个用户旅程的标签打清楚,然后从已经注册的只有一个用户旅程就要写上只有一个用户旅程,然后关系链路有匹配的就要写清楚关系链路的一个形式。这个是标签体系,嗯。
功能四:
能更新你一下这个开发的这个群开发的一个形式,开发的那个发送开发的时候一定要用这个派对 AI/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/派对AI 进行开发,把这个要加到派规则里面,默认的规则里面,然后这个派对 AI 开发的过程当中要把要绑定群,那这个群都固定做这个,如果用这售的开发的时候。都需要固定的把这个发到这个售的创业派对开发资料群里面。
https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494
使用派对 AI 来进行开发,默认的开发每一次都是需要派对 AI 优先领域卡罗 AI 来读取来使用,然后把复盘的格式都写上去。
功能三:会员权限
成为会员的这一个页面那个可以。社交权利改成派对权利。你会员权利比如派对权利改成会员权利那匹配。匹配次数是有1,980次匹配次数然后还有链那个。一个那个书里面那个整个的全年的可书里面解锁章节。新增张杰非争执张吗解锁新那个张杰是那个。张杰队。解锁全部章节这对然后一个。加入创业项目查看最新的项目那个查看最新创业项目权限。查看最新创业项目这个也是一个权利然后一个。美每日的专属团队每日。纪要总结。然后。自营项目可参与匹配小伙伴可参与到匹配。那个社交权利匹配创业伙伴是那个加入创业伙伴词获得创业客资匹配的创意合伙人。然后每一个的话总结一下不要超过四个字。这个是会员群里这块。
‘那还有一个,每个权限都是要对齐的,那个派对权限,这里社交权限改成派对权利,这里头还有一个点是文内,文章内只要提到你的名字,别人你只要提到你的名字,有人就可以直接点击爱得到你的名字,就可以直接那个加到你,就可以直接 AI 来协助你。链接,你来协助链接跟你产生链接拉群,然后把这几个权利都帮我柔和一下,特别是标题,不要超过四个字描述清楚。然后这个有相应的功能,
然后这个。支付1980元。那个成。几底下这个字1980年那个加入创业加那个。立即支付1980元。加入创业派对这个 VIP 会员就不要写了,把这句话改一下。
![](images/2026-03-15-15-23-04.png)
功能二:首页
![](images/2026-03-15-15-17-08.png)然后这个首页这里的话显示不是最新更新了,显示是那个。推荐。然后开始阅读,是要改成点击阅读。
功能一vip会员
![](images/2026-03-15-15-14-34.png)
这个 VIP 会员无法。这个 VIP 会员那个无法保存跟关闭,在后台这个用户详情里面无法保存跟关闭,帮我处理一下,并且检查这个所有的这个功能是否按照要求过来,要求来操作。那并且保证所有的功能是可以使用的,可以正常的使用,把整个的功能的一个链路帮我做好,做清楚并且检测清楚,然后帮我修复好。

View File

@@ -0,0 +1,5 @@
功能一:
![](images/2026-03-15-19-13-23.png)添加规则,这里保存,是添加规则,这里保存失败了。保存失败,然后这个。保存失败,帮我修复一下,看看具体什么问题,直接帮我操作,并且修复完这里没有添加规则和读取之前的那个规则,以及这个规则添加完之后必须得生效的规则,可用的规则都给帮我添加那个添加进去,嗯。

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Some files were not shown because too many files have changed in this diff Show More