fix: 注册 user-rules 和 shensheshou 路由,修复规则配置保存失败
- 将 /api/db/user-rules 的 GET/POST/PUT/DELETE 路由注册到 db 组 - 将 /api/admin/shensheshou/* 的 4 个路由注册到 admin 组 - 包含上次对话的头像 URL 修复(normalizeImageUrl 全栈) Made-with: Cursor
34
.cursor/rules/party-ai-dev.mdc
Normal 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 标准复盘格式(🎯📌💡📝▶ 五块齐全),带日期+时间。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
soul-admin/dist/assets/index-DdPcz68r.css
vendored
1
soul-admin/dist/assets/index-Q6CWPvkN.css
vendored
Normal file
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
442
soul-admin/src/pages/api-docs/ApiDocsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">排名积分制:三个维度各取前20名(第1名=20分,第20名=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名=20分、第2名=19分...第20名=1分</li>
|
||||
<li>最近更新前 30 篇:第1名=30分、第2名=29分...第30名=1分</li>
|
||||
<li>付款数前 20 名:第1名=20分、第2名=19分...第20名=1分</li>
|
||||
<li>热度分可在编辑章节中手动覆盖</li>
|
||||
<li>阅读量排名前20:第1名=20分 → 第20名=1分,其余0分</li>
|
||||
<li>新度排名前20(按最近更新):同理20→1分</li>
|
||||
<li>付款量排名前20:同理20→1分</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ----- 分销 -----
|
||||
|
||||
4
soul-api/scripts/add-chapters-preview-percent.sql
Normal 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;
|
||||
BIN
soul-api/server
BIN
soul-api/uploads/test/1773552305387635000_f4p35m.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
soul-api/uploads/test/1773552307029944000_jzyimk.png
Normal file
|
After Width: | Height: | Size: 286 B |
@@ -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\":\"订单未支付\"}"}
|
||||
|
||||
29
开发文档/1、需求/修改/20260314内容管理10.md
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
|
||||
|
||||
功能五:
|
||||
这个 API 文档就整合到系统设置里面了,那系统设置放到正下方,退出登录的这个位置。跟退出登录这里保持一致,API 文档整合到系统设置里面。
|
||||
|
||||
功能四:默认所有文章阅读比例
|
||||
帮我检查一下。没有这回事,除非自己设置
|
||||
完善一下这个编辑器的这个图片上传的功能,就是我的标点在哪?那个光标在哪,图片就上传到哪,并且得有一个进度以及这个链接编辑的功能,链接上传以及编辑的功能,这个得完善一下。默认预览比例是20%,这个只有唯一的默认的预览比例,唯一的一个条件没有,如果这文章属性没有改,就千万不要改,这个是只有唯一的一个条件。那个是前500个字,不是不要那个50%是怎么回事?确保所有的功能都是生效的,并且有微信。
|
||||
[](images/2026-03-15-13-45-21.png)
|
||||
|
||||
|
||||
功能四:内容api接口
|
||||
这个内容 API 接口,API 文档是跟内容管理的 API 接口,这两个做一个整合,是用来那个使用这个内容的。那我以后传 MD 文档更新一下这个文章的时候,文章发送的那个不直接发送到数据库,而是通过接口发送到咱们的那个编辑器里面来。
|
||||
|
||||
功能三:编辑器
|
||||

|
||||
那个上传的那个编辑器里面的得有一个进度条。上传视频和图片,如果上传比较慢,这个进度条也显示出来。
|
||||
那个有文章有修改,要及时检测,如果检测到 ad 跟减号的时候,包括接口进来的时候都是一样有传数据,通过接口传数据检测到有 @跟 #号,就要直接跟咱们后台的这个那个功能和的这个参数相匹配,嗯。这个是内容方面的一个
|
||||
对,一个问题。
|
||||
|
||||
功能二:热度分
|
||||

|
||||

|
||||
|
||||
热度粉里面的那个热度那个不匹配。不匹配,那都分它,这个不在这个里面不匹配,那帮我修正一下这个热度分,以及这里面的那个编辑,这边插入链接旁边应该多一个 add 爱的指定人的一个列表,嗯,这个也没有,嗯,也帮我把这个完成一下。你帮我完成一下这个 @的放在这边,@的指定的人放到这个界面的下面。
|
||||
|
||||
功能一:
|
||||
热度的分数是按这三个维度阅读群众的底下这个算法叠加的,那这个阅读权重这里展示的应该是百分之几,10%、40%、50%,这个权重不是零点几,那这里面的分数这边热度是等于这几个值排序的值。那叠加出来的一个热度,这个排序特别注意一下。
|
||||
6
开发文档/1、需求/修改/20260314内容管理11.md
Normal 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开始。然后这里面的这个分数,这三个维度的分数排分。这是,然后默认排名的话是按这几个,然后这里面的话有一个是当他的这三个,每一篇文章有这三个维度加起来的总分数。加起来的总分数。就每一个排名的一个分数乘以相应的权重,相加起来的作为一个总分数,比如阅读和心度的权重跟付款的权重,它乘以那个权重的比例。然后就是他们文章加起来的总分数来进行排序,嗯。
|
||||
并且检查好文章内的热度,分男的都分的一个,留存文章内的一个热度分。在数据库里面都可以添加这个热度分。得清晰,
|
||||
16
开发文档/1、需求/修改/20260314内容管理9.md
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
|
||||
|
||||
功能四:文章内容解锁问题
|
||||
加一个规则,就是那个20%是不能超过500个字,不是固定。前面还有一个规则,就是那个如果20%超过500个字,也是只显示500个字,默认的不一定是20%,是默认的这个百分比超过500个字。除非是手动测试的情况,不然都是默认就是不能超过。500个字。
|
||||
|
||||
|
||||
功能三:存客宝获取优化,迁移到前台
|
||||
可把这边的 TOKEN 都连接上,那这里的话是跳到乘客保证这页面就不要了,尽可能的把乘客保证的参数配置都配置在咱们的这里的这一个里面,场景获客的这个功能都配置到这个里面,并且把这个列表跟获客情况都列清楚。每一条、每一个人链接的的有多少个人?多少的获客把这些参数跟数据都放到这个页面里面,可以直接看看得到,可以在这个新增一个这个编辑旁边新增一个按钮来看这个功能。
|
||||
|
||||
功能二:
|
||||

|
||||
a 内脏这个章节底下这个热度算法和第几名热度算法是有问题的,检查一下这个热度算法的一些情况,热度算法有问题,帮我处理一下这个热度算法相关的功能,然后包括这里的话热内容的排行榜都来排行,跟这个排名算法、排兵算法这一个没有生效。帮我处理一下这个问题。这一都没有生效,这个热度有问题帮我处理一下。
|
||||
|
||||

|
||||
然后第二个的话就是编辑。编辑文章这里的话有一个那个热度分也是没有生效的,帮我全面的去检查一下,这个热度分也没有生效。然后编辑框这里增加一个 add 的功能,以及上传视频的一个功能,非上传视频支持上传视频的功能,视频的话上传到 OSS,阿里的那 OSS 上面。iOS 在后台直接可以配置用卡洛的那个卡洛 AI 拿那个 OS,然后检查一下 OS 的地址,在后台也可以直接配置,图片跟视频都传到 OS s 上面,阿里云的 OS s 上面
|
||||
13
开发文档/1、需求/修改/20260315 用户管理2.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
|
||||
|
||||
功能二:用户旅程
|
||||

|
||||
长沙现有的数据库的行为里面直接完善。直接完善,然后把这个用户旅程给我,帮我弄好写清楚。
|
||||
确保用户旅程跟规则配置里面要有相应的数据,不要不是显示重新加载你这个数据得帮我显示出来,现在看不到直接帮我处理掉。
|
||||
|
||||
|
||||
功能一:
|
||||
are if in 的算法,那个放到那个右上角这里,然后把这个里面咱们的相应的能计算的所有用户,关于用户行为能计算的和用户旅程能计算的都用 AI,你来整理一下,归类到 if、in 这三个维度来进行相应的那个 r,f。m 的估值的一个评分,以及它可以对接存客宝这边的一个算法进行估值的一个评分。那这个是 FM 的估值的一个形式。
|
||||
并且确保我这边估值的评分是有这个估值的评分的。然后并且这个算法可以按跟那个内容的排序形式一样,是可以做一个那个排名算法,就放到这个上面去 IFM 的估值的算法一样放到这个上面去,左右上角上面
|
||||
12
开发文档/1、需求/修改/20260315匹配功能1.md
Normal file
@@ -0,0 +1,12 @@
|
||||
、
|
||||
|
||||
|
||||
功能二:填写手机
|
||||
能。使用匹配功能的话,一定要注册好手机号,一定要自己要注册手机号的那个田琪手机号和获得头像,那这个才能做匹配的功能,整体点匹配的功能
|
||||

|
||||
要确保后面提交的线索上面有那个手机或者微信号。
|
||||
|
||||
功能一:
|
||||

|
||||
匹配功能右上角有一个那个设置的这个标签,帮我把它去掉右上角这个设置的标签去掉。
|
||||
所有的匹配功能。所有的匹配功能都是点一下开始匹配资源,然后默认填写好这个。但点匹配功能的时候,那个资源对接的时候就需要填一下自己的那个诉求完善资料,点击匹配自由完善资料。完善资料跟头像,那这个点击匹配默认都是那个3~10秒的时间再弹出来。
|
||||
12
开发文档/1、需求/修改/20260315数据统计1.md
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
功能一:数据统计
|
||||
功能四:
|
||||
这个网站上面的标签都需要有一个统计,然后在后台能实时能看到然后那个后台的那个小程序上面的美的标签的那个美的每一个点击标签的那个都做一次统计,看一下哪一个标签点击的统计的一个排行,那后台里面要看得到小程序上面每一个标签,每一这个动作的一个点击的排行,然后用按照我们分类的模块来进行分类。能知道每一个按钮的点击次数。以及这个是前端的,然后后端的也是一样,每一个按钮的按摩块的一个点击次数的一个统计。这个功能在数据概览的分类标签的功能底下。
|
||||
|
||||
然后在这充值就没有把充值赠送这个去掉,充值赠送的这一块去掉,然后这个里面的话是您能充值之后那个。可以直接申请退款的,那分别选择了金额就是。10块钱,30 50。1,000把100改成1,000,然后这个交易记录就是谁看看了这篇文章,他能看得到这个叫那个阅读那个消费记录,这个是我,我的余额,包括自己的一些交易记录也放上来。要确保这个接口,小程序是可以直接支付的。
|
||||
|
||||
|
||||
功能二:vip会员
|
||||

|
||||
那个卡路创业派对的这一个界面的。界面的话第一个解锁全部章节 VIP 默认就是加入会员就解锁全部章节,然后第二个的话,那个。链接资源和匹配,小伙伴放第一,社交权利就是匹配创业伙伴,然后第二个的话就是链接资源,第三个才是那个加入创业老板排行。第4个才是专属的那个。VIP 标识。然后内容的权利的话就是解锁全部章节一年,然后。加入那个创业项目团队。第三个的话就是每日纪要的阅读权限。美乐派那个派对纪要的阅读。然后第四个的话,就是那个。那个自己就是项目,自己下有机就是项目。认可的项目可加入那个文章,获得合作伙伴的内容权益,然后加入考诺的创业派对,改成加入考诺创业派对 VIP 会员。然后下面那句一字加入尊享那个去掉,删除掉。不要有下面这一句话。
|
||||
14
开发文档/1、需求/修改/20260315数据统计2.md
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
|
||||
功能二:
|
||||
老伙伴包括 AI 拓客数据,这里的话有点多,找伙伴和 AI 混合数据统计,这一个变成简洁整合一些,简洁整合起来一些。那把这些用户数据统计这一个变得更简洁一点,然后。更简洁一点,功能性变得更有效一些。但做一些优化迭代的用户统计的这个地方,那我能一目了然知道整个网站的一个情况。那把 AI。
|
||||
|
||||
|
||||

|
||||
其他的几个数据统计也是一样,统计的数据。统计的数据集中在一页,然后简洁一点,数据清晰并且有关联性能描述整个的整个网站以及这个用户体验,体现在用户那个使用那整个踏盘的一个数据上面。
|
||||
|
||||
功能一:统计
|
||||

|
||||
开户统计变成一个小标签,统计一下就可以了,代付统计的一个数值在那个收入里面。对,一个特殊的一个标签,然后里面统计的那个充值的余额在咱们的这个用户管理里面体现就可以了,还有多少余额就在用户管理底下,那提现有多少余额就好了,不要再不需要再额外在首页上面新增一个。
|
||||
|
||||
今日点击这里的话,显示的就是今日点击这个就是点击数量,但是这个是按月的统计的点击数量。
|
||||
45
开发文档/1、需求/修改/20260315用户管理1.md
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
|
||||
功能十:用户rfm估值
|
||||
然后以各个的用户旅程跟用户习惯和标签和我们的那个 RF m 的那个估值的一个算法,对这个用户的 RF m 进行一个那个小程序上的一些估值,然后按我们的一个估值的把这个估值的一个估值的分值的一个形式。按这个的分类,同时把这个估值的算法放到用户管理的右上角,这个估值的一个算法 RFM 估值的算法放到这边,并且这一个有可以跟存那个神色手这边对接一整个的一个接口也帮我写清楚。
|
||||
|
||||
|
||||
功能九:全量优化
|
||||
然后全力思考这个用户,那个用户管理的整个的这个模块,然后看一下整个用户管理的模块的一个湿滑度,然后跟整个项目的一个方向帮我做一些在上面修改,并且保证所有的功能可用的情况下,那帮我做整体的一个。修改。整体的一个修改,并且。并且把这个。权力的思考,并且做整体的一个优化修改,在这方面叠加我的一些想法,以及那个帮我搜索最佳的一个方案策略,全网搜索一下,来帮我思考这个和我这个书的一个方向的一个优质的一个解决方案,那继续帮我优质和优化,并且在检测优化完之后检测没有任何 bug。前端后端以数据库是完善的情况下来做
|
||||
碰到所有的错误,直接帮我解决掉,嗯。
|
||||
|
||||
|
||||
功能八:超级个体
|
||||
然后这个超级个体的列表,就是我们在超级个体是可以置顶到首页有四个位置,四个位置是超级个体,成为超级个体的话就可以直接在这个四个位置,那这个后台咱们是可以直接置顶的,只有四个位置不能超过四个。然后这4个的话是要成为 v 那个 VIP 会员才可以直接使用的。这超级个体的这一块,然后也可以去做字典。
|
||||
|
||||
功能七:规则配置
|
||||
体的那个规则配置里面,规则配置我们前期有设置的一些规则配置,比如它注册需要点击头像,并且这些规则配置需要在整个的网站上面做好锚点,一注册判断做好规则,它一有这个用户点击的什么内容,就需要执行什么操作,比如那个添加头像写名字。以及他需要是的各个的行为的一些规则配置。嗯,帮帮我把这个完善整个规则,并且把规则写清楚。嗯。把我们的规则写清楚,完善清楚,并且直接可以生效。设置好一定是可以生效的,跟咱们整个网站深度的理解一下。
|
||||
|
||||
功能六:用户旅程
|
||||

|
||||
然后这里的整个的用户旅程的规则,就我们就用户旅程的这些说做所有的一个汇总,然后这边点击进去都是每一个旅程都是可以看到那个具体的一些客户点击进去有完善的,有多少的用户都能直接看到并且分类。然后这些整个行为有操作的,各个的行为锚点尽可能去丰富以及统计。这权利的去理解,并且帮我深度的那个和用户的旅程绑定和用锚点绑定。这里主要是统计用户的习惯用的这个用户旅程主要统计用户的习惯用以及这些用户的那个成交程的一些细节。
|
||||
|
||||
|
||||
功能五:标签体系
|
||||

|
||||
用户标签体系的话是需要去清晰的去了解标签体系,嗯,添加这个用户有留存到的时候,实时推送到存客宝里面的标用户词的一个标签体系里面。那后面一个人的这个用户旅程,这里都需要有记录,从他的注册到他成为会员哥哥的那个用户旅程的标签,咱们整个的网站上面都需要把这个用户旅程的标签打清楚,然后从已经注册的只有一个用户旅程就要写上只有一个用户旅程,然后关系链路有匹配的就要写清楚关系链路的一个形式。这个是标签体系,嗯。
|
||||
|
||||
功能四:
|
||||
|
||||
能更新你一下这个开发的这个群开发的一个形式,开发的那个发送开发的时候一定要用这个派对 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 会员就不要写了,把这句话改一下。
|
||||

|
||||
|
||||
|
||||
功能二:首页
|
||||
然后这个首页这里的话显示不是最新更新了,显示是那个。推荐。然后开始阅读,是要改成点击阅读。
|
||||
|
||||
功能一:vip会员
|
||||

|
||||
这个 VIP 会员无法。这个 VIP 会员那个无法保存跟关闭,在后台这个用户详情里面无法保存跟关闭,帮我处理一下,并且检查这个所有的这个功能是否按照要求过来,要求来操作。那并且保证所有的功能是可以使用的,可以正常的使用,把整个的功能的一个链路帮我做好,做清楚并且检测清楚,然后帮我修复好。
|
||||
5
开发文档/1、需求/修改/20260315用户管理3.md
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
|
||||
功能一:
|
||||
添加规则,这里保存,是添加规则,这里保存失败了。保存失败,然后这个。保存失败,帮我修复一下,看看具体什么问题,直接帮我操作,并且修复完这里没有添加规则和读取之前的那个规则,以及这个规则添加完之后必须得生效的规则,可用的规则都给帮我添加那个添加进去,嗯。
|
||||
BIN
开发文档/1、需求/修改/images/2026-03-15-09-52-35.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-52-53.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-54-15.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-56-46.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-57-27.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-57-54.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-09-58-57.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-00-09.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-00-33.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-02-49.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-04-37.png
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-31-25.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-10-38-24.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-02-38.png
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-06-59.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-13-16.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-17-27.png
Normal file
|
After Width: | Height: | Size: 420 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-38-40.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-39-07.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-40-29.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-48-00.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-11-54-06.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-12-00-17.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-35-04.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-38-39.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-39-09.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-40-30.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-41-54.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-42-55.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-45-21.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-46-08.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-13-48-10.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-14-00-44.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-14-00-57.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-14-34.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-17-08.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-23-04.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-28-26.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-29-05.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-31-01.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-32-18.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-38-25.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-38-44.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-43-56.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-44-38.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-45-31.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-47-07.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-15-15-51-08.png
Normal file
|
After Width: | Height: | Size: 515 KiB |