更新管理端用户详情弹窗,新增 VIP 手动设置功能,支持到期日、展示名、项目、联系方式和简介的编辑。优化 VIP 相关接口,确保用户状态和资料更新功能正常,增强用户体验。调整文档,明确 VIP 设置的必填项和格式要求。

This commit is contained in:
Alex-larget
2026-02-26 18:03:01 +08:00
parent ab27acdb21
commit a5e2cfaa61
40 changed files with 1520 additions and 993 deletions

View File

@@ -102,13 +102,13 @@ API 需操作数据库且 MCP 不可用? → 加载 SKILL-MySQL直接操作
|------|-------|----------|
| 跨端协同 | SKILL-角色流程控制.md | 小程序/管理端/API 任一有功能开发且涉及多端时;流程图见 `.cursor/docs/角色协同流程图.html` |
| 变更检查 | SKILL-变更关联检查.md、soul-change-checklist.mdc | **无论改哪端,改完必过** |
| **文档同步** | **SKILL-助理小风-文档同步.md** | **讨论完毕、记录内容、同步到开发文档时**;对应规则 `assistant-xiaofeng.mdc` |
| **文档同步** | **SKILL-助理橙子-文档同步.md** | **讨论完毕、记录内容、同步到开发文档时**;对应规则 `assistant-xiaofeng.mdc` |
| next-project | SKILL-next-project仅预览.md | 编辑 next-project/ 或需区分线上后端时 |
| 项目拆解 | SKILL-Next全栈拆解为前后端分离与小程序.md | 拆解 Next.js 全栈时;拆解前必读 SKILL-三端架构与框架分析.md |
---
**小橙(橙子/橙橙/🍊)**:当用户说小橙、橙子、橙橙、🍊、「讨论完毕」「记录一下」「同步到开发文档」等时,加载 **SKILL-助理小风-文档同步.md**,以小橙身份记录讨论要点并更新 `开发文档/``临时需求池/`,保持项目文档与讨论结论一致。
**小橙(橙子/橙橙/🍊)**:当用户说小橙、橙子、橙橙、🍊、「讨论完毕」「记录一下」「同步到开发文档」等时,加载 **SKILL-助理橙子-文档同步.md**,以小橙身份记录讨论要点并更新 `开发文档/``临时需求池/`,保持项目文档与讨论结论一致。
**Skills 迭代**Skills 会随 bug 修复与项目演进持续升级。修 bug 时若发现规则、流程或约定有遗漏或错误,应同步更新对应 Skill避免同类问题复现。详见 `.cursor/docs/角色驱动Skills分析.md`

View File

@@ -52,4 +52,4 @@ alwaysApply: true
| 涉及「该接口给谁用」 | 先确定使用方再写/改代码,避免路径混用 |
| **跨端功能开发** | 加载 **SKILL-角色流程控制.md**,按协同流程执行 |
| **变更完成准备提交** | **必过** **soul-change-checklist.mdc** + **SKILL-变更关联检查.md**,未过即视为漏改 |
| **小橙/橙子/橙橙/🍊、讨论完毕、记录、同步文档** | 加载 **SKILL-助理小风-文档同步.md**,以小橙身份更新开发文档 |
| **小橙/橙子/橙橙/🍊、讨论完毕、记录、同步文档** | 加载 **SKILL-助理橙子-文档同步.md**,以小橙身份更新开发文档 |

View File

@@ -42,6 +42,17 @@ description: Soul 创业派对后端 API 开发规范。在 soul-api/ 下编辑
- **Model**:所有表对应结构体在 `internal/model`,带 `gorm``json` 标签;不对外暴露字段用 `json:"-"`
- **配置**:仅通过 `internal/config``Load()` 读环境变量;业务代码不直接 `os.Getenv`
### 3.2 新增表/字段完整流程(吸收 vip_roles 经验)
| 步骤 | 动作 |
|------|------|
| 1 | 在 `internal/model/` 新建 `xxx.go`,定义结构体与 `TableName()` |
| 2 | 在 `internal/database/database.go``AutoMigrate(&model.Xxx{})` |
| 3 | 在 `soul-api/scripts/` 编写迁移 SQLCREATE TABLE IF NOT EXISTS、ALTER TABLE ADD COLUMN、INSERT IGNORE 默认数据) |
| 4 | 新建 `internal/handler/xxx.go` 实现 CRUDGET 列表、POST 新增、PUT 更新、DELETE 删除) |
| 5 | 在 `router.go``db` 组下注册:`db.GET("/xxx", handler.DBXxxList)``db.POST/PUT/DELETE("/xxx", handler.DBXxxAction)` |
| 6 | 部署前执行迁移脚本:`mysql -u user -p db < soul-api/scripts/add-xxx.sql` |
## 3.1 依赖物尽其用(补充)
| 依赖 | 用途 | 约定 |

View File

@@ -99,7 +99,22 @@ MCP 配置参考:`开发文档/8、部署/Soul-MySQL-MCP配置说明.md`(连
## 7. 与 soul-api 的配合
- 执行 `ALTER TABLE` 后,**必须**同步修改 `internal/model` 中的 GORM 结构体。
- 迁移 SQL 建议放在 `soul-api/migrations/` 或项目内统一目录,便于版本管理。
- 迁移 SQL 建议放在 `soul-api/scripts/``soul-api/migrations/`,便于版本管理。
---
## 8. 迁移脚本最佳实践(吸收 vip_roles 经验)
新增表或字段时,编写可重复执行或幂等的 SQL 脚本:
| 场景 | 写法 | 说明 |
|------|------|------|
| **新建表** | `CREATE TABLE IF NOT EXISTS xxx (...)` | 幂等,重复执行不报错 |
| **新增列** | `ALTER TABLE xxx ADD COLUMN yyy ...` | MySQL 不支持 IF NOT EXISTS重复执行会报错可单独执行或注释掉已执行部分 |
| **插入默认数据** | `INSERT IGNORE INTO xxx (name, sort) VALUES (...)` | 配合 `UNIQUE(name)` 防重复;不指定 id 让 AUTO_INCREMENT 生效 |
| **脚本位置** | `soul-api/scripts/add-xxx.sql` | 命名清晰,便于部署时按顺序执行 |
**完整流程**1) 编写 SQL 脚本 → 2) 在 `database.go``AutoMigrate(&model.Xxx{})` → 3) 同步修改 `internal/model` → 4) 部署前执行 `mysql -u user -p db < scripts/add-xxx.sql`
---

View File

@@ -63,6 +63,7 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
| 章节/内容 | 目录、阅读、购买 | book/content、user 权限与进度 | 章节/内容 CRUD、配置 |
| 找伙伴/匹配 | 匹配、展示 | match、ckb 等 miniprogram 接口 | 匹配配置、开关 |
| 配置项 | 仅读取 | miniprogram/config 或 db/config | 配置编辑admin/db |
| VIP/超级个体 | 展示列表、详情 | miniprogram/vip/membersdb/users PUT、db/vip-roles | 用户列表「设置 VIP」弹窗、VIP 角色管理页 |
每次做「某个功能」的变更时,问:

View File

