fix: 头像昵称页与规则引擎;用户页与 user/user_rule API;更新 admin dist
Made-with: Cursor
This commit is contained in:
@@ -12,11 +12,29 @@ Page({
|
||||
saving: false,
|
||||
showPrivacyModal: false,
|
||||
nicknameInputFocus: false,
|
||||
/** 规则引擎传入:avatar | nickname,用于高亮对应区块 */
|
||||
uiFocus: '',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
const focus = String(options.focus || '').toLowerCase()
|
||||
if (focus === 'avatar' || focus === 'nickname') {
|
||||
this.setData({ uiFocus: focus })
|
||||
}
|
||||
this.loadFromUser()
|
||||
if (focus === 'nickname') {
|
||||
setTimeout(() => {
|
||||
if (typeof wx.requirePrivacyAuthorize === 'function') {
|
||||
wx.requirePrivacyAuthorize({
|
||||
success: () => this.setData({ nicknameInputFocus: true }),
|
||||
fail: () => {},
|
||||
})
|
||||
} else {
|
||||
this.setData({ nicknameInputFocus: true })
|
||||
}
|
||||
}, 400)
|
||||
}
|
||||
},
|
||||
|
||||
loadFromUser() {
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
<view class="guide-card">
|
||||
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
|
||||
<text class="guide-title">设置对外展示信息</text>
|
||||
<text class="guide-desc">头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
|
||||
<text class="guide-desc" wx:if="{{uiFocus === 'avatar'}}">请先换一张清晰头像,伙伴更容易认出你。</text>
|
||||
<text class="guide-desc" wx:elif="{{uiFocus === 'nickname'}}">请改一个真实好记的昵称,方便伙伴称呼你。</text>
|
||||
<text class="guide-desc" wx:else>头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
@@ -28,7 +30,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
|
||||
<view class="form-section">
|
||||
<view class="form-section {{uiFocus === 'nickname' ? 'section-focus' : ''}}">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
|
||||
<input
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
gap: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.avatar-section.section-focus .avatar-wrap {
|
||||
box-shadow: 0 0 0 6rpx rgba(94, 234, 212, 0.45), 0 0 36rpx rgba(94, 234, 212, 0.45);
|
||||
}
|
||||
.form-section.section-focus .form-input-wrap {
|
||||
border-color: rgba(94, 234, 212, 0.55);
|
||||
box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25);
|
||||
}
|
||||
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
|
||||
.avatar-wrap-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* 稳定版兼容:readCount 用 getReadCount(),hasPurchasedFull 用 hasFullBook,完善头像跳 avatar-nickname
|
||||
*
|
||||
* trigger → scene 映射:
|
||||
* 注册 → after_login
|
||||
* 注册 → after_login(头像或昵称未完善)
|
||||
* update_avatar / 完善头像 → 仅头像未完善
|
||||
* update_nickname / 修改昵称 → 仅昵称为默认
|
||||
* 点击收费章节 → before_read
|
||||
* 完成匹配 → after_match
|
||||
* 完成付款 → after_pay
|
||||
@@ -34,6 +36,8 @@ const CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
const TRIGGER_SCENE_MAP = {
|
||||
'注册': 'after_login',
|
||||
'完善头像': 'after_login',
|
||||
'修改昵称': 'after_login',
|
||||
'点击收费章节': 'before_read',
|
||||
'完成匹配': 'after_match',
|
||||
'完成付款': 'after_pay',
|
||||
@@ -46,6 +50,10 @@ const TRIGGER_SCENE_MAP = {
|
||||
'浏览导师页': 'browse_mentor',
|
||||
}
|
||||
|
||||
// 与后台「规则类型 trigger」一致:支持英文 key 与同义中文(逻辑在 isRuleEnabled 定义之后)
|
||||
const TRIGGER_KEYS_AVATAR = ['update_avatar', '完善头像']
|
||||
const TRIGGER_KEYS_NICKNAME = ['update_nickname', '修改昵称']
|
||||
|
||||
function isInCooldown(ruleId) {
|
||||
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false
|
||||
try {
|
||||
@@ -132,6 +140,29 @@ function getRuleInfo(rules, triggerName) {
|
||||
return rules.find(r => r.trigger === triggerName && !r.completed)
|
||||
}
|
||||
|
||||
function isAnyTriggerEnabled(rules, keys) {
|
||||
return keys.some((k) => isRuleEnabled(rules, k))
|
||||
}
|
||||
|
||||
function getFirstRuleInfo(rules, keys) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const info = getRuleInfo(rules, keys[i])
|
||||
if (info) return info
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isAvatarMissingOrDefault(user) {
|
||||
user = user || getUserInfo()
|
||||
const avatar = user.avatar || user.avatarUrl || ''
|
||||
return !avatar || avatar.includes('default')
|
||||
}
|
||||
|
||||
function isNicknamePlaceholder(nickname) {
|
||||
const n = trimStr(nickname)
|
||||
return !n || n === '微信用户' || n.startsWith('微信用户')
|
||||
}
|
||||
|
||||
function markRuleCompleted(ruleId) {
|
||||
const userId = getUserInfo().id
|
||||
if (!userId || !ruleId) return
|
||||
@@ -147,7 +178,54 @@ function markRuleCompleted(ruleId) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 稳定版:跳转 avatar-nickname(专注头像+昵称,首次登录由 app.login 强制 redirect)
|
||||
// 仅头像:trigger = update_avatar 或 完善头像
|
||||
function checkRule_UpdateAvatar(rules) {
|
||||
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_AVATAR)) return null
|
||||
const app = getAppInstance()
|
||||
if (app && app.globalData.isVip) return null
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (!isAvatarMissingOrDefault(user)) return null
|
||||
if (isInCooldown('update_avatar')) return null
|
||||
setCooldown('update_avatar')
|
||||
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_AVATAR)
|
||||
return {
|
||||
ruleId: 'update_avatar',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '上传头像',
|
||||
message: info?.description || '换一张清晰头像,伙伴在名片和匹配里更容易认出你。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/avatar-nickname/avatar-nickname?focus=avatar'
|
||||
}
|
||||
}
|
||||
|
||||
// 仅昵称:trigger = update_nickname 或 修改昵称
|
||||
function checkRule_UpdateNickname(rules) {
|
||||
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_NICKNAME)) return null
|
||||
const app = getAppInstance()
|
||||
if (app && app.globalData.isVip) return null
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
const nickname = user.nickname || user.nickName || ''
|
||||
if (!isNicknamePlaceholder(nickname)) return null
|
||||
if (isInCooldown('update_nickname')) return null
|
||||
setCooldown('update_nickname')
|
||||
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_NICKNAME)
|
||||
return {
|
||||
ruleId: 'update_nickname',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '修改昵称',
|
||||
message: info?.description || '改一个真实好记的昵称,方便伙伴称呼你。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/avatar-nickname/avatar-nickname?focus=nickname'
|
||||
}
|
||||
}
|
||||
|
||||
// 稳定版:trigger=注册 时头像或昵称任一未完善则引导(与上面两项拆分配置并存)
|
||||
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 跳转 profile-edit,避免与主流程冲突
|
||||
function checkRule_FillAvatar(rules) {
|
||||
if (!isRuleEnabled(rules, '注册')) return null
|
||||
@@ -155,12 +233,15 @@ function checkRule_FillAvatar(rules) {
|
||||
if (app && app.globalData.isVip) return null
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
const avatar = user.avatar || user.avatarUrl || ''
|
||||
const nickname = user.nickname || ''
|
||||
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return null
|
||||
const nickname = user.nickname || user.nickName || ''
|
||||
if (!isAvatarMissingOrDefault(user) && !isNicknamePlaceholder(nickname)) return null
|
||||
if (isInCooldown('fill_avatar')) return null
|
||||
setCooldown('fill_avatar')
|
||||
const info = getRuleInfo(rules, '注册')
|
||||
const needNick = isNicknamePlaceholder(nickname)
|
||||
const needAv = isAvatarMissingOrDefault(user)
|
||||
const focus = needAv && !needNick ? 'avatar' : needNick && !needAv ? 'nickname' : ''
|
||||
const qs = focus ? `?focus=${focus}` : ''
|
||||
return {
|
||||
ruleId: 'fill_avatar',
|
||||
serverRuleId: info?.id,
|
||||
@@ -169,10 +250,15 @@ function checkRule_FillAvatar(rules) {
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/avatar-nickname/avatar-nickname'
|
||||
target: '/pages/avatar-nickname/avatar-nickname' + qs
|
||||
}
|
||||
}
|
||||
|
||||
/** 头像/昵称类引导:优先拆条规则(完善头像、修改昵称),其次合并规则(注册) */
|
||||
function checkAvatarNicknameGuides(rules) {
|
||||
return checkRule_UpdateAvatar(rules) || checkRule_UpdateNickname(rules) || checkRule_FillAvatar(rules)
|
||||
}
|
||||
|
||||
function checkRule_BindPhone(rules) {
|
||||
if (!isRuleEnabled(rules, '点击收费章节')) return null
|
||||
const user = getUserInfo()
|
||||
@@ -332,17 +418,17 @@ function checkRulesSync(scene, rules) {
|
||||
|
||||
switch (scene) {
|
||||
case 'after_login':
|
||||
return checkRule_FillAvatar(rules)
|
||||
return checkAvatarNicknameGuides(rules)
|
||||
case 'before_read':
|
||||
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
|
||||
return checkRule_BindPhone(rules) || checkAvatarNicknameGuides(rules)
|
||||
case 'before_pay':
|
||||
return checkRule_FillAvatar(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules)
|
||||
return checkAvatarNicknameGuides(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules)
|
||||
case 'after_match':
|
||||
return null
|
||||
case 'after_pay':
|
||||
return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules)
|
||||
case 'page_show':
|
||||
return checkRule_FillAvatar(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
|
||||
return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
|
||||
case 'before_join_party':
|
||||
return checkRule_JoinParty(rules)
|
||||
default:
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
soul-admin/dist/index.html
vendored
2
soul-admin/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dz0mx7au.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Dk6CvQRe.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -96,7 +96,9 @@ interface UserRule {
|
||||
const TRIGGER_OPTIONS: { value: string; label: string; group: string }[] = [
|
||||
{ value: 'after_login', label: '注册/登录成功', group: '用户状态' },
|
||||
{ value: 'bind_phone', label: '绑定手机号', group: '用户状态' },
|
||||
{ value: 'fill_profile', label: '完善资料(头像/MBTI/行业)', group: '用户状态' },
|
||||
{ value: 'update_avatar', label: '完善头像(非默认图,与昵称分开配置)', group: '用户状态' },
|
||||
{ value: 'update_nickname', label: '修改昵称(非默认微信昵称,与头像分开)', group: '用户状态' },
|
||||
{ value: 'fill_profile', label: '完善资料(MBTI/行业/职位,不含头像昵称)', group: '用户状态' },
|
||||
{ value: 'view_chapter', label: '浏览章节', group: '阅读行为' },
|
||||
{ value: 'browse_5_chapters', label: '累计浏览5个章节', group: '阅读行为' },
|
||||
{ value: 'purchase_section', label: '购买单章', group: '付费行为' },
|
||||
@@ -118,6 +120,30 @@ const ACTION_TYPE_OPTIONS: { value: string; label: string; desc: string }[] = [
|
||||
{ value: 'tag', label: '自动打标签', desc: '触发后自动给用户打上指定标签' },
|
||||
]
|
||||
|
||||
/** 后端曾将 []byte 编成 base64;或历史脏数据。统一为 string[],避免规则列表 .map 白屏 */
|
||||
function normalizeTriggerConditions(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const p = JSON.parse(v) as unknown
|
||||
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
|
||||
} catch {
|
||||
try {
|
||||
const decoded = typeof atob === 'function' ? atob(v) : ''
|
||||
const p = JSON.parse(decoded) as unknown
|
||||
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeUserRule(r: UserRule): UserRule {
|
||||
return { ...r, triggerConditions: normalizeTriggerConditions(r.triggerConditions) }
|
||||
}
|
||||
|
||||
interface VipMember {
|
||||
id: string
|
||||
name: string
|
||||
@@ -492,7 +518,7 @@ export function UsersPage() {
|
||||
setRulesLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules')
|
||||
if (data?.success) setRules(data.rules || [])
|
||||
if (data?.success) setRules((data.rules || []).map((r) => normalizeUserRule(r)))
|
||||
} catch { } finally { setRulesLoading(false) }
|
||||
}, [])
|
||||
|
||||
@@ -1510,7 +1536,9 @@ export function UsersPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
{rules.map((rule) => {
|
||||
const triggerList = normalizeTriggerConditions(rule.triggerConditions)
|
||||
return (
|
||||
<div key={rule.id} className={`p-3 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
@@ -1518,13 +1546,13 @@ export function UsersPage() {
|
||||
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||||
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
|
||||
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
|
||||
{(rule.triggerConditions || []).length > 0 && (
|
||||
{triggerList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 ml-1">
|
||||
{(rule.triggerConditions || []).slice(0, 3).map((tc) => {
|
||||
{triggerList.slice(0, 3).map((tc) => {
|
||||
const opt = TRIGGER_OPTIONS.find((o) => o.value === tc)
|
||||
return <Badge key={tc} className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[9px]">{opt?.label || tc}</Badge>
|
||||
})}
|
||||
{(rule.triggerConditions || []).length > 3 && <span className="text-gray-500 text-[9px]">+{(rule.triggerConditions || []).length - 3}</span>}
|
||||
{triggerList.length > 3 && <span className="text-gray-500 text-[9px]">+{triggerList.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
{rule.actionType && rule.actionType !== 'popup' && (
|
||||
@@ -1535,7 +1563,7 @@ export function UsersPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-3 shrink-0">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: rule.triggerConditions || [], actionType: rule.actionType || 'popup', sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: triggerList, actionType: rule.actionType || 'popup', sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1549,7 +1577,8 @@ export function UsersPage() {
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -1899,7 +1928,7 @@ export function UsersPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">兼容触发标识(旧版)</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white text-xs h-8" placeholder="例:after_login、after_pay(兼容现有小程序 ruleEngine)" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} />
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white text-xs h-8" placeholder="与小程序一致:注册、完成付款、update_avatar、update_nickname 等" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -687,6 +687,10 @@ func userTrackActionLabelCN(action string) string {
|
||||
return "完善资料"
|
||||
case "fill_avatar":
|
||||
return "设置头像"
|
||||
case "update_avatar":
|
||||
return "完善头像"
|
||||
case "update_nickname":
|
||||
return "修改昵称"
|
||||
case "visit_page":
|
||||
return "访问页面"
|
||||
case "first_pay":
|
||||
|
||||
@@ -8,6 +8,29 @@ import (
|
||||
// RuleJSON 存储 JSON 数组/对象的列(user_rules 的 trigger_conditions 等)
|
||||
type RuleJSON []byte
|
||||
|
||||
// MarshalJSON 原样输出 JSON,避免 encoding/json 将 []byte 编成 base64 导致前端把 triggerConditions 当字符串而 .map 崩溃
|
||||
func (r RuleJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(r) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
out := make([]byte, len(r))
|
||||
copy(out, r)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON 接收请求体中的原始 JSON(对象/数组)
|
||||
func (r *RuleJSON) UnmarshalJSON(data []byte) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
*r = nil
|
||||
return nil
|
||||
}
|
||||
*r = append((*r)[0:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RuleJSON) Value() (driver.Value, error) { return []byte(r), nil }
|
||||
func (r *RuleJSON) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
|
||||
Reference in New Issue
Block a user