Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing.
This commit is contained in:
@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
|
||||
App({
|
||||
globalData: {
|
||||
// API基础地址 - 连接真实后端
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://souldev.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:8080',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
|
||||
|
||||
// 小程序配置 - 真实AppID
|
||||
@@ -207,6 +207,31 @@ App({
|
||||
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
|
||||
},
|
||||
|
||||
// 判断用户资料是否完善(昵称 + 头像)
|
||||
_isProfileIncomplete(user) {
|
||||
if (!user) return true
|
||||
const nickname = (user.nickname || '').trim()
|
||||
const avatar = (user.avatar || '').trim()
|
||||
const isDefaultNickname = !nickname || nickname === '微信用户'
|
||||
const noAvatar = !avatar
|
||||
return isDefaultNickname || noAvatar
|
||||
},
|
||||
|
||||
// 登录后若资料未完善,引导跳转到资料编辑页
|
||||
_ensureProfileCompletedAfterLogin(user) {
|
||||
try {
|
||||
if (!user || !this._isProfileIncomplete(user)) return
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
// 避免在资料页内重复跳转
|
||||
if (current && current.route === 'pages/profile-edit/profile-edit') return
|
||||
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
} catch (e) {
|
||||
console.warn('[App] 跳转资料编辑页失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 根据业务 id 从 bookData 查 mid(用于跳转)
|
||||
getSectionMid(sectionId) {
|
||||
const list = this.globalData.bookData || []
|
||||
@@ -479,12 +504,13 @@ App({
|
||||
|
||||
// 保存用户信息
|
||||
if (res.data.user) {
|
||||
this.globalData.userInfo = res.data.user
|
||||
const user = res.data.user
|
||||
this.globalData.userInfo = user
|
||||
this.globalData.isLoggedIn = true
|
||||
this.globalData.purchasedSections = res.data.user.purchasedSections || []
|
||||
this.globalData.hasFullBook = res.data.user.hasFullBook || false
|
||||
this.globalData.purchasedSections = user.purchasedSections || []
|
||||
this.globalData.hasFullBook = user.hasFullBook || false
|
||||
|
||||
wx.setStorageSync('userInfo', res.data.user)
|
||||
wx.setStorageSync('userInfo', user)
|
||||
wx.setStorageSync('token', res.data.token || '')
|
||||
|
||||
// 登录成功后,检查待绑定的推荐码并执行绑定
|
||||
@@ -493,6 +519,9 @@ App({
|
||||
console.log('[App] 登录后自动绑定推荐码:', pendingRef)
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
}
|
||||
|
||||
return res.data
|
||||
@@ -540,17 +569,21 @@ App({
|
||||
wx.setStorageSync('openId', res.data.openId)
|
||||
// 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
|
||||
if (res.data.user) {
|
||||
this.globalData.userInfo = res.data.user
|
||||
const user = res.data.user
|
||||
this.globalData.userInfo = user
|
||||
this.globalData.isLoggedIn = true
|
||||
this.globalData.purchasedSections = res.data.user.purchasedSections || []
|
||||
this.globalData.hasFullBook = res.data.user.hasFullBook || false
|
||||
wx.setStorageSync('userInfo', res.data.user)
|
||||
this.globalData.purchasedSections = user.purchasedSections || []
|
||||
this.globalData.hasFullBook = user.hasFullBook || false
|
||||
wx.setStorageSync('userInfo', user)
|
||||
wx.setStorageSync('token', res.data.token || '')
|
||||
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
|
||||
if (pendingRef) {
|
||||
console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
}
|
||||
return res.data.openId
|
||||
}
|
||||
@@ -587,12 +620,13 @@ App({
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
this.globalData.userInfo = res.data.user
|
||||
const user = res.data.user
|
||||
this.globalData.userInfo = user
|
||||
this.globalData.isLoggedIn = true
|
||||
this.globalData.purchasedSections = res.data.user.purchasedSections || []
|
||||
this.globalData.hasFullBook = res.data.user.hasFullBook || false
|
||||
this.globalData.purchasedSections = user.purchasedSections || []
|
||||
this.globalData.hasFullBook = user.hasFullBook || false
|
||||
|
||||
wx.setStorageSync('userInfo', res.data.user)
|
||||
wx.setStorageSync('userInfo', user)
|
||||
wx.setStorageSync('token', res.data.token)
|
||||
|
||||
// 登录成功后绑定推荐码
|
||||
@@ -601,6 +635,9 @@ App({
|
||||
console.log('[App] 手机号登录后自动绑定推荐码:', pendingRef)
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages/match/match",
|
||||
"pages/my/my",
|
||||
"pages/read/read",
|
||||
"pages/link-preview/link-preview",
|
||||
"pages/about/about",
|
||||
"pages/agreement/agreement",
|
||||
"pages/privacy/privacy",
|
||||
@@ -16,7 +17,11 @@
|
||||
"pages/addresses/edit",
|
||||
"pages/withdraw-records/withdraw-records",
|
||||
"pages/vip/vip",
|
||||
"pages/member-detail/member-detail","pages/mentors/mentors","pages/mentor-detail/mentor-detail","pages/profile-show/profile-show","pages/profile-edit/profile-edit"
|
||||
"pages/member-detail/member-detail",
|
||||
"pages/mentors/mentors",
|
||||
"pages/mentor-detail/mentor-detail",
|
||||
"pages/profile-show/profile-show",
|
||||
"pages/profile-edit/profile-edit"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
|
||||
@@ -205,10 +205,14 @@ Page({
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 最新更新:用 book/latest-chapters 取第1条
|
||||
// 2. 最新更新:用 book/latest-chapters 取第1条(排除「序言」「尾声」「附录」)
|
||||
try {
|
||||
const latestRes = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
|
||||
const latestList = (latestRes && latestRes.data) ? latestRes.data : []
|
||||
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
|
||||
const latestList = rawList.filter(l => {
|
||||
const pt = (l.part_title || l.partTitle || '').toLowerCase()
|
||||
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
|
||||
})
|
||||
if (latestList.length > 0) {
|
||||
const l = latestList[0]
|
||||
this.setData({
|
||||
@@ -341,7 +345,7 @@ Page({
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/lead',
|
||||
url: '/api/miniprogram/ckb/index-lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
@@ -426,25 +430,25 @@ Page({
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/lead',
|
||||
url: '/api/miniprogram/ckb/index-lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
phone: p,
|
||||
name: (app.globalData.userInfo?.nickname || '').trim() || undefined
|
||||
}
|
||||
name: (app.globalData.userInfo?.nickname || '').trim() || undefined,
|
||||
},
|
||||
})
|
||||
wx.hideLoading()
|
||||
this.setData({ showLeadModal: false, leadPhone: '' })
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
// 同步手机号到用户资料
|
||||
try {
|
||||
const currentPhone = (app.globalData.userInfo?.phone || '').trim()
|
||||
if (!currentPhone && userId) {
|
||||
if (userId) {
|
||||
await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
method: 'POST',
|
||||
data: { userId, phone: p }
|
||||
data: { userId, phone: p },
|
||||
})
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.phone = p
|
||||
|
||||
43
miniprogram/pages/link-preview/link-preview.js
Normal file
43
miniprogram/pages/link-preview/link-preview.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
url: '',
|
||||
title: '链接预览',
|
||||
statusBarHeight: 44,
|
||||
navBarHeight: 88,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const url = decodeURIComponent(options.url || '')
|
||||
const title = options.title ? decodeURIComponent(options.title) : '链接预览'
|
||||
this.setData({
|
||||
url,
|
||||
title,
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
navBarHeight: app.globalData.navBarHeight || 88,
|
||||
})
|
||||
},
|
||||
|
||||
goBack() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
wx.navigateBack()
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
},
|
||||
|
||||
copyLink() {
|
||||
const url = (this.data.url || '').trim()
|
||||
if (!url) {
|
||||
wx.showToast({ title: '暂无可复制链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: '链接已复制', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
4
miniprogram/pages/link-preview/link-preview.json
Normal file
4
miniprogram/pages/link-preview/link-preview.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
28
miniprogram/pages/link-preview/link-preview.wxml
Normal file
28
miniprogram/pages/link-preview/link-preview.wxml
Normal file
@@ -0,0 +1,28 @@
|
||||
<view class="page">
|
||||
<!-- 简单自定义导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content" style="height: {{navBarHeight - statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-arrow">←</text>
|
||||
</view>
|
||||
<view class="nav-title">
|
||||
<text class="nav-title-text">{{title}}</text>
|
||||
</view>
|
||||
<view class="nav-actions">
|
||||
<view class="copy-btn" bindtap="copyLink">
|
||||
<text class="copy-text">复制链接</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
|
||||
|
||||
<!-- 链接预览区域 -->
|
||||
<view class="webview-wrap" wx:if="{{url}}">
|
||||
<web-view src="{{url}}"></web-view>
|
||||
</view>
|
||||
<view class="empty-wrap" wx:else>
|
||||
<text class="empty-text">暂无链接地址</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
83
miniprogram/pages/link-preview/link-preview.wxss
Normal file
83
miniprogram/pages/link-preview/link-preview.wxss
Normal file
@@ -0,0 +1,83 @@
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 40rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-title-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
min-width: 120rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 6rpx 10rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.copy-text {
|
||||
font-size: 22rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.webview-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
web-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
@@ -596,8 +596,19 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 复制用户ID
|
||||
// 复制联系方式:优先复制微信号,其次复制用户ID
|
||||
copyUserId() {
|
||||
const userWechat = (this.data.userWechat || '').trim()
|
||||
if (userWechat) {
|
||||
wx.setClipboardData({
|
||||
data: userWechat,
|
||||
success: () => {
|
||||
wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const userId = this.data.userInfo?.id || ''
|
||||
if (!userId) {
|
||||
wx.showToast({ title: '暂无ID', icon: 'none' })
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
|
||||
</view>
|
||||
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
|
||||
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-stats-row">
|
||||
|
||||
@@ -37,6 +37,7 @@ Page({
|
||||
showMbtiPicker: false,
|
||||
saving: false,
|
||||
loading: true,
|
||||
showAvatarModal: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -95,6 +96,7 @@ Page({
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
|
||||
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
|
||||
onRegionInput(e) { this.setData({ region: e.detail.value }) },
|
||||
onIndustryInput(e) { this.setData({ industry: e.detail.value }) },
|
||||
onBusinessScaleInput(e) { this.setData({ businessScale: e.detail.value }) },
|
||||
@@ -114,7 +116,26 @@ Page({
|
||||
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
|
||||
},
|
||||
|
||||
chooseAvatar() {
|
||||
// 点击头像:选择微信头像或从相册选择
|
||||
onAvatarTap() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['使用微信头像', '从相册选择'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.setData({ showAvatarModal: true })
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.chooseAvatarFromAlbum()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
closeAvatarModal() {
|
||||
this.setData({ showAvatarModal: false })
|
||||
},
|
||||
|
||||
// 从相册/相机选择头像
|
||||
chooseAvatarFromAlbum() {
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
@@ -160,6 +181,52 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 微信原生 chooseAvatar 回调:使用当前微信头像
|
||||
async onChooseAvatar(e) {
|
||||
const tempAvatarUrl = e.detail?.avatarUrl
|
||||
this.setData({ showAvatarModal: false })
|
||||
if (!tempAvatarUrl) return
|
||||
wx.showLoading({ title: '上传中...', mask: true })
|
||||
|
||||
try {
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempAvatarUrl,
|
||||
name: 'file',
|
||||
formData: { folder: 'avatars' },
|
||||
success: (r) => {
|
||||
try {
|
||||
const data = JSON.parse(r.data)
|
||||
if (data.success) resolve(data)
|
||||
else reject(new Error(data.error || '上传失败'))
|
||||
} catch {
|
||||
reject(new Error('解析失败'))
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
|
||||
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
|
||||
this.setData({ avatar: avatarUrl })
|
||||
await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
method: 'POST',
|
||||
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
|
||||
})
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.avatar = avatarUrl
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||
} catch (err) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-wrap" bindtap="chooseAvatar">
|
||||
<view class="avatar-wrap" bindtap="onAvatarTap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
@@ -31,7 +31,18 @@
|
||||
<view class="section">
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入昵称" value="{{nickname}}" bindinput="onNicknameInput"/></view>
|
||||
<view class="form-input-wrap">
|
||||
<input
|
||||
class="form-input-inner"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
value="{{nickname}}"
|
||||
bindinput="onNicknameInput"
|
||||
bindchange="onNicknameChange"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
|
||||
</view>
|
||||
<view class="form-row form-row-2">
|
||||
<view class="form-item">
|
||||
@@ -134,4 +145,15 @@
|
||||
</view>
|
||||
<view class="bottom-space"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 头像弹窗:通过 button 获取微信头像 -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
|
||||
<text class="avatar-modal-title">使用微信头像</text>
|
||||
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
|
||||
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
|
||||
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -101,3 +101,83 @@
|
||||
}
|
||||
.save-btn[disabled] { opacity: 0.6; }
|
||||
.bottom-space { height: 120rpx; }
|
||||
|
||||
/* 昵称提示文案 */
|
||||
.input-tip {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
/* 头像弹窗样式,复用我的页风格 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(16rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
background: #0b1220;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.avatar-modal-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.avatar-modal-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.btn-choose-avatar {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
background: #5EEAD4;
|
||||
color: #050B14;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
}
|
||||
.btn-choose-avatar::after {
|
||||
border: none;
|
||||
}
|
||||
.avatar-modal-cancel {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
@@ -421,31 +421,47 @@ Page({
|
||||
},
|
||||
|
||||
|
||||
// 加载导航
|
||||
loadNavigation(id) {
|
||||
const sectionOrder = [
|
||||
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
|
||||
'2.1', '2.2', '2.3', '2.4', '2.5',
|
||||
'3.1', '3.2', '3.3', '3.4',
|
||||
'4.1', '4.2', '4.3', '4.4', '4.5',
|
||||
'5.1', '5.2', '5.3', '5.4', '5.5',
|
||||
'6.1', '6.2', '6.3', '6.4',
|
||||
'7.1', '7.2', '7.3', '7.4', '7.5',
|
||||
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
|
||||
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
|
||||
'10.1', '10.2', '10.3', '10.4',
|
||||
'11.1', '11.2', '11.3', '11.4', '11.5',
|
||||
'epilogue'
|
||||
]
|
||||
|
||||
const currentIndex = sectionOrder.indexOf(id)
|
||||
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
|
||||
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
|
||||
|
||||
this.setData({
|
||||
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
|
||||
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
|
||||
})
|
||||
// 加载导航:基于后端章节顺序计算上一篇/下一篇
|
||||
async loadNavigation(id) {
|
||||
try {
|
||||
// 优先使用全局缓存的 bookData
|
||||
let chapters = app.globalData.bookData || []
|
||||
if (!chapters || !Array.isArray(chapters) || chapters.length === 0) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
chapters = (res && (res.data || res.chapters)) || []
|
||||
}
|
||||
if (!chapters || chapters.length === 0) {
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
return
|
||||
}
|
||||
// 过滤掉没有 id 的记录,并按 sort_order + id 排序
|
||||
const ordered = chapters
|
||||
.filter(c => c.id)
|
||||
.sort((a, b) => {
|
||||
const soA = typeof a.sort_order === 'number' ? a.sort_order : (typeof a.sortOrder === 'number' ? a.sortOrder : 0)
|
||||
const soB = typeof b.sort_order === 'number' ? b.sort_order : (typeof b.sortOrder === 'number' ? b.sortOrder : 0)
|
||||
if (soA !== soB) return soA - soB
|
||||
return String(a.id).localeCompare(String(b.id), 'zh-Hans-CN')
|
||||
})
|
||||
const index = ordered.findIndex(c => String(c.id) === String(id))
|
||||
const prev = index > 0 ? ordered[index - 1] : null
|
||||
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null
|
||||
this.setData({
|
||||
prevSection: prev ? {
|
||||
id: prev.id,
|
||||
mid: prev.mid ?? prev.MID ?? null,
|
||||
title: prev.section_title || prev.sectionTitle || prev.title || this.getSectionTitle(prev.id),
|
||||
} : null,
|
||||
nextSection: next ? {
|
||||
id: next.id,
|
||||
mid: next.mid ?? next.MID ?? null,
|
||||
title: next.section_title || next.sectionTitle || next.title || this.getSectionTitle(next.id),
|
||||
} : null,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[Read] loadNavigation failed:', e)
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
}
|
||||
},
|
||||
|
||||
// 返回(从分享进入无栈时回首页)
|
||||
@@ -453,7 +469,7 @@ Page({
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
// 点击正文中的 #链接标签:外链复制到剪贴板,小程序内页直接跳转
|
||||
// 点击正文中的 #链接标签:小程序内页/预览页跳转
|
||||
onLinkTagTap(e) {
|
||||
let url = (e.currentTarget.dataset.url || '').trim()
|
||||
const label = (e.currentTarget.dataset.label || '').trim()
|
||||
@@ -484,25 +500,13 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
// 外部 URL:优先用 wx.openLink 在浏览器打开,旧版微信降级复制
|
||||
// 外部 URL:跳转到内置预览页,由 web-view 打开
|
||||
if (url) {
|
||||
if (typeof wx.openLink === 'function') {
|
||||
wx.openLink({
|
||||
url,
|
||||
fail: () => {
|
||||
// openLink 不支持(如不在微信内),降级复制
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
|
||||
})
|
||||
}
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
const encodedTitle = encodeURIComponent(label || '链接预览')
|
||||
wx.navigateTo({
|
||||
url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1141,14 +1145,18 @@ Page({
|
||||
// 跳转到上一篇
|
||||
goToPrev() {
|
||||
if (this.data.prevSection) {
|
||||
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
|
||||
const { id, mid } = this.data.prevSection
|
||||
const query = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.redirectTo({ url: `/pages/read/read?${query}` })
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转到下一篇
|
||||
goToNext() {
|
||||
if (this.data.nextSection) {
|
||||
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
|
||||
const { id, mid } = this.data.nextSection
|
||||
const query = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.redirectTo({ url: `/pages/read/read?${query}` })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@
|
||||
<!-- 分享操作区 -->
|
||||
<view class="action-section">
|
||||
<view class="action-row-inline">
|
||||
<button class="action-btn-inline btn-share-inline" open-type="share">
|
||||
<text class="action-icon-small">💬</text>
|
||||
<text class="action-text-small">推荐给好友</text>
|
||||
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
|
||||
<text class="action-icon-small">📣</text>
|
||||
<text class="action-text-small">分享到朋友圈</text>
|
||||
</button>
|
||||
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
|
||||
<text class="action-icon-small">🖼️</text>
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
# 宝塔部署:若 API 站点开启了强制 HTTPS,这里必须用 https,否则预检请求会被重定向导致 CORS 报错
|
||||
# VITE_API_BASE_URL=http://localhost:3006
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
# VITE_API_BASE_URL=https://soulapi.quwanzhi.com
|
||||
|
||||
# VITE_API_BASE_URL=https://souldev.quwanzhi.com
|
||||
|
||||
|
||||
@@ -226,6 +226,8 @@ export function ContentPage() {
|
||||
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
|
||||
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
|
||||
const [rankingPage, setRankingPage] = useState(1)
|
||||
const [rankedSectionsList, setRankedSectionsList] = useState<SectionListItem[]>([])
|
||||
const [rankingLoading, setRankingLoading] = useState(false)
|
||||
const [pinnedSectionIds, setPinnedSectionIds] = useState<string[]>([])
|
||||
const [pinnedLoading, setPinnedLoading] = useState(false)
|
||||
const [previewPercent, setPreviewPercent] = useState(20)
|
||||
@@ -236,21 +238,28 @@ export function ContentPage() {
|
||||
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||||
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
|
||||
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
|
||||
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
|
||||
const [newLinkTag, setNewLinkTag] = useState({
|
||||
tagId: '',
|
||||
label: '',
|
||||
url: '',
|
||||
type: 'url' as 'url' | 'miniprogram' | 'ckb',
|
||||
appId: '',
|
||||
pagePath: '',
|
||||
})
|
||||
const [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
|
||||
const richEditorRef = useRef<RichEditorRef>(null)
|
||||
|
||||
const tree = buildTree(sectionsList)
|
||||
const totalSections = sectionsList.length
|
||||
|
||||
const rankedSections = [...sectionsList].sort((a, b) => (b.hotScore ?? 0) - (a.hotScore ?? 0))
|
||||
// 内容排行榜:排序与置顶由后端 API 统一计算,前端只展示
|
||||
const RANKING_PAGE_SIZE = 10
|
||||
const rankingTotalPages = Math.max(1, Math.ceil(rankedSections.length / RANKING_PAGE_SIZE))
|
||||
const rankingPageSections = rankedSections.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
|
||||
const rankingTotalPages = Math.max(1, Math.ceil(rankedSectionsList.length / RANKING_PAGE_SIZE))
|
||||
const rankingPageSections = rankedSectionsList.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
|
||||
|
||||
const loadList = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
|
||||
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
|
||||
'/api/db/book?action=list',
|
||||
{ cache: 'no-store' as RequestCache },
|
||||
@@ -264,8 +273,29 @@ export function ContentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRanking = async () => {
|
||||
setRankingLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
|
||||
'/api/db/book?action=ranking',
|
||||
{ cache: 'no-store' as RequestCache },
|
||||
)
|
||||
const sections = Array.isArray(data?.sections) ? data.sections : []
|
||||
setRankedSectionsList(sections)
|
||||
// 同步置顶配置(后端为唯一数据源)
|
||||
const pinned = sections.filter((s) => s.isPinned).map((s) => s.id)
|
||||
setPinnedSectionIds(pinned)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setRankedSectionsList([])
|
||||
} finally {
|
||||
setRankingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadList()
|
||||
loadRanking()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -310,6 +340,7 @@ export function ContentPage() {
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
toast.success('已删除')
|
||||
loadList()
|
||||
loadRanking()
|
||||
} else {
|
||||
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
@@ -394,9 +425,25 @@ export function ContentPage() {
|
||||
|
||||
const loadLinkTags = useCallback(async () => {
|
||||
try {
|
||||
const data = await get<{ success?: boolean; linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] }>('/api/db/link-tags')
|
||||
if (data?.success && data.linkTags) setLinkTags(data.linkTags.map(t => ({ id: t.tagId, label: t.label, url: t.url, type: t.type as 'url' | 'miniprogram', appId: t.appId, pagePath: t.pagePath })))
|
||||
} catch { /* ignore */ }
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
|
||||
}>('/api/db/link-tags')
|
||||
if (data?.success && data.linkTags) {
|
||||
setLinkTags(
|
||||
data.linkTags.map((t) => ({
|
||||
id: t.tagId,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
|
||||
appId: t.appId || '',
|
||||
pagePath: t.pagePath || '',
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTogglePin = async (sectionId: string) => {
|
||||
@@ -410,6 +457,7 @@ export function ContentPage() {
|
||||
value: next,
|
||||
description: '强制置顶章节ID列表(精选推荐/首页最新更新)',
|
||||
})
|
||||
loadRanking() // 置顶配置变更后重新拉取排行榜(后端统一计算排序)
|
||||
} catch { setPinnedSectionIds(pinnedSectionIds) }
|
||||
}
|
||||
|
||||
@@ -1984,13 +2032,23 @@ export function ContentPage() {
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-amber-400" />
|
||||
内容排行榜
|
||||
<span className="text-xs text-gray-500 font-normal ml-2">按热度排行 · 共 {rankedSections.length} 节</span>
|
||||
<span className="text-xs text-gray-500 font-normal ml-2">按热度排行 · 共 {rankedSectionsList.length} 节</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage <= 1}
|
||||
onClick={() => loadRanking()}
|
||||
disabled={rankingLoading}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
title="刷新排行榜"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${rankingLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage <= 1 || rankingLoading}
|
||||
onClick={() => setRankingPage((p) => Math.max(1, p - 1))}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
>
|
||||
@@ -2000,7 +2058,7 @@ export function ContentPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage >= rankingTotalPages}
|
||||
disabled={rankingPage >= rankingTotalPages || rankingLoading}
|
||||
onClick={() => setRankingPage((p) => Math.min(rankingTotalPages, p + 1))}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
>
|
||||
@@ -2023,7 +2081,7 @@ export function ContentPage() {
|
||||
</div>
|
||||
{rankingPageSections.map((s, idx) => {
|
||||
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
|
||||
const isPinned = pinnedSectionIds.includes(s.id)
|
||||
const isPinned = s.isPinned ?? pinnedSectionIds.includes(s.id)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
@@ -2217,35 +2275,102 @@ export function ContentPage() {
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
|
||||
const payload = { ...newLinkTag }
|
||||
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
|
||||
await post('/api/db/link-tags', payload)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
loadLinkTags()
|
||||
}}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
|
||||
onClick={async () => {
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) {
|
||||
toast.error('标签ID和显示文字必填')
|
||||
return
|
||||
}
|
||||
const payload = { ...newLinkTag }
|
||||
await post('/api/db/link-tags', payload)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
setEditingLinkTagId(null)
|
||||
loadLinkTags()
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{editingLinkTagId ? '保存' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{linkTags.map(t => (
|
||||
{linkTags.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-amber-400 font-bold text-base">#{t.label}</span>
|
||||
<Badge variant="secondary" className={`text-[10px] ${t.type === 'ckb' ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-gray-700 text-gray-300'}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-amber-400 font-bold text-base hover:underline"
|
||||
onClick={() => {
|
||||
setNewLinkTag({
|
||||
tagId: t.id,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: t.type,
|
||||
appId: t.appId,
|
||||
pagePath: t.pagePath,
|
||||
})
|
||||
setEditingLinkTagId(t.id)
|
||||
}}
|
||||
>
|
||||
#{t.label}
|
||||
</button>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${
|
||||
t.type === 'ckb'
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
|
||||
</Badge>
|
||||
<a href={t.url} target="_blank" rel="noreferrer" className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1">
|
||||
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
{t.url && (
|
||||
<a
|
||||
href={t.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1"
|
||||
>
|
||||
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-300 hover:text-white h-6 px-2"
|
||||
onClick={() => {
|
||||
setNewLinkTag({
|
||||
tagId: t.id,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: t.type,
|
||||
appId: t.appId,
|
||||
pagePath: t.pagePath,
|
||||
})
|
||||
setEditingLinkTagId(t.id)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 h-6 px-2"
|
||||
onClick={async () => {
|
||||
await del(`/api/db/link-tags?tagId=${t.id}`)
|
||||
if (editingLinkTagId === t.id) {
|
||||
setEditingLinkTagId(null)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
}
|
||||
loadLinkTags()
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||||
await del(`/api/db/link-tags?tagId=${t.id}`)
|
||||
loadLinkTags()
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">暂无链接标签,添加后可在编辑器中使用 #标签 跳转</div>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crown,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
@@ -81,11 +82,12 @@ interface UserRule {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface VipRole {
|
||||
id: number
|
||||
interface VipMember {
|
||||
id: string
|
||||
name: string
|
||||
sort: number
|
||||
createdAt?: string
|
||||
avatar?: string | null
|
||||
vipRole?: string | null
|
||||
vipSort?: number | null
|
||||
}
|
||||
|
||||
// 用户旅程阶段定义
|
||||
@@ -144,12 +146,11 @@ export function UsersPage() {
|
||||
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
|
||||
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
|
||||
|
||||
// ===== VIP 角色 =====
|
||||
const [vipRoles, setVipRoles] = useState<VipRole[]>([])
|
||||
const [vipRolesLoading, setVipRolesLoading] = useState(false)
|
||||
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
|
||||
const [editingVipRole, setEditingVipRole] = useState<VipRole | null>(null)
|
||||
const [vipRoleForm, setVipRoleForm] = useState({ name: '', sort: 0 })
|
||||
// ===== 超级个体(VIP 用户列表) =====
|
||||
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
|
||||
const [vipMembersLoading, setVipMembersLoading] = useState(false)
|
||||
const [draggingVipId, setDraggingVipId] = useState<string | null>(null)
|
||||
const [dragOverVipId, setDragOverVipId] = useState<string | null>(null)
|
||||
|
||||
// ===== 用户旅程总览 =====
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
@@ -313,36 +314,169 @@ export function UsersPage() {
|
||||
try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { }
|
||||
}
|
||||
|
||||
// ===== VIP 角色 =====
|
||||
const loadVipRoles = useCallback(async () => {
|
||||
setVipRolesLoading(true)
|
||||
// ===== 超级个体(VIP 用户列表) =====
|
||||
const loadVipMembers = useCallback(async () => {
|
||||
setVipMembersLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; roles?: VipRole[] }>('/api/db/vip-roles')
|
||||
if (data?.success) setVipRoles(data.roles || [])
|
||||
} catch { } finally { setVipRolesLoading(false) }
|
||||
const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>(
|
||||
'/api/db/vip-members?limit=500',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
const list = [...data.data].map((m, idx) => ({
|
||||
...m,
|
||||
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
|
||||
}))
|
||||
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
|
||||
setVipMembers(list)
|
||||
} else if (data && data.error) {
|
||||
toast.error(data.error)
|
||||
}
|
||||
} catch {
|
||||
toast.error('加载超级个体列表失败')
|
||||
} finally {
|
||||
setVipMembersLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSaveVipRole() {
|
||||
if (!vipRoleForm.name) { toast.error('请填写角色名称'); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingVipRole) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
|
||||
if (!data?.success) { toast.error('更新失败'); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
|
||||
if (!data?.success) { toast.error('创建失败'); return }
|
||||
}
|
||||
setShowVipRoleModal(false); loadVipRoles()
|
||||
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
|
||||
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
|
||||
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
|
||||
const [vipRoleInput, setVipRoleInput] = useState('')
|
||||
const [vipRoleSaving, setVipRoleSaving] = useState(false)
|
||||
|
||||
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
|
||||
|
||||
const openVipRoleModal = (member: VipMember) => {
|
||||
setVipRoleModalMember(member)
|
||||
setVipRoleInput(member.vipRole || '')
|
||||
setShowVipRoleModal(true)
|
||||
}
|
||||
|
||||
async function handleDeleteVipRole(id: number) {
|
||||
if (!confirm('确定删除?')) return
|
||||
const handleSetVipRole = async (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!vipRoleModalMember) return
|
||||
if (!trimmed) {
|
||||
toast.error('请选择或输入标签')
|
||||
return
|
||||
}
|
||||
setVipRoleSaving(true)
|
||||
try {
|
||||
const data = await del<{ success?: boolean }>(`/api/db/vip-roles?id=${id}`)
|
||||
if (data?.success) loadVipRoles()
|
||||
} catch { }
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: vipRoleModalMember.id,
|
||||
vipRole: trimmed,
|
||||
})
|
||||
if (!res?.success) {
|
||||
toast.error(res?.error || '更新超级个体标签失败')
|
||||
return
|
||||
}
|
||||
toast.success('已更新超级个体标签')
|
||||
setShowVipRoleModal(false)
|
||||
setVipRoleModalMember(null)
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新超级个体标签失败')
|
||||
} finally {
|
||||
setVipRoleSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [showVipSortModal, setShowVipSortModal] = useState(false)
|
||||
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
|
||||
const [vipSortInput, setVipSortInput] = useState('')
|
||||
const [vipSortSaving, setVipSortSaving] = useState(false)
|
||||
|
||||
const openVipSortModal = (member: VipMember) => {
|
||||
setVipSortModalMember(member)
|
||||
setVipSortInput(member.vipSort != null ? String(member.vipSort) : '')
|
||||
setShowVipSortModal(true)
|
||||
}
|
||||
|
||||
const handleSetVipSort = async () => {
|
||||
if (!vipSortModalMember) return
|
||||
const num = Number(vipSortInput)
|
||||
if (!Number.isFinite(num)) {
|
||||
toast.error('请输入有效的数字序号')
|
||||
return
|
||||
}
|
||||
setVipSortSaving(true)
|
||||
try {
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: vipSortModalMember.id,
|
||||
vipSort: num,
|
||||
})
|
||||
if (!res?.success) {
|
||||
toast.error(res?.error || '更新排序序号失败')
|
||||
return
|
||||
}
|
||||
toast.success('已更新排序序号')
|
||||
setShowVipSortModal(false)
|
||||
setVipSortModalMember(null)
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新排序序号失败')
|
||||
} finally {
|
||||
setVipSortSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVipRowDragStart = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', id)
|
||||
setDraggingVipId(id)
|
||||
}
|
||||
|
||||
const handleVipRowDragOver = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
if (dragOverVipId !== id) setDragOverVipId(id)
|
||||
}
|
||||
|
||||
const handleVipRowDragEnd = () => {
|
||||
setDraggingVipId(null)
|
||||
setDragOverVipId(null)
|
||||
}
|
||||
|
||||
const handleVipRowDrop = async (e: React.DragEvent<HTMLTableRowElement>, targetId: string) => {
|
||||
e.preventDefault()
|
||||
const fromId = e.dataTransfer.getData('text/plain') || draggingVipId
|
||||
setDraggingVipId(null)
|
||||
setDragOverVipId(null)
|
||||
if (!fromId || fromId === targetId) return
|
||||
|
||||
const fromMember = vipMembers.find((m) => m.id === fromId)
|
||||
const targetMember = vipMembers.find((m) => m.id === targetId)
|
||||
if (!fromMember || !targetMember) return
|
||||
|
||||
const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1
|
||||
const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1
|
||||
|
||||
// 本地先交换顺序,提升交互流畅度
|
||||
setVipMembers((prev) => {
|
||||
const list = [...prev]
|
||||
const fromIdx = list.findIndex((m) => m.id === fromId)
|
||||
const toIdx = list.findIndex((m) => m.id === targetId)
|
||||
if (fromIdx === -1 || toIdx === -1) return prev
|
||||
const next = [...list]
|
||||
const [m1, m2] = [next[fromIdx], next[toIdx]]
|
||||
next[fromIdx] = { ...m2, vipSort: fromSort }
|
||||
next[toIdx] = { ...m1, vipSort: targetSort }
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const [res1, res2] = await Promise.all([
|
||||
put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }),
|
||||
put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }),
|
||||
])
|
||||
if (!res1?.success || !res2?.success) {
|
||||
toast.error(res1?.error || res2?.error || '更新排序失败')
|
||||
await loadVipMembers()
|
||||
return
|
||||
}
|
||||
toast.success('已更新排序')
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新排序失败')
|
||||
await loadVipMembers()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 用户旅程总览 =====
|
||||
@@ -381,8 +515,8 @@ export function UsersPage() {
|
||||
<TabsTrigger value="rules" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadRules}>
|
||||
<Settings className="w-4 h-4" /> 规则配置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipRoles}>
|
||||
<Crown className="w-4 h-4" /> VIP 角色
|
||||
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipMembers}>
|
||||
<Crown className="w-4 h-4" /> 超级个体列表
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -707,46 +841,136 @@ export function UsersPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== VIP 角色 ===== */}
|
||||
{/* ===== 超级个体列表(VIP 用户) ===== */}
|
||||
<TabsContent value="vip-roles">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-gray-400 text-sm">管理用户 VIP 角色分类,这些角色将在用户详情和会员展示中使用</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-gray-400 text-sm">
|
||||
展示当前所有有效的超级个体(VIP 用户),用于检查会员信息与排序值。
|
||||
</p>
|
||||
<p className="text-xs text-[#38bdac]">
|
||||
提示:按住任意一行即可拖拽排序,释放后将同步更新小程序展示顺序。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadVipRoles} disabled={vipRolesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${vipRolesLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Plus className="w-4 h-4 mr-2" /> 添加角色
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadVipMembers}
|
||||
disabled={vipMembersLoading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${vipMembersLoading ? 'animate-spin' : ''}`}
|
||||
/>{' '}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vipRolesLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
|
||||
) : vipRoles.length === 0 ? (
|
||||
{vipMembersLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : vipMembers.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50">
|
||||
<Crown className="w-12 h-12 text-amber-400/30 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-4">暂无 VIP 角色</p>
|
||||
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Plus className="w-4 h-4 mr-2" /> 添加第一个角色</Button>
|
||||
<p className="text-gray-400 mb-4">当前没有有效的超级个体用户。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{vipRoles.map((role) => (
|
||||
<div key={role.id} className="p-4 bg-[#0f2137] border border-amber-500/20 rounded-xl hover:border-amber-500/40 transition-all group">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-white font-medium">{role.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => { setEditingVipRole(role); setVipRoleForm({ name: role.name, sort: role.sort }); setShowVipRoleModal(true) }} className="text-gray-500 hover:text-[#38bdac]"><Edit3 className="w-3.5 h-3.5" /></button>
|
||||
<button type="button" onClick={() => handleDeleteVipRole(role.id)} className="text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-xs">排序: {role.sort}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400 w-16">序号</TableHead>
|
||||
<TableHead className="text-gray-400">成员</TableHead>
|
||||
<TableHead className="text-gray-400 w-40">超级个体标签</TableHead>
|
||||
<TableHead className="text-gray-400 w-24">排序值</TableHead>
|
||||
<TableHead className="text-gray-400 w-40 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vipMembers.map((m, index) => {
|
||||
const isDragging = draggingVipId === m.id
|
||||
const isOver = dragOverVipId === m.id
|
||||
return (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleVipRowDragStart(e, m.id)}
|
||||
onDragOver={(e) => handleVipRowDragOver(e, m.id)}
|
||||
onDrop={(e) => handleVipRowDrop(e, m.id)}
|
||||
onDragEnd={handleVipRowDragEnd}
|
||||
className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${
|
||||
isDragging ? 'opacity-60' : ''
|
||||
} ${isOver ? 'bg-[#38bdac]/10' : ''}`}
|
||||
>
|
||||
<TableCell className="text-gray-300">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.avatar ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img
|
||||
src={m.avatar}
|
||||
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
|
||||
{m.name?.[0] || '创'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm truncate">{m.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipRole || <span className="text-gray-500">(未设置超级个体标签)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipSort ?? index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-gray-300">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-amber-300 hover:text-amber-200"
|
||||
onClick={() => openVipRoleModal(m)}
|
||||
title="设置超级个体标签"
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
|
||||
onClick={() => {
|
||||
setSelectedUserIdForDetail(m.id)
|
||||
setShowDetailModal(true)
|
||||
}}
|
||||
title="编辑资料"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-sky-300 hover:text-sky-200"
|
||||
onClick={() => openVipSortModal(m)}
|
||||
title="设置排序序号"
|
||||
>
|
||||
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -754,6 +978,81 @@ export function UsersPage() {
|
||||
{/* ===== 弹框组件 ===== */}
|
||||
|
||||
{/* 添加/编辑用户 */}
|
||||
{/* 设置排序 */}
|
||||
<Dialog open={showVipSortModal} onOpenChange={(open) => { setShowVipSortModal(open); if (!open) setVipSortModalMember(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<ArrowUpDown className="w-5 h-5 text-[#38bdac]" />
|
||||
设置排序 — {vipSortModalMember?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Label className="text-gray-300 text-sm">排序序号(数字越小越靠前)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如:1"
|
||||
value={vipSortInput}
|
||||
onChange={(e) => setVipSortInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipSortModal(false)} 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={handleSetVipSort} disabled={vipSortSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />{vipSortSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 设置超级个体标签 */}
|
||||
<Dialog open={showVipRoleModal} onOpenChange={(open) => { setShowVipRoleModal(open); if (!open) setVipRoleModalMember(null) }}>
|
||||
<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" />
|
||||
设置超级个体标签 — {vipRoleModalMember?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Label className="text-gray-300 text-sm">选择或输入标签</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIP_ROLE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant={vipRoleInput === preset ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={vipRoleInput === preset ? 'bg-[#38bdac] hover:bg-[#2da396] text-white' : 'border-gray-600 text-gray-300 hover:bg-gray-700/50'}
|
||||
onClick={() => setVipRoleInput(preset)}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-400 text-xs">或手动输入</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如:创业者、资源整合者等"
|
||||
value={vipRoleInput}
|
||||
onChange={(e) => setVipRoleInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} 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={() => handleSetVipRole(vipRoleInput)} disabled={vipRoleSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />{vipRoleSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
|
||||
@@ -788,21 +1087,6 @@ export function UsersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加/编辑 VIP 角色 */}
|
||||
<Dialog open={showVipRoleModal} onOpenChange={setShowVipRoleModal}>
|
||||
<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" />{editingVipRole ? '编辑 VIP 角色' : '添加 VIP 角色'}</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={vipRoleForm.name} onChange={(e) => setVipRoleForm({ ...vipRoleForm, name: 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={vipRoleForm.sort} onChange={(e) => setVipRoleForm({ ...vipRoleForm, sort: parseInt(e.target.value) || 0 })} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} 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={handleSaveVipRole} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" />{isSaving ? '保存中...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 绑定关系 */}
|
||||
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import toast from '@/utils/toast'
|
||||
import toast from '@/utils/toast'
|
||||
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,
|
||||
@@ -12,130 +9,59 @@ import {
|
||||
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'
|
||||
import { Crown } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface VipRole {
|
||||
id: number
|
||||
interface VipMember {
|
||||
id: string
|
||||
name: string
|
||||
sort: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
avatar?: string
|
||||
vipRole?: string
|
||||
vipSort?: number
|
||||
}
|
||||
|
||||
export function VipRolesPage() {
|
||||
const [roles, setRoles] = useState<VipRole[]>([])
|
||||
const [members, setMembers] = useState<VipMember[]>([])
|
||||
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() {
|
||||
async function loadMembers() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles')
|
||||
if (data?.success && data.data) setRoles(data.data)
|
||||
const data = await get<{ success?: boolean; data?: VipMember[] }>(
|
||||
'/api/db/vip-members?limit=100',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
const list = [...data.data].map((m, idx) => ({
|
||||
...m,
|
||||
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
|
||||
}))
|
||||
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
|
||||
setMembers(list)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load roles error:', e)
|
||||
console.error('Load VIP members error:', e)
|
||||
toast.error('加载 VIP 成员失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
loadMembers()
|
||||
}, [])
|
||||
|
||||
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()) {
|
||||
toast.error('角色名称不能为空')
|
||||
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 {
|
||||
toast.error('更新失败: ' + (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 {
|
||||
toast.error('新增失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save error:', e)
|
||||
toast.error('保存失败')
|
||||
} 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 toast.error('删除失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Delete error:', e)
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<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">
|
||||
@@ -146,42 +72,44 @@ export function VipRolesPage() {
|
||||
<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>
|
||||
<TableHead className="text-gray-400 w-20">序号</TableHead>
|
||||
<TableHead className="text-gray-400">成员</TableHead>
|
||||
<TableHead className="text-gray-400 w-40">超级个体</TableHead>
|
||||
<TableHead className="text-gray-400 w-28">排序值</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>
|
||||
{members.map((m, index) => (
|
||||
<TableRow key={m.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.avatar ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img
|
||||
src={m.avatar}
|
||||
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
|
||||
{m.name?.[0] || '创'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm truncate">{m.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipRole || <span className="text-gray-500">(未设置超级个体)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{m.vipSort ?? index + 1}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{roles.length === 0 && (
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12 text-gray-500">
|
||||
暂无角色,点击「新增角色」添加
|
||||
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
||||
当前没有有效的超级个体用户。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -190,54 +118,7 @@ export function VipRolesPage() {
|
||||
)}
|
||||
</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/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"errors":true,"version":"5.6.3"}
|
||||
@@ -18,6 +18,9 @@ SKIP_AUTO_MIGRATE=1
|
||||
# 统一 API 域名(测试环境)
|
||||
API_BASE_URL=https://souldev.quwanzhi.com
|
||||
|
||||
#添加卡若
|
||||
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
|
||||
@@ -14,6 +14,9 @@ DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/sou
|
||||
# 统一 API 域名(支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
|
||||
API_BASE_URL=https://soulapi.quwanzhi.com
|
||||
|
||||
#添加卡若
|
||||
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
|
||||
@@ -382,9 +382,42 @@ func BookHot(c *gin.Context) {
|
||||
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
|
||||
func BookRecommended(c *gin.Context) {
|
||||
// 优先复用管理端内容排行榜的算法,保证与后台「内容排行榜」顺序一致
|
||||
sections, err := computeArticleRankingSections(database.DB())
|
||||
if err == nil && len(sections) > 0 {
|
||||
// 前 3 名作为精选推荐
|
||||
limit := 3
|
||||
if len(sections) < limit {
|
||||
limit = len(sections)
|
||||
}
|
||||
tags := []string{"热门", "推荐", "精选"}
|
||||
out := make([]gin.H, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
s := sections[i]
|
||||
tag := "精选"
|
||||
if i < len(tags) {
|
||||
tag = tags[i]
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": s.ID,
|
||||
"mid": s.MID,
|
||||
"sectionTitle": s.Title,
|
||||
"partTitle": s.PartTitle,
|
||||
"chapterTitle": s.ChapterTitle,
|
||||
"tag": tag,
|
||||
"isFree": s.IsFree,
|
||||
"price": s.Price,
|
||||
"isNew": s.IsNew,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
return
|
||||
}
|
||||
|
||||
// 兜底:沿用原有热门章节算法,至少保证有推荐
|
||||
list := bookHotChaptersSorted(database.DB(), 3)
|
||||
if len(list) == 0 {
|
||||
// 兜底:按 updated_at 取前 3,同样排除序言/尾声/附录
|
||||
// 第二层兜底:按 updated_at 取前 3,同样排除序言/尾声/附录
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
|
||||
@@ -290,6 +290,134 @@ func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
|
||||
// - 固定使用全局 CKB_LEAD_API_KEY,不受文章 @ 人物的 ckb_api_key 影响
|
||||
// - 请求体:userId(可选,用于补全昵称)、phone/wechatId(至少一个)、name(可选)
|
||||
func CKBIndexLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
wechatId := strings.TrimSpace(body.WechatID)
|
||||
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
|
||||
if phone == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
db := database.DB()
|
||||
if name == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "小程序用户"
|
||||
}
|
||||
|
||||
// 首页固定使用全局密钥:CKB_LEAD_API_KEY(.env)或代码内置 ckbAPIKey
|
||||
leadKey := ckbAPIKey
|
||||
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
|
||||
leadKey = cfg.CkbLeadAPIKey
|
||||
}
|
||||
|
||||
// 去重限频:2 分钟内同一用户/手机/微信只能提交一次
|
||||
var cond []string
|
||||
var args []interface{}
|
||||
if body.UserID != "" {
|
||||
cond = append(cond, "user_id = ?")
|
||||
args = append(args, body.UserID)
|
||||
}
|
||||
cond = append(cond, "phone = ?")
|
||||
args = append(args, phone)
|
||||
cutoff := time.Now().Add(-2 * time.Minute)
|
||||
var recentCount int64
|
||||
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁,请2分钟后再试"})
|
||||
return
|
||||
}
|
||||
repeatedSubmit := false
|
||||
var existCount int64
|
||||
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
|
||||
|
||||
source := "index_link_button"
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
"apiKey": leadKey,
|
||||
}
|
||||
params["phone"] = phone
|
||||
params["sign"] = ckbSign(params, leadKey)
|
||||
q := url.Values{}
|
||||
q.Set("name", name)
|
||||
q.Set("timestamp", strconv.FormatInt(ts, 10))
|
||||
q.Set("apiKey", leadKey)
|
||||
q.Set("phone", phone)
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if repeatedSubmit {
|
||||
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
if m, ok := result.Data.(map[string]interface{}); ok {
|
||||
data = m
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
// 存客宝返回失败,透传其错误信息与 code,便于前端/运营判断原因
|
||||
errMsg := strings.TrimSpace(result.Message)
|
||||
if errMsg == "" {
|
||||
errMsg = "提交失败,请稍后重试"
|
||||
}
|
||||
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errMsg,
|
||||
"ckbCode": result.Code,
|
||||
"ckbMessage": result.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention)
|
||||
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(被@的 personId)、targetNickname、source
|
||||
func CKBLead(c *gin.Context) {
|
||||
|
||||
@@ -2,7 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -13,13 +17,15 @@ import (
|
||||
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||
var listSelectCols = []string{
|
||||
"id", "section_title", "price", "is_free", "is_new",
|
||||
"id", "mid", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
"hot_score", "updated_at",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
@@ -29,6 +35,166 @@ type sectionListItem struct {
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
ClickCount int64 `json:"clickCount"` // 阅读次数(reading_progress)
|
||||
PayCount int64 `json:"payCount"` // 付款笔数(orders.product_type=section)
|
||||
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
|
||||
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
|
||||
}
|
||||
|
||||
// computeSectionListWithHotScore 计算章节列表(含 hotScore),保持 sort_order 顺序,供 章节管理 树使用
|
||||
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
|
||||
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
|
||||
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 读取置顶配置 pinned_section_ids
|
||||
pinnedIDs := []string{}
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
pinnedSet := make(map[string]int) // id -> 置顶顺序
|
||||
for i, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = i
|
||||
}
|
||||
}
|
||||
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
|
||||
sort.Slice(sections, func(i, j int) bool {
|
||||
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
|
||||
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
|
||||
if piOk && !pjOk {
|
||||
return true
|
||||
}
|
||||
if !piOk && pjOk {
|
||||
return false
|
||||
}
|
||||
if piOk && pjOk {
|
||||
return pi < pj
|
||||
}
|
||||
return sections[i].HotScore > sections[j].HotScore
|
||||
})
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeSectionsWithHotScore 内部:计算 hotScore,可选设置 isPinned
|
||||
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
readCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var rp []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&rp)
|
||||
for _, r := range rp {
|
||||
readCountMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
}
|
||||
payCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var op []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
|
||||
Group("product_id").Scan(&op)
|
||||
for _, r := range op {
|
||||
payCountMap[r.ProductID] = r.Cnt
|
||||
}
|
||||
}
|
||||
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
var v struct {
|
||||
ReadWeight float64 `json:"readWeight"`
|
||||
RecencyWeight float64 `json:"recencyWeight"`
|
||||
PayWeight float64 `json:"payWeight"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
|
||||
if v.ReadWeight > 0 {
|
||||
readWeight = v.ReadWeight
|
||||
}
|
||||
if v.PayWeight > 0 {
|
||||
payWeight = v.PayWeight
|
||||
}
|
||||
if v.RecencyWeight > 0 {
|
||||
recencyWeight = v.RecencyWeight
|
||||
}
|
||||
}
|
||||
}
|
||||
pinnedIDs := []string{}
|
||||
if setPinned {
|
||||
var cfg2 model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
}
|
||||
pinnedSet := make(map[string]bool)
|
||||
for _, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = true
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
readCnt := readCountMap[r.ID]
|
||||
payCnt := payCountMap[r.ID]
|
||||
recencyScore := 0.0
|
||||
if !r.UpdatedAt.IsZero() {
|
||||
days := now.Sub(r.UpdatedAt).Hours() / 24
|
||||
recencyScore = math.Max(0, (30-days)/30)
|
||||
if recencyScore > 1 {
|
||||
recencyScore = 1
|
||||
}
|
||||
}
|
||||
hot := float64(readCnt)*readWeight + float64(payCnt)*payWeight + recencyScore*recencyWeight
|
||||
item := sectionListItem{
|
||||
ID: r.ID,
|
||||
MID: r.MID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readCnt,
|
||||
PayCount: payCnt,
|
||||
HotScore: hot,
|
||||
}
|
||||
if setPinned {
|
||||
item.IsPinned = pinnedSet[r.ID]
|
||||
}
|
||||
sections = append(sections, item)
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
@@ -40,28 +206,20 @@ func DBBookAction(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
switch action {
|
||||
case "list":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
// 章节管理树:按 sort_order 顺序,含 hotScore
|
||||
sections, err := computeSectionListWithHotScore(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "ranking":
|
||||
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
|
||||
sections, err := computeArticleRankingSections(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
|
||||
@@ -312,6 +312,10 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if u.VipRole != nil {
|
||||
vipRole = *u.VipRole
|
||||
}
|
||||
vipSort := 0
|
||||
if u.VipSort != nil {
|
||||
vipSort = *u.VipSort
|
||||
}
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
@@ -343,12 +347,14 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
"story_achievement": getStringValue(u.StoryAchievement),
|
||||
"storyTurning": getStringValue(u.StoryTurning),
|
||||
"story_turning": getStringValue(u.StoryTurning),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"help_offer": getStringValue(u.HelpOffer),
|
||||
"helpNeed": getStringValue(u.HelpNeed),
|
||||
"help_need": getStringValue(u.HelpNeed),
|
||||
"projectIntro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"vipSort": vipSort,
|
||||
"vip_sort": vipSort,
|
||||
"is_vip": isVip,
|
||||
}
|
||||
}
|
||||
|
||||
54
soul-api/internal/handler/vip_members_admin.go
Normal file
54
soul-api/internal/handler/vip_members_admin.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
|
||||
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
|
||||
func DBVipMembersList(c *gin.Context) {
|
||||
limit := 200
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 与 VipMembers 一致:优先 users 表(is_vip=1 且 vip_expire_date>NOW),排序使用 vip_sort
|
||||
var users []model.User
|
||||
err := db.Table("users").
|
||||
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
|
||||
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
||||
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
||||
Limit(limit).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil || len(users) == 0 {
|
||||
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
|
||||
var userIDs []string
|
||||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||||
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
||||
Pluck("user_id", &userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
db.Where("id IN ?", userIDs).Find(&users)
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
list = append(list, formatVipMember(&users[i], true))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.POST("/vip-roles", handler.DBVipRolesAction)
|
||||
db.PUT("/vip-roles", handler.DBVipRolesAction)
|
||||
db.DELETE("/vip-roles", handler.DBVipRolesAction)
|
||||
db.GET("/vip-members", handler.DBVipMembersList)
|
||||
db.GET("/match-records", handler.DBMatchRecordsList)
|
||||
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
|
||||
db.GET("/mentors", handler.DBMentorsList)
|
||||
@@ -267,6 +268,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
miniprogram.POST("/ckb/match", handler.CKBMatch)
|
||||
miniprogram.POST("/ckb/lead", handler.CKBLead)
|
||||
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
miniprogram.DELETE("/upload", handler.UploadDelete)
|
||||
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
|
||||
|
||||
Reference in New Issue
Block a user