@@ -61,6 +61,15 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
**检查清单**分页、搜索防抖、刷新、loading、空状态、错误条、仅 admin/db 路径。详见 `开发文档/列表标准与角色分工.md`
### 4.1 表单弹窗与「可选择+可手动填写」(吸收 SetVipModal 经验)
当表单需支持「从预设选项选择」或「手动填写自定义值」时:
- **下拉**`<select>``GET /api/db/xxx` 拉取预设列表,选项包含「其他(手动填写)」;
- **自定义输入**:当选中「其他」时展示 `<Input>`,最终提交时取「选中项」或「自定义输入」;
- **独立弹窗**:功能入口在列表行操作按钮(如「设置 VIP」点击打开独立 `XxxModal`,不塞进详情页,保持详情页纯粹;
- **路由与菜单**:新增管理页(如 VIP 角色)时,在 `App.tsx` 加 Route`AdminLayout``menuItems` 加菜单项。
---
## 5. 业务逻辑约定(与 soul-api 对齐)

455
soul-admin/dist/assets/index-BvA8RYip.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -15,6 +15,7 @@ import { SitePage } from './pages/site/SitePage'
import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
import { MatchPage } from './pages/match/MatchPage'
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -32,6 +33,7 @@ function App() {
<Route path="content" element={<ContentPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />

View File

@@ -0,0 +1,280 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Crown, Save, X } from 'lucide-react'
import { get, put } from '@/api/client'
interface SetVipModalProps {
open: boolean
onClose: () => void
userId: string | null
userNickname?: string
onSaved?: () => void
}
interface VipRole {
id: number
name: string
sort: number
}
interface VipForm {
isVip: boolean
vipExpireDate: string
vipSort: number | ''
vipRole: string
vipRoleCustom: string
vipName: string
vipProject: string
vipContact: string
vipBio: string
}
const DEFAULT_FORM: VipForm = {
isVip: false,
vipExpireDate: '',
vipSort: '',
vipRole: '',
vipRoleCustom: '',
vipName: '',
vipProject: '',
vipContact: '',
vipBio: '',
}
export function SetVipModal({
open,
onClose,
userId,
userNickname = '',
onSaved,
}: SetVipModalProps) {
const [form, setForm] = useState<VipForm>(DEFAULT_FORM)
const [roles, setRoles] = useState<VipRole[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
setForm(DEFAULT_FORM)
return
}
let cancelled = false
setLoading(true)
Promise.all([
get<{ success?: boolean; data?: 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
if (u) {
const vipRole = String(u.vipRole ?? '')
const inRoles = rolesList.some((r: VipRole) => r.name === vipRole)
setForm({
isVip: !!(u.isVip ?? false),
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
vipSort: typeof u.vipSort === 'number' ? u.vipSort : '',
vipRole: inRoles ? vipRole : (vipRole ? '__custom__' : ''),
vipRoleCustom: inRoles ? '' : vipRole,
vipName: String(u.vipName ?? ''),
vipProject: String(u.vipProject ?? ''),
vipContact: String(u.vipContact ?? ''),
vipBio: String(u.vipBio ?? ''),
})
} else {
setForm(DEFAULT_FORM)
}
}).catch((e) => {
if (!cancelled) console.error('Load error:', e)
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [open, userId])
async function handleSave() {
if (!userId) return
if (form.isVip && !form.vipExpireDate.trim()) {
alert('开启 VIP 时请填写有效到期日')
return
}
if (form.isVip && form.vipExpireDate.trim()) {
const d = new Date(form.vipExpireDate)
if (isNaN(d.getTime())) {
alert('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
setSaving(true)
try {
const roleValue = form.vipRole === '__custom__' ? form.vipRoleCustom.trim() : form.vipRole
const payload: Record<string, unknown> = {
id: userId,
isVip: form.isVip,
vipExpireDate: form.isVip ? form.vipExpireDate : undefined,
vipSort: form.vipSort === '' ? undefined : form.vipSort,
vipRole: roleValue || undefined,
vipName: form.vipName || undefined,
vipProject: form.vipProject || undefined,
vipContact: form.vipContact || undefined,
vipBio: form.vipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('VIP 设置已保存')
onSaved?.()
onClose()
} else {
alert('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save VIP error:', e)
alert('保存失败')
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
VIP - {userNickname || userId}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-gray-400">...</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label className="text-gray-300">VIP </Label>
<Switch
checked={form.isVip}
onCheckedChange={(checked) => setForm((f) => ({ ...f, isVip: checked }))}
/>
</div>
{form.isVip && (
<>
<div className="space-y-2">
<Label className="text-gray-300">
(YYYY-MM-DD) <span className="text-amber-400">*</span>
</Label>
<Input
type="date"
className="bg-[#0a1628] border-gray-700 text-white"
value={form.vipExpireDate}
onChange={(e) => setForm((f) => ({ ...f, vipExpireDate: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="数字越小越靠前,留空按时间"
value={form.vipSort === '' ? '' : form.vipSort}
onChange={(e) => {
const v = e.target.value
setForm((f) => ({ ...f, vipSort: v === '' ? '' : parseInt(v, 10) || 0 }))
}}
/>
</div>
</>
)}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md px-3 py-2"
value={form.vipRole}
onChange={(e) => setForm((f) => ({ ...f, vipRole: e.target.value }))}
>
<option value=""></option>
{roles.map((r) => (
<option key={r.id} value={r.name}>{r.name}</option>
))}
<option value="__custom__"></option>
</select>
{form.vipRole === '__custom__' && (
<Input
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="输入自定义角色"
value={form.vipRoleCustom}
onChange={(e) => setForm((f) => ({ ...f, vipRoleCustom: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300">VIP </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="创业老板排行展示名"
value={form.vipName}
onChange={(e) => setForm((f) => ({ ...f, vipName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">/</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="项目名称"
value={form.vipProject}
onChange={(e) => setForm((f) => ({ ...f, vipProject: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="微信号或手机"
value={form.vipContact}
onChange={(e) => setForm((f) => ({ ...f, vipContact: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="简要描述业务"
value={form.vipBio}
onChange={(e) => setForm((f) => ({ ...f, vipBio: e.target.value }))}
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving || loading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -10,7 +10,6 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import {
User,
Phone,
@@ -25,7 +24,6 @@ import {
Save,
X,
Tag,
Crown,
} from 'lucide-react'
import { get, put, post } from '@/api/client'
@@ -91,12 +89,6 @@ export function UserDetailModal({
const [editNickname, setEditNickname] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
const [editIsVip, setEditIsVip] = useState(false)
const [editVipExpireDate, setEditVipExpireDate] = useState('')
const [editVipName, setEditVipName] = useState('')
const [editVipProject, setEditVipProject] = useState('')
const [editVipContact, setEditVipContact] = useState('')
const [editVipBio, setEditVipBio] = useState('')
useEffect(() => {
if (open && userId) loadUserDetail()
@@ -115,12 +107,6 @@ export function UserDetailModal({
setEditPhone(u.phone || '')
setEditNickname(u.nickname || '')
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
setEditIsVip(!!u.isVip)
setEditVipExpireDate(u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '')
setEditVipName(u.vipName || '')
setEditVipProject(u.vipProject || '')
setEditVipContact(u.vipContact || '')
setEditVipBio(u.vipBio || '')
}
try {
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
@@ -173,17 +159,6 @@ export function UserDetailModal({
async function handleSave() {
if (!user) return
if (editIsVip && !editVipExpireDate.trim()) {
alert('开启 VIP 时请填写有效到期日')
return
}
if (editIsVip && editVipExpireDate.trim()) {
const d = new Date(editVipExpireDate)
if (isNaN(d.getTime())) {
alert('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
@@ -191,12 +166,6 @@ export function UserDetailModal({
phone: editPhone || undefined,
nickname: editNickname || undefined,
tags: JSON.stringify(editTags),
isVip: editIsVip,
vipExpireDate: editVipExpireDate || undefined,
vipName: editVipName || undefined,
vipProject: editVipProject || undefined,
vipContact: editVipContact || undefined,
vipBio: editVipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
@@ -331,70 +300,6 @@ export function UserDetailModal({
/>
</div>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">VIP </span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between">
<Label className="text-gray-300">VIP </Label>
<Switch
checked={editIsVip}
onCheckedChange={setEditIsVip}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">
(YYYY-MM-DD)
{editIsVip && <span className="text-amber-400 ml-1">*</span>}
</Label>
<Input
type="date"
className="bg-[#162840] border-gray-700 text-white"
value={editVipExpireDate}
onChange={(e) => setEditVipExpireDate(e.target.value)}
required={editIsVip}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">VIP </Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="创业老板排行展示名"
value={editVipName}
onChange={(e) => setEditVipName(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">/</Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="项目名称"
value={editVipProject}
onChange={(e) => setEditVipProject(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="微信号或手机"
value={editVipContact}
onChange={(e) => setEditVipContact(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="简要描述业务"
value={editVipBio}
onChange={(e) => setEditVipBio(e.target.value)}
/>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>

View File

@@ -9,6 +9,7 @@ import {
Wallet,
BookOpen,
GitMerge,
Crown,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
@@ -17,6 +18,7 @@ const menuItems = [
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: Wallet, label: '交易中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },

View File

@@ -15,6 +15,8 @@ interface ReferralConfig {
bindingDays: number
userDiscount: number
enableAutoWithdraw: boolean
vipOrderShareVip: number
vipOrderShareNonVip: number
}
const DEFAULT: ReferralConfig = {
@@ -23,6 +25,8 @@ const DEFAULT: ReferralConfig = {
bindingDays: 30,
userDiscount: 5,
enableAutoWithdraw: false,
vipOrderShareVip: 20,
vipOrderShareNonVip: 10,
}
export function ReferralSettingsPage() {
@@ -41,6 +45,8 @@ export function ReferralSettingsPage() {
bindingDays: c.bindingDays ?? 30,
userDiscount: c.userDiscount ?? 5,
enableAutoWithdraw: c.enableAutoWithdraw ?? false,
vipOrderShareVip: c.vipOrderShareVip ?? 20,
vipOrderShareNonVip: c.vipOrderShareNonVip ?? 10,
})
}
})
@@ -57,6 +63,8 @@ export function ReferralSettingsPage() {
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
vipOrderShareVip: Number(config.vipOrderShareVip) || 20,
vipOrderShareNonVip: Number(config.vipOrderShareNonVip) || 10,
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/referral-settings', body)
if (!res || (res as { success?: boolean }).success === false) {
@@ -162,9 +170,45 @@ export function ReferralSettingsPage() {
/>
</div>
<p className="text-xs text-gray-500">
= ×{' '}
= ×{' '}
<span className="text-[#38bdac] font-mono">{config.distributorShare}%</span>
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">
<Info className="w-3 h-3 text-[#38bdac]" />
广 %
</Label>
<Input
type="number"
min={0}
max={100}
className="bg-[#0a1628] border-gray-700 text-white"
value={config.vipOrderShareVip}
onChange={handleNumberChange('vipOrderShareVip')}
/>
<p className="text-xs text-gray-500">
广 20%
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">
<Info className="w-3 h-3 text-[#38bdac]" />
广 %
</Label>
<Input
type="number"
min={0}
max={100}
className="bg-[#0a1628] border-gray-700 text-white"
value={config.vipOrderShareNonVip}
onChange={handleNumberChange('vipOrderShareNonVip')}
/>
<p className="text-xs text-gray-500">
广 10%
</p>
</div>

View File

@@ -31,8 +31,10 @@ import {
RefreshCw,
Users,
Eye,
Crown,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { SetVipModal } from '@/components/modules/user/SetVipModal'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { get, del, post, put } from '@/api/client'
@@ -80,6 +82,8 @@ export function UsersPage() {
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [showSetVipModal, setShowSetVipModal] = useState(false)
const [selectedUserForVip, setSelectedUserForVip] = useState<User | null>(null)
const [formData, setFormData] = useState({
phone: '',
nickname: '',
@@ -237,6 +241,11 @@ export function UsersPage() {
setShowDetailModal(true)
}
const handleSetVip = (user: User) => {
setSelectedUserForVip(user)
setShowSetVipModal(true)
}
async function handleSavePassword() {
if (!newPassword) {
alert('请输入新密码')
@@ -466,6 +475,14 @@ export function UsersPage() {
onUserUpdated={loadUsers}
/>
<SetVipModal
open={showSetVipModal}
onClose={() => { setShowSetVipModal(false); setSelectedUserForVip(null) }}
userId={selectedUserForVip?.id ?? null}
userNickname={selectedUserForVip?.nickname}
onSaved={loadUsers}
/>
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
<DialogHeader>
@@ -691,6 +708,15 @@ export function UsersPage() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleSetVip(user)}
className="text-gray-400 hover:text-amber-400 hover:bg-amber-400/10"
title="设置 VIP"
>
<Crown className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Crown, Plus, Edit3, Trash2, X, Save } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface VipRole {
id: number
name: string
sort: number
createdAt?: string
updatedAt?: string
}
export function VipRolesPage() {
const [roles, setRoles] = useState<VipRole[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingRole, setEditingRole] = useState<VipRole | null>(null)
const [formName, setFormName] = useState('')
const [formSort, setFormSort] = useState(0)
const [saving, setSaving] = useState(false)
async function loadRoles() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles')
if (data?.success && data.data) setRoles(data.data)
} catch (e) {
console.error('Load roles error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadRoles()
}, [])
const handleAdd = () => {
setEditingRole(null)
setFormName('')
setFormSort(roles.length > 0 ? Math.max(...roles.map((r) => r.sort)) + 1 : 0)
setShowModal(true)
}
const handleEdit = (role: VipRole) => {
setEditingRole(role)
setFormName(role.name)
setFormSort(role.sort)
setShowModal(true)
}
const handleSave = async () => {
if (!formName.trim()) {
alert('角色名称不能为空')
return
}
setSaving(true)
try {
if (editingRole) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
id: editingRole.id,
name: formName.trim(),
sort: formSort,
})
if (data?.success) {
setShowModal(false)
loadRoles()
} else {
alert('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
name: formName.trim(),
sort: formSort,
})
if (data?.success) {
setShowModal(false)
loadRoles()
} else {
alert('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
alert('保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该角色?已设置该角色的 VIP 用户将保留角色名称。')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/vip-roles?id=${id}`)
if (data?.success) loadRoles()
else alert('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
alert('删除失败')
}
}
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
VIP
</h2>
<p className="text-gray-400 mt-1">
VIP
</p>
</div>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-white">{r.name}</TableCell>
<TableCell className="text-gray-400">{r.sort}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(r)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(r.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{roles.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">
{editingRole ? '编辑角色' : '新增角色'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:创始人、投资人"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={formSort}
onChange={(e) => setFormSort(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./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/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.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/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./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/api-doc/apidocpage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.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"],"version":"5.6.3"}

View File

@@ -30,6 +30,9 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.UserAddress{}); err != nil {
log.Printf("database: user_addresses migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.VipRole{}); err != nil {
log.Printf("database: vip_roles migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -278,12 +278,14 @@ func AdminSettingsPost(c *gin.Context) {
func AdminReferralSettingsGet(c *gin.Context) {
db := database.DB()
defaultConfig := gin.H{
"distributorShare": float64(90),
"distributorShare": float64(90),
"minWithdrawAmount": float64(10),
"bindingDays": float64(30),
"userDiscount": float64(5),
"withdrawFee": float64(5),
"enableAutoWithdraw": false,
"vipOrderShareVip": float64(20),
"vipOrderShareNonVip": float64(10),
}
var row model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
@@ -301,24 +303,36 @@ func AdminReferralSettingsGet(c *gin.Context) {
// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config请求体为完整配置对象
func AdminReferralSettingsPost(c *gin.Context) {
var body struct {
DistributorShare float64 `json:"distributorShare"`
MinWithdrawAmount float64 `json:"minWithdrawAmount"`
BindingDays float64 `json:"bindingDays"`
UserDiscount float64 `json:"userDiscount"`
WithdrawFee float64 `json:"withdrawFee"`
EnableAutoWithdraw bool `json:"enableAutoWithdraw"`
DistributorShare float64 `json:"distributorShare"`
MinWithdrawAmount float64 `json:"minWithdrawAmount"`
BindingDays float64 `json:"bindingDays"`
UserDiscount float64 `json:"userDiscount"`
WithdrawFee float64 `json:"withdrawFee"`
EnableAutoWithdraw bool `json:"enableAutoWithdraw"`
VipOrderShareVip float64 `json:"vipOrderShareVip"`
VipOrderShareNonVip float64 `json:"vipOrderShareNonVip"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
vipOrderShareVip := body.VipOrderShareVip
if vipOrderShareVip == 0 {
vipOrderShareVip = 20
}
vipOrderShareNonVip := body.VipOrderShareNonVip
if vipOrderShareNonVip == 0 {
vipOrderShareNonVip = 10
}
val := gin.H{
"distributorShare": body.DistributorShare,
"minWithdrawAmount": body.MinWithdrawAmount,
"bindingDays": body.BindingDays,
"userDiscount": body.UserDiscount,
"withdrawFee": body.WithdrawFee,
"distributorShare": body.DistributorShare,
"minWithdrawAmount": body.MinWithdrawAmount,
"bindingDays": body.BindingDays,
"userDiscount": body.UserDiscount,
"withdrawFee": body.WithdrawFee,
"enableAutoWithdraw": body.EnableAutoWithdraw,
"vipOrderShareVip": vipOrderShareVip,
"vipOrderShareNonVip": vipOrderShareNonVip,
}
valBytes, err := json.Marshal(val)
if err != nil {
@@ -456,18 +470,6 @@ func DBUsersList(c *gin.Context) {
return
}
// 读取推广配置中的分销比例
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
userIDs := make([]string, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
@@ -494,17 +496,15 @@ func DBUsersList(c *gin.Context) {
sectionCountMap[r.UserID] = int(r.Count)
}
// 2. 分销收益:从 referrer 订单计算佣金;可提现 = 累计佣金 - 已提现 - 待处理提现
// 2. 分销收益:从 referrer 订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%
referrerEarningsMap := make(map[string]float64)
var referrerRows []struct {
ReferrerID string
Total float64
}
db.Model(&model.Order{}).Select("referrer_id, COALESCE(SUM(amount), 0) as total").
Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").
Group("referrer_id").Find(&referrerRows)
for _, r := range referrerRows {
referrerEarningsMap[r.ReferrerID] = r.Total * distributorShare
var referrerOrders []model.Order
db.Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").Find(&referrerOrders)
for i := range referrerOrders {
rid := referrerOrders[i].ReferrerID
if rid != nil && *rid != "" {
referrerEarningsMap[*rid] += computeOrderCommission(db, &referrerOrders[i], nil)
}
}
withdrawnMap := make(map[string]float64)
var withdrawnRows []struct {
@@ -625,6 +625,8 @@ func DBUsersAction(c *gin.Context) {
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
@@ -675,6 +677,12 @@ func DBUsersAction(c *gin.Context) {
}
if body.IsVip != nil {
updates["is_vip"] = *body.IsVip
if *body.IsVip {
now := time.Now()
updates["vip_activated_at"] = now // 手动设置时与付款一致:按时间排序,最新在前
} else {
updates["vip_activated_at"] = nil
}
}
if body.VipExpireDate != nil {
if *body.VipExpireDate == "" {
@@ -687,6 +695,17 @@ func DBUsersAction(c *gin.Context) {
}
}
}
if body.VipSort != nil {
updates["vip_sort"] = *body.VipSort
}
if body.VipRole != nil {
s := strings.TrimSpace(*body.VipRole)
if s == "" {
updates["vip_role"] = nil
} else {
updates["vip_role"] = s
}
}
if body.VipName != nil {
updates["vip_name"] = *body.VipName
}
@@ -752,18 +771,6 @@ func DBUsersReferrals(c *gin.Context) {
}
db := database.DB()
// 分销比例(与小程序 /api/miniprogram/earnings、支付回调一致
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
var bindings []model.ReferralBinding
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "referrals": []interface{}{}, "stats": gin.H{"total": 0, "purchased": 0, "free": 0, "earnings": 0, "pendingEarnings": 0, "withdrawnEarnings": 0}})
@@ -817,12 +824,13 @@ func DBUsersReferrals(c *gin.Context) {
})
}
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单+提现表实时计算
var orderSum struct{ Total float64 }
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0) as total").
Where("referrer_id = ? AND status = ?", userId, "paid").
Scan(&orderSum)
earningsE := orderSum.Total * distributorShare
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单逐条 computeOrderCommission 求和
var refOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&refOrders)
earningsE := 0.0
for i := range refOrders {
earningsE += computeOrderCommission(db, &refOrders[i], nil)
}
var withdrawnSum struct{ Total float64 }
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").

View File

@@ -457,13 +457,18 @@ func MiniprogramPayNotify(c *gin.Context) {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "vip" {
// VIP 支付成功:更新 users.is_vip、vip_expire_date(与 next-project 一致
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at排序后付款在前
expireDate := time.Now().AddDate(0, 0, 365)
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": vipActivatedAt,
})
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"))
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} else if attach.ProductType == "section" && attach.ProductID != "" {
@@ -505,33 +510,8 @@ func MiniprogramPayNotify(c *gin.Context) {
io.Copy(c.Writer, resp.Body)
}
// 处理分销佣金
// amount 为实付金额若有好友优惠则已打折order 用于判断是否有推荐人从而反推原价
// 处理分销佣金(会员订单 20%/10%,内容订单 90%
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
// 获取分成配置,默认 90%;好友优惠用于反推原价(佣金按原价计算)
distributorShare := 0.9
userDiscount := 0.0
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if disc, ok := config["userDiscount"].(float64); ok {
userDiscount = disc / 100
}
}
}
// 佣金按原价计算:若有推荐人则实付已打折,反推原价 = amount / (1 - userDiscount)
commissionBase := amount
if order != nil && userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || order.ReferralCode != nil && *order.ReferralCode != "") {
if (1 - userDiscount) > 0 {
commissionBase = amount / (1 - userDiscount)
}
}
// 查找有效推广绑定
type Binding struct {
ID int `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
@@ -539,7 +519,6 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
PurchaseCount int `gorm:"column:purchase_count"`
TotalCommission float64 `gorm:"column:total_commission"`
}
var binding Binding
err := db.Raw(`
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
@@ -548,31 +527,35 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
ORDER BY binding_date DESC
LIMIT 1
`, buyerUserID).Scan(&binding).Error
if err != nil {
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
return
}
// 检查是否过期
if time.Now().After(binding.ExpiryDate) {
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
return
}
// 计算佣金(按原价)
commission := commissionBase * distributorShare
// 确保 order 有 referrer_id补记订单可能缺失
if order != nil && (order.ReferrerID == nil || *order.ReferrerID == "") {
order.ReferrerID = &binding.ReferrerID
db.Model(order).Update("referrer_id", binding.ReferrerID)
}
// 构建用于计算的 order若为 nil 则用 binding 信息)
calcOrder := order
if calcOrder == nil {
calcOrder = &model.Order{Amount: amount, ProductType: "unknown", ReferrerID: &binding.ReferrerID}
}
commission := computeOrderCommission(db, calcOrder, nil)
if commission <= 0 {
fmt.Printf("[PayNotify] 佣金为 0跳过分佣: orderSn=%s\n", orderSn)
return
}
newPurchaseCount := binding.PurchaseCount + 1
newTotalCommission := binding.TotalCommission + commission
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
binding.ReferrerID, amount, commission, distributorShare*100)
// 更新推广者的待结算收益
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f\n",
binding.ReferrerID, amount, commission)
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
// 更新绑定记录COALESCE 避免 total_commission 为 NULL 时 NULL+?=NULL
db.Exec(`
UPDATE referral_bindings
SET last_purchase_date = NOW(),
@@ -580,7 +563,6 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
total_commission = COALESCE(total_commission, 0) + ?
WHERE id = ?
`, commission, binding.ID)
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
}

View File

@@ -85,18 +85,6 @@ func OrdersList(c *gin.Context) {
return
}
// 分销比例(与支付回调一致)
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
// 收集订单中的 user_id、referrer_id查用户信息
userIDs := make(map[string]bool)
for _, o := range orders {
@@ -150,10 +138,14 @@ func OrdersList(c *gin.Context) {
m["referrerCode"] = getStr(u.ReferralCode)
}
}
// 分销佣金:仅对已支付且存在推荐人的订单,按配置比例计算(与支付回调口径一致
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission会员 20%/10%,内容 90%
status := getStr(o.Status)
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
m["referrerEarnings"] = o.Amount * distributorShare
var refUser *model.User
if u := userMap[*o.ReferrerID]; u != nil {
refUser = u
}
m["referrerEarnings"] = computeOrderCommission(db, &o, refUser)
} else {
m["referrerEarnings"] = nil
}

View File

@@ -212,20 +212,16 @@ func ReferralData(c *gin.Context) {
).Count(&expiredBindings)
// 3. 付款统计
var paidOrders []struct {
Amount float64
UserID string
}
db.Model(&model.Order{}).
Select("amount, user_id").
Where("referrer_id = ? AND status = 'paid'", userId).
Find(&paidOrders)
var paidOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalAmount := 0.0
totalCommission := 0.0
uniqueUsers := make(map[string]bool)
for _, order := range paidOrders {
totalAmount += order.Amount
uniqueUsers[order.UserID] = true
for i := range paidOrders {
totalAmount += paidOrders[i].Amount
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
uniqueUsers[paidOrders[i].UserID] = true
}
uniquePaidCount := len(uniqueUsers)
@@ -344,11 +340,12 @@ func ReferralData(c *gin.Context) {
Find(&earningsDetailsList)
earningsDetails := []gin.H{}
for _, e := range earningsDetailsList {
for i := range earningsDetailsList {
e := &earningsDetailsList[i]
var buyer model.User
db.Where("id = ?", e.UserID).First(&buyer)
commission := e.Amount * distributorShare
commission := computeOrderCommission(db, e, nil)
earningsDetails = append(earningsDetails, gin.H{
"id": e.ID,
"orderSn": e.OrderSN,
@@ -363,9 +360,8 @@ func ReferralData(c *gin.Context) {
})
}
// 计算收益
totalCommission := totalAmount * distributorShare
estimatedEarnings := totalAmount * distributorShare
// 计算收益totalCommission 已按订单逐条计算)
estimatedEarnings := totalCommission
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
if availableEarnings < 0 {
availableEarnings = 0
@@ -442,31 +438,16 @@ func MyEarnings(c *gin.Context) {
return
}
db := database.DB()
distributorShare := 0.9
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
}
}
var user model.User
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
return
}
var paidOrders []struct {
Amount float64
}
db.Model(&model.Order{}).
Select("amount").
Where("referrer_id = ? AND status = 'paid'", userId).
Find(&paidOrders)
totalAmount := 0.0
for _, o := range paidOrders {
totalAmount += o.Amount
var paidOrders []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalCommission := 0.0
for i := range paidOrders {
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
}
var pendingWithdraw struct{ Total float64 }
db.Model(&model.Withdrawal{}).
@@ -478,7 +459,6 @@ func MyEarnings(c *gin.Context) {
Select("COALESCE(SUM(amount), 0) as total").
Where("user_id = ? AND status = ?", userId, "success").
Scan(&successWithdraw)
totalCommission := totalAmount * distributorShare
pendingWithdrawAmount := pendingWithdraw.Total
withdrawnFromTable := successWithdraw.Total
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount

View File

@@ -0,0 +1,69 @@
package handler
import (
"encoding/json"
"time"
"soul-api/internal/model"
"gorm.io/gorm"
)
// computeOrderCommission 按订单计算应付给推广者的佣金
// 会员订单:推广者会员 20%、非会员 10%内容订单90%(好友优惠 5% 仅针对内容)
// order: 已支付订单,需有 product_type、amount、referrer_id
// referrerUser: 推广者用户信息,用于判断 is_vip可为 nil会查库
func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model.User) float64 {
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
return 0
}
// 读取推广配置
distributorShare := 0.9
userDiscount := 0.0
vipOrderShareVip := 20.0
vipOrderShareNonVip := 10.0
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if disc, ok := config["userDiscount"].(float64); ok {
userDiscount = disc / 100
}
if v, ok := config["vipOrderShareVip"].(float64); ok {
vipOrderShareVip = v / 100
}
if v, ok := config["vipOrderShareNonVip"].(float64); ok {
vipOrderShareNonVip = v / 100
}
}
}
// 会员订单:无好友优惠,按推广者是否会员分 20%/10%
if order.ProductType == "vip" {
base := order.Amount
var referrer model.User
if referrerUser != nil {
referrer = *referrerUser
} else if err := db.Where("id = ?", *order.ReferrerID).First(&referrer).Error; err != nil {
return 0
}
isVip := referrer.IsVip != nil && *referrer.IsVip
if referrer.VipExpireDate != nil && referrer.VipExpireDate.Before(time.Now()) {
isVip = false
}
if isVip {
return base * vipOrderShareVip
}
return base * vipOrderShareNonVip
}
// 内容订单:若有推荐人且 userDiscount>0反推原价否则按实付
commissionBase := order.Amount
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
if (1 - userDiscount) > 0 {
commissionBase = order.Amount / (1 - userDiscount)
}
}
return commissionBase * distributorShare
}

View File

@@ -255,12 +255,12 @@ func VipMembers(c *gin.Context) {
return
}
// 列表:优先 users 表is_vip=1 且 vip_expire_date>NOW
// 列表:优先 users 表is_vip=1 且 vip_expire_date>NOW排序vip_sort 优先(小在前),否则 vip_activated_at DESC
var users []model.User
err := db.Table("users").
Select("id", "nickname", "avatar", "vip_name", "vip_project", "vip_avatar", "vip_bio").
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort").
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
Order("vip_expire_date DESC").
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
Limit(limit).
Find(&users).Error
@@ -320,12 +320,17 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if contact == "" && u.WechatID != nil {
contact = *u.WechatID
}
vipRole := ""
if u.VipRole != nil {
vipRole = *u.VipRole
}
return gin.H{
"id": u.ID,
"name": name,
"nickname": name,
"avatar": avatar,
"vip_name": name,
"vip_role": vipRole,
"vip_avatar": avatar,
"vip_project": project,
"vip_contact": contact,

View File

@@ -0,0 +1,90 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBVipRolesList GET /api/db/vip-roles 角色列表(管理端 Set VIP 下拉用)
func DBVipRolesList(c *gin.Context) {
db := database.DB()
var roles []model.VipRole
if err := db.Order("sort ASC, id ASC").Find(&roles).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": roles})
}
// DBVipRolesAction POST /api/db/vip-roles 新增角色PUT 更新DELETE 删除
func DBVipRolesAction(c *gin.Context) {
db := database.DB()
method := c.Request.Method
if method == "POST" {
var body struct {
Name string `json:"name" binding:"required"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"})
return
}
role := model.VipRole{Name: body.Name, Sort: body.Sort}
if err := db.Create(&role).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": role})
return
}
if method == "PUT" {
var body struct {
ID int `json:"id" binding:"required"`
Name *string `json:"name"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
updates := map[string]interface{}{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
}
if err := db.Model(&model.VipRole{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
return
}
if method == "DELETE" {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
if err := db.Where("id = ?", id).Delete(&model.VipRole{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}

View File

@@ -16,26 +16,23 @@ import (
)
// computeAvailableWithdraw 与小程序 / referral 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核
// 用于 referral/data 展示与 withdraw 接口二次查库校验(不信任前端传参
// 佣金按订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%
func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) {
distributorShare := 0.9
minAmount = 10
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(cfg.ConfigValue, &config); config != nil {
if share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if m, ok := config["minWithdrawAmount"].(float64); ok {
minAmount = m
}
}
}
var sumOrder struct{ Total float64 }
db.Model(&model.Order{}).Where("referrer_id = ? AND status = ?", userID, "paid").
Select("COALESCE(SUM(amount), 0) as total").Scan(&sumOrder)
totalCommission = sumOrder.Total * distributorShare
var orders []model.Order
db.Where("referrer_id = ? AND status = ?", userID, "paid").Find(&orders)
for i := range orders {
totalCommission += computeOrderCommission(db, &orders[i], nil)
}
var w struct{ Total float64 }
db.Model(&model.Withdrawal{}).Where("user_id = ? AND status = ?", userID, "success").
Select("COALESCE(SUM(amount), 0)").Scan(&w)

View File

@@ -24,9 +24,12 @@ type User struct {
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写)
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time手动=now
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序越小越前NULL 按 vip_activated_at
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`

View File

@@ -0,0 +1,14 @@
package model
import "time"
// VipRole 超级个体固定角色,用于 Set VIP 时下拉选择
type VipRole struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;size:50;not null" json:"name"`
Sort int `gorm:"column:sort;default:0" json:"sort"` // 下拉展示顺序,越小越前
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (VipRole) TableName() string { return "vip_roles" }

View File

@@ -130,6 +130,10 @@ func Setup(cfg *config.Config) *gin.Engine {
db.PUT("/users", handler.DBUsersAction)
db.DELETE("/users", handler.DBUsersDelete)
db.GET("/users/referrals", handler.DBUsersReferrals)
db.GET("/vip-roles", handler.DBVipRolesList)
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/match-records", handler.DBMatchRecordsList)
}

View File

@@ -0,0 +1,13 @@
-- 新增 users.vip_activated_at成为 VIP 时间,用于排序(后付款/后设置在前)
-- 执行mysql -u user -p database < add-vip-activated-at.sql
-- 若列已存在会报错,可忽略
ALTER TABLE users ADD COLUMN vip_activated_at DATETIME NULL COMMENT '成为VIP时间付款=pay_time手动=now排序用';
-- 可选:为已有 VIP 用户回填 vip_activated_at取该用户最近一次 vip 订单的 pay_time
-- UPDATE users u
-- SET u.vip_activated_at = (
-- SELECT MAX(o.pay_time) FROM orders o
-- WHERE o.user_id = u.id AND o.product_type = 'vip' AND o.status = 'paid'
-- )
-- WHERE u.is_vip = 1 AND u.vip_activated_at IS NULL;

View File

@@ -0,0 +1,25 @@
-- 1. 新建 vip_roles 表
CREATE TABLE IF NOT EXISTS vip_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
sort INT DEFAULT 0 COMMENT '下拉展示顺序,越小越前',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '超级个体固定角色';
-- 2. 插入默认角色UNIQUE name 防重复)
INSERT IGNORE INTO vip_roles (name, sort) VALUES
('创始人', 1),
('投资人', 2),
('产品经理', 3),
('技术负责人', 4),
('运营总监', 5),
('销售总监', 6),
('市场总监', 7),
('合伙人', 8),
('顾问', 9),
('品牌主理人', 10);
-- 3. users 表新增 vip_sort、vip_role
ALTER TABLE users ADD COLUMN vip_sort INT NULL COMMENT '手动排序,越小越前';
ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL COMMENT '角色:从 vip_roles 选或手动填写';

View File

@@ -1,263 +0,0 @@
<html class="dark" lang="zh-CN"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Clean Home with Contact Author Link</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#4FD1C5", // Teal accent
"background-dark": "#000000", // Pure black background
"card-dark": "#1C1C1E", // Dark gray card background
"text-primary": "#FFFFFF",
"text-secondary": "#A0AEC0",
"accent-orange": "#F6AD55",
},
fontFamily: {
sans: ['"Noto Sans SC"', 'sans-serif'],
},
borderRadius: {
DEFAULT: "0.75rem",
xl: "1rem",
'2xl': "1.5rem",
}
},
},
};
</script>
<style type="text/tailwindcss">
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
body {
min-height: max(884px, 100dvh);
background-color: #000000;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background-dark text-text-primary font-sans antialiased">
<div class="max-w-md mx-auto min-h-screen relative pb-24">
<header class="pt-6 px-4 pb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-black font-bold text-lg shadow-lg shadow-primary/20">
S
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold text-white">Soul创业派对</h1>
<button class="flex items-center gap-0.5 bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full text-[10px] font-bold text-primary active:scale-95 transition-transform">
<span class="material-symbols-outlined text-[12px]">add</span>
联系作者
</button>
</div>
<p class="text-xs text-text-secondary">来自派对房的真实故事</p>
</div>
</div>
<div class="bg-card-dark px-2 py-1 rounded-md text-xs font-medium text-primary">
73章
</div>
</header>
<div class="px-4 mt-4">
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-gray-500 text-sm">search</span>
</span>
<input class="block w-full pl-10 pr-3 py-3 rounded-xl bg-card-dark border-none text-sm placeholder-gray-500 text-white focus:ring-1 focus:ring-primary transition-all" placeholder="搜索章节标题或内容..." type="text"/>
</div>
</div>
<div class="px-4 mt-6">
<div class="bg-gradient-to-br from-[#1a2e2c] to-black rounded-2xl p-6 relative overflow-hidden border border-gray-800">
<div class="absolute -right-10 -top-10 w-40 h-40 bg-primary/5 rounded-full blur-3xl"></div>
<span class="inline-block bg-primary text-black text-[10px] font-bold px-2 py-1 rounded-md mb-3">最新更新</span>
<h2 class="text-2xl font-bold text-white mb-1">流量红利的终局</h2>
<p class="text-text-secondary text-sm mb-6">第五篇 真实的社会</p>
<a class="inline-flex items-center text-primary font-medium text-sm hover:text-white transition-colors" href="#">
开始阅读 <span class="material-symbols-outlined text-sm ml-1">arrow_forward</span>
</a>
</div>
</div>
<div class="px-4 mt-4">
<div class="bg-card-dark rounded-2xl p-4 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="font-bold text-sm text-white">我的阅读</h3>
<span class="text-xs text-text-secondary">2/73章</span>
</div>
<div class="w-full bg-black h-1.5 rounded-full mb-4 overflow-hidden">
<div class="bg-primary h-1.5 rounded-full" style="width: 3%"></div>
</div>
<div class="grid grid-cols-4 gap-2 text-center">
<div>
<div class="text-lg font-bold text-white">2</div>
<div class="text-xs text-text-secondary">已读</div>
</div>
<div>
<div class="text-lg font-bold text-white">71</div>
<div class="text-xs text-text-secondary">待读</div>
</div>
<div>
<div class="text-lg font-bold text-white">5</div>
<div class="text-xs text-text-secondary">篇章</div>
</div>
<div>
<div class="text-lg font-bold text-white">11</div>
<div class="text-xs text-text-secondary">章节</div>
</div>
</div>
</div>
</div>
<div class="mt-8">
<div class="flex justify-between items-center px-4 mb-3">
<h3 class="text-lg font-bold text-white">超级个体</h3>
<a class="text-primary text-xs flex items-center" href="#">查看全部 <span class="material-symbols-outlined text-[12px] ml-0.5">chevron_right</span></a>
</div>
<div class="flex overflow-x-auto px-4 gap-4 hide-scrollbar pb-2">
<div class="flex flex-col items-center min-w-[70px]">
<div class="w-14 h-14 rounded-full bg-card-dark flex items-center justify-center mb-2 overflow-hidden border border-gray-800">
<span class="material-symbols-outlined text-gray-500 text-3xl">person</span>
</div>
<span class="text-[10px] text-center text-text-secondary w-full truncate">微信用户RBEY</span>
</div>
<div class="flex flex-col items-center min-w-[70px]">
<div class="w-14 h-14 rounded-full bg-teal-900/20 flex items-center justify-center mb-2 overflow-hidden border border-gray-800">
<span class="material-symbols-outlined text-primary/40 text-3xl">person</span>
</div>
<span class="text-[10px] text-center text-text-secondary w-full truncate">微信用户HciA</span>
</div>
<div class="flex flex-col items-center min-w-[70px]">
<div class="w-14 h-14 rounded-full bg-amber-500/10 text-amber-500 font-bold flex items-center justify-center mb-2 border border-amber-500/20 shadow-md">
</div>
<span class="text-[10px] text-center text-text-secondary w-full truncate">好版登小程序顾问</span>
</div>
<div class="flex flex-col items-center min-w-[70px]">
<div class="w-14 h-14 rounded-full bg-card-dark flex items-center justify-center mb-2 overflow-hidden border border-gray-800">
<img alt="Profile of user" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDt0LPVk4rpoGURC14aewQDlOdPLCY-WBgu-orJ7EKJzZsYaiK2eSxQEL4l36LMFzK-fXWmWyX-xfhB4NQqEOhNM1Sc0tLIJmyRFNSgeitHN4Do7Beo9rKT-kQPmKSZGq6ae4XzpkJGoq9ymqfPqgqgpgSDicCKajZrR-MHh3Xz8NvKAlVKsRWo0HKv79WIo8muyOqz7C1j1X_bb3JJm3dKGi2Ls2UBiRvTj2W5F7vejrW3iHsazchJaXEa9tzgARzYKLUICz7nY0k"/>
</div>
<span class="text-[10px] text-center text-text-secondary w-full truncate">乘风</span>
</div>
</div>
</div>
<div class="mt-6 px-4">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-bold text-white">精选推荐</h3>
<a class="text-primary text-xs flex items-center" href="#">查看全部 <span class="material-symbols-outlined text-[12px] ml-0.5">chevron_right</span></a>
</div>
<div class="space-y-3">
<div class="bg-card-dark p-4 rounded-2xl flex items-center justify-between group active:scale-[0.98] transition-all">
<div class="flex-1 pr-4">
<div class="flex items-center gap-2 mb-1">
<span class="text-primary text-sm font-bold">1.1</span>
<span class="bg-accent-orange/10 text-accent-orange text-[10px] font-bold px-1.5 py-0.5 rounded">热门</span>
</div>
<h4 class="font-medium text-sm text-white line-clamp-1">荷包:电动车出租的被动收入模式</h4>
<p class="text-xs text-text-secondary mt-1">第一篇 真实的人</p>
</div>
<span class="material-symbols-outlined text-gray-600 group-hover:text-primary transition-colors text-lg">chevron_right</span>
</div>
<div class="bg-card-dark p-4 rounded-2xl flex items-center justify-between group active:scale-[0.98] transition-all">
<div class="flex-1 pr-4">
<div class="flex items-center gap-2 mb-1">
<span class="text-primary text-sm font-bold">1.2</span>
<span class="bg-primary/10 text-primary text-[10px] font-bold px-1.5 py-0.5 rounded">推荐</span>
</div>
<h4 class="font-medium text-sm text-white line-clamp-1">老墨:资源整合高手的社交方法</h4>
<p class="text-xs text-text-secondary mt-1">第一篇 真实的人</p>
</div>
<span class="material-symbols-outlined text-gray-600 group-hover:text-primary transition-colors text-lg">chevron_right</span>
</div>
<div class="bg-card-dark p-4 rounded-2xl flex items-center justify-between group active:scale-[0.98] transition-all">
<div class="flex-1 pr-4">
<div class="flex items-center gap-2 mb-1">
<span class="text-primary text-sm font-bold">9.17</span>
<span class="bg-primary/10 text-primary text-[10px] font-bold px-1.5 py-0.5 rounded">精选</span>
</div>
<h4 class="font-medium text-sm text-white line-clamp-1">第104场 | 婚恋、AI客服与一个微信</h4>
<p class="text-xs text-text-secondary mt-1">第四篇 真实的赚钱</p>
</div>
<span class="material-symbols-outlined text-gray-600 group-hover:text-primary transition-colors text-lg">chevron_right</span>
</div>
</div>
</div>
<div class="mt-8 px-4 pb-28">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-white">最新新增</h3>
<span class="bg-accent-orange text-black text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg shadow-accent-orange/20">+10</span>
</div>
<div class="relative pl-3 space-y-6 before:content-[''] before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[1px] before:bg-gray-800">
<div class="relative pl-6 flex items-start group">
<div class="absolute left-[-4.5px] top-1.5 w-2.5 h-2.5 rounded-full bg-primary ring-4 ring-black"></div>
<div class="flex-1 pb-4 border-b border-gray-900">
<div class="flex justify-between items-start mb-1">
<div class="flex items-center gap-2">
<span class="bg-accent-orange/10 text-accent-orange text-[9px] px-1 rounded font-bold border border-accent-orange/20">NEW</span>
<h4 class="text-sm font-medium text-white line-clamp-1 group-hover:text-primary transition-colors">流量红利的终局</h4>
</div>
<div class="flex flex-col items-end pl-2">
<span class="text-accent-orange font-bold text-sm">¥1</span>
<span class="text-[10px] text-text-secondary">2/24</span>
</div>
</div>
<p class="text-xs text-text-secondary line-clamp-1">深入分析当前市场的流量趋势与未来走向...</p>
</div>
</div>
<div class="relative pl-6 flex items-start group">
<div class="absolute left-[-4.5px] top-1.5 w-2.5 h-2.5 rounded-full bg-gray-800 ring-4 ring-black group-hover:bg-primary transition-colors"></div>
<div class="flex-1 pb-4 border-b border-gray-900">
<div class="flex justify-between items-start mb-1">
<div class="flex items-center gap-2">
<span class="bg-accent-orange/10 text-accent-orange text-[9px] px-1 rounded font-bold border border-accent-orange/20">NEW</span>
<h4 class="text-sm font-medium text-white line-clamp-1 group-hover:text-primary transition-colors">9.18 第105场 | 创业社群</h4>
</div>
<div class="flex flex-col items-end pl-2">
<span class="text-accent-orange font-bold text-sm">¥1</span>
<span class="text-[10px] text-text-secondary">2/24</span>
</div>
</div>
<p class="text-xs text-text-secondary line-clamp-1">社群运营的核心逻辑与直播实战技巧分享...</p>
</div>
</div>
</div>
</div>
<div class="fixed bottom-6 left-4 right-4 bg-card-dark/90 backdrop-blur-md rounded-full shadow-2xl z-50 px-6 py-3 border border-gray-800">
<div class="flex justify-between items-center relative">
<button class="flex flex-col items-center gap-1 text-primary">
<span class="material-symbols-outlined text-2xl font-variation-fill-1">home</span>
<span class="text-[10px] font-medium">首页</span>
</button>
<button class="flex flex-col items-center gap-1 text-gray-500 hover:text-primary transition-colors">
<span class="material-symbols-outlined text-2xl">list</span>
<span class="text-[10px] font-medium">目录</span>
</button>
<div class="relative -top-8 group">
<button class="w-14 h-14 bg-primary rounded-full flex items-center justify-center shadow-lg shadow-primary/20 transform transition-transform active:scale-95">
<span class="material-symbols-outlined text-black text-2xl font-bold">groups</span>
</button>
<span class="absolute -bottom-6 w-full text-center text-[10px] font-medium text-gray-500">找伙伴</span>
</div>
<button class="flex flex-col items-center gap-1 text-gray-500 hover:text-primary transition-colors">
<span class="material-symbols-outlined text-2xl">person</span>
<span class="text-[10px] font-medium">我的</span>
</button>
<div class="w-2"></div>
</div>
</div>
</div>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -109,10 +109,35 @@ VIP 接口、章节推荐逻辑、数据库依赖
## 分润规则
- **内容订单**:好友优惠 5%(仅针对文章/内容,会员订单无优惠);推广者 90%
- **会员订单**:推广者会员 20%、非会员 10%待实现,技术分析见 `临时需求池/分润需求-技术分析.md`
- **会员订单**:推广者会员 20%、非会员 10%**已实现**computeOrderCommission推广设置页可配置 vipOrderShareVip、vipOrderShareNonVip
## 需求池
- **需求分析**`临时需求池/需求分析-产品经理视角.md`
- **分润技术分析**`临时需求池/分润需求-技术分析.md`
- **搁置**:打包购买引导、存客宝对接
---
## VIP 升级2026-02-26 小橙同步)
### 功能变更
| 项目 | 说明 |
|------|------|
| **设置入口** | 用户列表每行新增「设置 VIP」按钮独立 SetVipModalUserDetailModal 移除 VIP 区块 |
| **排序** | `vip_activated_at`:付款=订单 pay_time手动=now`vip_sort` 手动排序VipMembers 按「vip_sort 优先,否则 vip_activated_at DESC」 |
| **角色** | 新增 `vip_roles` 表;`users.vip_role` 存角色名SetVipModal 下拉可选 +「其他」手动填写 |
| **管理端** | 新增「VIP 角色」页 `/vip-roles`CRUD 预设角色 |
### 数据迁移
- `开发文档/8、部署/VIP功能-数据库迁移说明.md`
- 脚本:`soul-api/scripts/add-vip-activated-at.sql``add-vip-roles-and-fields.sql`
### Skills 升级
- SKILL-MySQL直接操作§8 迁移脚本最佳实践
- SKILL-API开发§3.2 新增表/字段完整流程
- SKILL-管理端开发§4.1 表单弹窗与「可选择+可手动填写」
- SKILL-变更关联检查VIP/超级个体三端分工表

View File

@@ -33,4 +33,7 @@ IP 设定、风格、输出规范(见原卡若角色设定)。
| 2026-02 | 找伙伴匹配后台用户库、资源对接两步(能帮什么/需要什么) | 已有 | match 页与后端 /api/match |
| 2026-02 | VIP 手动设置 + 支付设置 + 日志(区分来源、订单号) | 已完成 | 用户详情弹窗、支付回调、db/miniprogram handler |
| 2026-02 | 管理端设置 VIP 必填到期日 | 已完成 | 前后端校验 |
| 2026-02 | 会员订单分润差异化(会员 20% / 非会员 10% | 待实现 | 技术分析见 临时需求池/分润需求-技术分析.md |
| 2026-02 | 会员订单分润差异化(会员 20% / 非会员 10% | 已完成 | 技术分析见 临时需求池/分润需求-技术分析.mdcomputeOrderCommission推广设置页增加会员分润配置 |
| 2026-02 | VIP 设置入口拆分:用户列表「设置 VIP」按钮 + 独立弹窗 | 已完成 | UserDetailModal 移除 VIP 区块SetVipModal 独立 |
| 2026-02 | VIP 排序:后付款/后设置在前;支持手动排序 | 已完成 | vip_activated_at、vip_sortVipMembers 排序逻辑 |
| 2026-02 | VIP 角色:可选择 + 可手动填写 | 已完成 | vip_roles 表VIP 角色管理页SetVipModal 角色下拉 |

View File

@@ -0,0 +1,44 @@
# VIP 功能 - 数据库迁移说明
> 2026-02-26 小橙同步。VIP 排序、角色、设置入口升级。
---
## 一、迁移脚本
| 脚本 | 说明 |
|------|------|
| `soul-api/scripts/add-vip-activated-at.sql` | 新增 `users.vip_activated_at`(成为 VIP 时间,排序用) |
| `soul-api/scripts/add-vip-roles-and-fields.sql` | 新建 `vip_roles` 表;新增 `users.vip_sort``users.vip_role` |
---
## 二、执行顺序
```bash
# 1. vip_activated_at若尚未执行
mysql -u user -p database < soul-api/scripts/add-vip-activated-at.sql
# 2. vip_roles 表 + users 新字段
mysql -u user -p database < soul-api/scripts/add-vip-roles-and-fields.sql
```
`vip_sort``vip_role` 已存在,对应 `ALTER` 会报错,可忽略或单独执行未执行过的语句。
---
## 三、功能说明
| 字段/表 | 用途 |
|---------|------|
| `vip_activated_at` | 成为 VIP 时间:付款=订单 pay_time手动=设置时 now排序用后付款/后设置在前) |
| `vip_sort` | 手动排序数字越小越靠前NULL 时按 vip_activated_at |
| `vip_role` | 角色:从 vip_roles 选或手动填写 |
| `vip_roles` | 预设角色表(创始人、投资人、产品经理等),管理端可 CRUD |
---
## 四、管理端入口
- **用户列表**:每行「设置 VIP」按钮Crown 图标)→ SetVipModal
- **VIP 角色**侧栏「VIP 角色」→ `/vip-roles`,管理预设角色列表

View File

@@ -18,6 +18,7 @@
| 文档 | 说明 |
|------|------|
| [VIP功能-数据库迁移说明](VIP功能-数据库迁移说明.md) | VIP 排序、角色、vip_roles 表迁移2026-02-26 |
| [MCP-MySQL配置说明](MCP-MySQL配置说明.md) | MCP 连接 MySQL |
| [Soul-MySQL-MCP配置说明](Soul-MySQL-MCP配置说明.md) | 本项目 MySQL MCP 配置 |
| [API接入说明](API接入说明.md) | 外部/小程序接入 API |