更新管理端用户详情弹窗,新增 VIP 手动设置功能,支持到期日、展示名、项目、联系方式和简介的编辑。优化 VIP 相关接口,确保用户状态和资料更新功能正常,增强用户体验。调整文档,明确 VIP 设置的必填项和格式要求。
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -52,4 +52,4 @@ alwaysApply: true
|
||||
| 涉及「该接口给谁用」 | 先确定使用方再写/改代码,避免路径混用 |
|
||||
| **跨端功能开发** | 加载 **SKILL-角色流程控制.md**,按协同流程执行 |
|
||||
| **变更完成准备提交** | **必过** **soul-change-checklist.mdc** + **SKILL-变更关联检查.md**,未过即视为漏改 |
|
||||
| **小橙/橙子/橙橙/🍊、讨论完毕、记录、同步文档** | 加载 **SKILL-助理小风-文档同步.md**,以小橙身份更新开发文档 |
|
||||
| **小橙/橙子/橙橙/🍊、讨论完毕、记录、同步文档** | 加载 **SKILL-助理橙子-文档同步.md**,以小橙身份更新开发文档 |
|
||||
|
||||
@@ -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/` 编写迁移 SQL(CREATE TABLE IF NOT EXISTS、ALTER TABLE ADD COLUMN、INSERT IGNORE 默认数据) |
|
||||
| 4 | 新建 `internal/handler/xxx.go` 实现 CRUD(GET 列表、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 依赖物尽其用(补充)
|
||||
|
||||
| 依赖 | 用途 | 约定 |
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/members;db/users PUT、db/vip-roles | 用户列表「设置 VIP」弹窗、VIP 角色管理页 |
|
||||
|
||||
每次做「某个功能」的变更时,问:
|
||||
|
||||
|
||||
@@ -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
455
soul-admin/dist/assets/index-BvA8RYip.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-Bw4NCaMi.css
vendored
Normal file
1
soul-admin/dist/assets/index-Bw4NCaMi.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-BwYBUNyp.css
vendored
1
soul-admin/dist/assets/index-BwYBUNyp.css
vendored
File diff suppressed because one or more lines are too long
460
soul-admin/dist/assets/index-gaoGu1RS.js
vendored
460
soul-admin/dist/assets/index-gaoGu1RS.js
vendored
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
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-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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
280
soul-admin/src/components/modules/user/SetVipModal.tsx
Normal file
280
soul-admin/src/components/modules/user/SetVipModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
242
soul-admin/src/pages/vip-roles/VipRolesPage.tsx
Normal file
242
soul-admin/src/pages/vip-roles/VipRolesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
69
soul-api/internal/handler/referral_commission.go
Normal file
69
soul-api/internal/handler/referral_commission.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
90
soul-api/internal/handler/vip_roles.go
Normal file
90
soul-api/internal/handler/vip_roles.go
Normal 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": "不支持的请求方法"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
14
soul-api/internal/model/vip_role.go
Normal file
14
soul-api/internal/model/vip_role.go
Normal 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" }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
13
soul-api/scripts/add-vip-activated-at.sql
Normal file
13
soul-api/scripts/add-vip-activated-at.sql
Normal 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;
|
||||
25
soul-api/scripts/add-vip-roles-and-fields.sql
Normal file
25
soul-api/scripts/add-vip-roles-and-fields.sql
Normal 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 选或手动填写';
|
||||
@@ -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&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 |
@@ -109,10 +109,35 @@ VIP 接口、章节推荐逻辑、数据库依赖
|
||||
## 分润规则
|
||||
|
||||
- **内容订单**:好友优惠 5%(仅针对文章/内容,会员订单无优惠);推广者 90%
|
||||
- **会员订单**:推广者会员 20%、非会员 10%(待实现,技术分析见 `临时需求池/分润需求-技术分析.md`)
|
||||
- **会员订单**:推广者会员 20%、非会员 10%(**已实现**,computeOrderCommission;推广设置页可配置 vipOrderShareVip、vipOrderShareNonVip)
|
||||
|
||||
## 需求池
|
||||
|
||||
- **需求分析**:`临时需求池/需求分析-产品经理视角.md`
|
||||
- **分润技术分析**:`临时需求池/分润需求-技术分析.md`
|
||||
- **搁置**:打包购买引导、存客宝对接
|
||||
|
||||
---
|
||||
|
||||
## VIP 升级(2026-02-26 小橙同步)
|
||||
|
||||
### 功能变更
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **设置入口** | 用户列表每行新增「设置 VIP」按钮,独立 SetVipModal;UserDetailModal 移除 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/超级个体三端分工表
|
||||
|
||||
@@ -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%) | 已完成 | 技术分析见 临时需求池/分润需求-技术分析.md;computeOrderCommission;推广设置页增加会员分润配置 |
|
||||
| 2026-02 | VIP 设置入口拆分:用户列表「设置 VIP」按钮 + 独立弹窗 | 已完成 | UserDetailModal 移除 VIP 区块;SetVipModal 独立 |
|
||||
| 2026-02 | VIP 排序:后付款/后设置在前;支持手动排序 | 已完成 | vip_activated_at、vip_sort;VipMembers 排序逻辑 |
|
||||
| 2026-02 | VIP 角色:可选择 + 可手动填写 | 已完成 | vip_roles 表;VIP 角色管理页;SetVipModal 角色下拉 |
|
||||
|
||||
44
开发文档/8、部署/VIP功能-数据库迁移说明.md
Normal file
44
开发文档/8、部署/VIP功能-数据库迁移说明.md
Normal 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`,管理预设角色列表
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user