feat: implement audit mode restrictions across various pages

- Added checks for audit mode in VIP navigation and member detail pages, preventing access and displaying appropriate messages.
- Updated UI elements to conditionally render based on audit mode, enhancing user experience by hiding VIP-related options when in audit mode.
- Introduced loading indicators on the VIP page to manage user expectations during content validation.
- Enhanced error handling for image uploads in the article editor, ensuring a smoother content creation process.

This update aims to improve the overall user experience by enforcing access restrictions and providing clear feedback in audit mode.
This commit is contained in:
乘风
2026-04-24 18:23:34 +08:00
parent f08c82c441
commit 83a420f1b9
16 changed files with 887 additions and 49 deletions

View File

@@ -942,6 +942,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
trackClick('home', 'btn_click', '加入创业派对')
wx.navigateTo({ url: '/pages/vip/vip' })
},

View File

@@ -33,6 +33,10 @@ Page({
if (options.id) this.loadMember(options.id)
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
/** 朋友圈单页等受限环境 */
_isSinglePageMode() {
try {
@@ -288,7 +292,14 @@ Page({
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
success: (res) => {
if (!res.confirm) return
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持购买', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
}
})
return false
},
@@ -600,7 +611,13 @@ Page({
},
goToMatch() { wx.switchTab({ url: '/pages/match/match' }) },
goToVip() { wx.navigateTo({ url: '/pages/vip/vip' }) },
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
},
goToSuperArticleDetail(e) {
const id = e.currentTarget?.dataset?.id
if (!id) return

View File

@@ -225,7 +225,7 @@
<!-- 底部:分享 + 双入口(同一视觉块) -->
<view class="footer-panel">
<view class="footer-pills">
<view class="pill pill-gold" bindtap="goToVip">
<view class="pill pill-gold" wx:if="{{!auditMode}}" bindtap="goToVip">
<icon name="sparkles" size="30" color="#FBBF24" customClass="pill-ic"></icon>
<text class="pill-txt">成为超级个体</text>
</view>

View File

@@ -1093,6 +1093,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const p = String(this._getMyPageUi().vipPath || '').trim()

View File

@@ -20,6 +20,7 @@ Page({
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
if (this.data.profile) this.loadProfile()
},
@@ -100,6 +101,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
},
})

View File

@@ -165,8 +165,8 @@
<view class="scroll-pad"></view>
</scroll-view>
<!-- 底部按钮 -->
<view class="bottom-bar" wx:if="{{!loading}}">
<!-- 底部按钮(审核模式隐藏会员入口,与读页/钱包一致) -->
<view class="bottom-bar" wx:if="{{!loading && !auditMode}}">
<view class="vip-btn" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="32" color="#0f172a"></icon>

View File

@@ -173,6 +173,10 @@ Page({
return
}
if (nav.kind === 'page' && nav.path) {
if (nav.path === '/pages/vip/vip' && app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: nav.path })
return
}

View File

@@ -3,9 +3,18 @@ const app = getApp()
Page({
data: {
statusBarHeight: 44,
// 'input' → 'generating' → 'preview' → 'publishing'
step: 'input',
// 输入阶段
description: '',
images: [], // [{tempPath, url, uploading, error}]
// 预览/编辑阶段
title: '',
content: '',
saving: false,
genError: '',
meNickname: '',
},
@@ -14,10 +23,10 @@ Page({
statusBarHeight: app.globalData.statusBarHeight || 44,
meNickname: String(app.globalData.userInfo?.nickname || '我').trim() || '我',
})
this.ensureSuperIdentity()
this._checkIdentity()
},
async ensureSuperIdentity() {
async _checkIdentity() {
const userId = app.globalData.userInfo?.id
if (!app.globalData.isLoggedIn || !userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
@@ -39,6 +48,83 @@ Page({
}
},
// ─────────────── 图片管理 ───────────────
chooseImages() {
const remain = 3 - this.data.images.length
if (remain <= 0) return
wx.chooseMedia({
count: remain,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const newImgs = res.tempFiles.map((f) => ({
tempPath: f.tempFilePath,
url: '',
uploading: true,
error: false,
}))
const images = [...this.data.images, ...newImgs]
this.setData({ images })
// 并发上传
newImgs.forEach((img, offset) => {
const idx = this.data.images.length - newImgs.length + offset
this._uploadImage(img.tempPath, idx)
})
},
})
},
async _uploadImage(tempPath, idx) {
const baseUrl = app.globalData.baseUrl || ''
const token = wx.getStorageSync('token') || ''
return new Promise((resolve) => {
wx.uploadFile({
url: `${baseUrl}/api/upload`,
filePath: tempPath,
name: 'file',
formData: { folder: 'article-images' },
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
try {
const parsed = JSON.parse(res.data)
if (parsed.success && parsed.url) {
this._updateImage(idx, { url: parsed.url, uploading: false, error: false })
resolve(parsed.url)
return
}
} catch (_) { /* ignore */ }
this._updateImage(idx, { uploading: false, error: true })
resolve('')
},
fail: () => {
this._updateImage(idx, { uploading: false, error: true })
resolve('')
},
})
})
},
_updateImage(idx, patch) {
const images = [...this.data.images]
if (images[idx]) {
images[idx] = { ...images[idx], ...patch }
this.setData({ images })
}
},
removeImage(e) {
const idx = Number(e.currentTarget.dataset.index)
const images = this.data.images.filter((_, i) => i !== idx)
this.setData({ images })
},
// ─────────────── 输入 ───────────────
onDescInput(e) {
this.setData({ description: e.detail.value || '' })
},
onTitleInput(e) {
this.setData({ title: e.detail.value || '' })
},
@@ -47,25 +133,63 @@ Page({
this.setData({ content: e.detail.value || '' })
},
insertMentionSelf() {
const nick = this.data.meNickname || '我'
this.setData({ content: `${this.data.content}@${nick} ` })
// ─────────────── AI 生成 ───────────────
async generateArticle() {
if (this.data.step === 'generating') return
const desc = String(this.data.description || '').trim()
if (!desc) {
wx.showToast({ title: '请先填写描述', icon: 'none' })
return
}
// 等待所有图片上传完成
const stillUploading = this.data.images.some((img) => img.uploading)
if (stillUploading) {
wx.showToast({ title: '图片上传中,请稍候', icon: 'none' })
return
}
const imageUrls = this.data.images.filter((img) => img.url).map((img) => img.url)
const userId = app.globalData.userInfo?.id || ''
this.setData({ step: 'generating', genError: '' })
try {
const res = await app.request({
url: '/api/miniprogram/super/articles/generate',
method: 'POST',
data: { userId, description: desc, imageUrls },
timeout: 90000,
})
if (!res?.success) throw new Error(res?.error || 'AI 生成失败')
this.setData({
step: 'preview',
title: String(res.data?.title || '').trim(),
content: String(res.data?.content || '').trim(),
})
} catch (err) {
this.setData({ step: 'input', genError: err.message || 'AI 生成失败,请重试' })
wx.showToast({ title: this.data.genError, icon: 'none', duration: 2500 })
}
},
insertHashLink() {
this.setData({ content: `${this.data.content}#链接(https://)` })
backToInput() {
this.setData({ step: 'input', genError: '' })
},
// ─────────────── 发布 ───────────────
async submitArticle() {
if (this.data.saving) return
if (this.data.step === 'publishing') return
const userId = app.globalData.userInfo?.id
const title = String(this.data.title || '').trim()
const content = String(this.data.content || '').trim()
if (!title || !content) {
wx.showToast({ title: '请填写标题和正文', icon: 'none' })
wx.showToast({ title: '标题和正文不能为空', icon: 'none' })
return
}
this.setData({ saving: true })
this.setData({ step: 'publishing' })
try {
const res = await app.request({
url: '/api/miniprogram/super/articles',
@@ -77,12 +201,22 @@ Page({
setTimeout(() => wx.navigateBack(), 400)
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' })
} finally {
this.setData({ saving: false })
this.setData({ step: 'preview' })
}
},
goBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) })
},
// ─────────────── 手动添加 @自己 / #链接 ───────────────
insertMentionSelf() {
const nick = this.data.meNickname || '我'
this.setData({ content: `${this.data.content}@${nick} ` })
},
insertHashLink() {
this.setData({ content: `${this.data.content}#链接(https://)` })
},
})

View File

@@ -1,29 +1,156 @@
<view class="page">
<!-- 顶部导航 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.85)"></icon>
</view>
<text class="nav-title">发文章</text>
<text class="nav-title">{{step === 'preview' || step === 'publishing' ? '预览 & 发布' : 'AI 写文章'}}</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="card">
<text class="label">标题</text>
<view class="input-wrap">
<input class="input" maxlength="40" placeholder="写个标题最多40字" value="{{title}}" bindinput="onTitleInput"/>
<!-- ═══════════════════════════════════════════
STEP 1输入阶段input / generating
═══════════════════════════════════════════ -->
<block wx:if="{{step === 'input' || step === 'generating'}}">
<!-- 图片上传区 -->
<view class="section">
<view class="section-header">
<text class="section-icon">📷</text>
<text class="section-title">添加图片</text>
<text class="section-sub">选填最多3张AI 可理解图片内容</text>
</view>
<view class="img-grid">
<view wx:for="{{images}}" wx:key="index" class="img-cell {{item.error ? 'img-cell--error' : ''}}">
<image src="{{item.url || item.tempPath}}" class="img-thumb" mode="aspectFill"/>
<!-- 上传中遮罩 -->
<view wx:if="{{item.uploading}}" class="img-overlay">
<view class="img-spin"></view>
</view>
<!-- 错误提示 -->
<view wx:if="{{item.error && !item.uploading}}" class="img-overlay img-overlay--err">
<text class="img-err-txt">上传失败</text>
</view>
<!-- 删除按钮 -->
<view wx:if="{{!item.uploading}}" class="img-del" bindtap="removeImage" data-index="{{index}}">×</view>
</view>
<!-- 添加按钮 -->
<view wx:if="{{images.length < 3}}" class="img-add" bindtap="chooseImages">
<text class="img-add-icon">+</text>
<text class="img-add-label">拍照/相册</text>
</view>
</view>
</view>
<text class="label">正文</text>
<!-- 描述输入 -->
<view class="section">
<view class="section-header">
<text class="section-icon">✏️</text>
<text class="section-title">用几句话描述你想写什么</text>
</view>
<view class="desc-wrap">
<textarea
class="desc-textarea"
maxlength="200"
placeholder="例如我有个朋友做了一件很小的事结果客户留存率提升了30%,让我想到了..."
placeholder-class="desc-placeholder"
value="{{description}}"
bindinput="onDescInput"
auto-height
disabled="{{step === 'generating'}}"
/>
<text class="char-count">{{description.length}}/200</text>
</view>
</view>
<!-- 生成错误提示 -->
<view wx:if="{{genError}}" class="error-tip">⚠️ {{genError}}</view>
<!-- AI 生成按钮 / 加载状态 -->
<view class="ai-btn-wrap">
<view wx:if="{{step !== 'generating'}}" class="ai-btn" bindtap="generateArticle">
<text class="ai-btn-icon">✦</text>
<text class="ai-btn-text">AI 一键生成文章</text>
</view>
<!-- 生成中 loading 状态 -->
<view wx:else class="ai-loading-card">
<view class="ai-loading-stars">
<text class="star star1">✦</text>
<text class="star star2">✦</text>
<text class="star star3">✦</text>
</view>
<text class="ai-loading-title">AI 正在创作中</text>
<text class="ai-loading-desc">根据你的描述生成专属内容,请稍候...</text>
<view class="ai-loading-bar">
<view class="ai-loading-bar-fill"></view>
</view>
</view>
</view>
</block>
<!-- ═══════════════════════════════════════════
STEP 2预览 & 编辑preview / publishing
═══════════════════════════════════════════ -->
<block wx:elif="{{step === 'preview' || step === 'publishing'}}">
<!-- 标题编辑 -->
<view class="section">
<view class="field-header">
<text class="field-label">标题</text>
<text class="char-count">{{title.length}}/40</text>
</view>
<view class="input-wrap">
<input
class="input"
maxlength="40"
placeholder="文章标题"
value="{{title}}"
bindinput="onTitleInput"
disabled="{{step === 'publishing'}}"
/>
</view>
</view>
<!-- 工具栏 -->
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
<view class="tool-btn" bindtap="insertHashLink">#超链接</view>
</view>
<view class="textarea-wrap">
<textarea class="textarea" maxlength="5000" placeholder="输入正文内容..." value="{{content}}" bindinput="onContentInput"/>
</view>
<text class="hint">提示:可直接输入 @昵称 或 #链接(https://xxx)</text>
<view class="submit-btn" bindtap="submitArticle">{{saving ? '发布中...' : '发布文章'}}</view>
</view>
<!-- 正文编辑 -->
<view class="section section--content">
<view class="field-header">
<text class="field-label">正文</text>
<text class="char-count">{{content.length}}/5000</text>
</view>
<view class="textarea-wrap">
<textarea
class="textarea"
maxlength="5000"
placeholder="正文内容..."
value="{{content}}"
bindinput="onContentInput"
auto-height
disabled="{{step === 'publishing'}}"
/>
</view>
</view>
<!-- 底部操作栏 -->
<view class="action-bar">
<view class="action-secondary" bindtap="backToInput" wx:if="{{step !== 'publishing'}}">
<text>↺ 重新生成</text>
</view>
<view class="action-primary {{step === 'publishing' ? 'action-primary--loading' : ''}}" bindtap="submitArticle">
<text>{{step === 'publishing' ? '发布中...' : '发布文章'}}</text>
</view>
</view>
</block>
</view>

View File

@@ -1,28 +1,264 @@
.page { min-height: 100vh; background: #0b1220; color: #fff; }
/* ── 基础 ── */
.page {
min-height: 100vh;
background: #080f1e;
color: #f1f5f9;
padding-bottom: 120rpx;
}
/* ── 导航栏 ── */
.nav-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 24rpx; background: rgba(5, 11, 20, 0.9);
height: 44px; padding: 0 24rpx;
background: rgba(8, 15, 30, 0.92);
border-bottom: 1rpx solid rgba(148, 163, 184, 0.12);
}
.nav-back, .nav-placeholder { width: 64rpx; }
.nav-title { font-size: 32rpx; font-weight: 700; }
.card {
margin: 24rpx; padding: 28rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
.nav-title { font-size: 32rpx; font-weight: 700; color: #e2e8f0; }
/* ── 通用 Section ── */
.section {
margin: 24rpx 24rpx 0;
background: rgba(15, 23, 42, 0.9);
border: 1rpx solid rgba(148, 163, 184, 0.15);
border-radius: 24rpx;
padding: 28rpx 24rpx 20rpx;
}
.section--content { min-height: 360rpx; }
.section-header {
display: flex; align-items: center; gap: 10rpx;
margin-bottom: 20rpx;
}
.section-icon { font-size: 34rpx; }
.section-title { font-size: 28rpx; font-weight: 600; color: #e2e8f0; }
.section-sub { font-size: 22rpx; color: #64748b; margin-left: 4rpx; }
/* ── 图片网格 ── */
.img-grid {
display: flex; flex-wrap: wrap; gap: 16rpx;
}
.img-cell {
position: relative;
width: 192rpx; height: 192rpx;
border-radius: 16rpx;
overflow: hidden;
background: rgba(30, 41, 59, 0.8);
border: 1rpx solid rgba(100, 116, 139, 0.3);
}
.img-cell--error { border-color: rgba(248, 113, 113, 0.5); }
.img-thumb { width: 100%; height: 100%; }
.img-overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: rgba(8, 15, 30, 0.6);
}
.img-overlay--err { background: rgba(127, 29, 29, 0.65); }
.img-err-txt { font-size: 22rpx; color: #fca5a5; }
.img-spin {
width: 48rpx; height: 48rpx;
border: 4rpx solid rgba(103, 232, 249, 0.25);
border-top-color: #67e8f9;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.img-del {
position: absolute; top: 8rpx; right: 8rpx;
width: 40rpx; height: 40rpx;
border-radius: 50%;
background: rgba(8, 15, 30, 0.75);
color: #e2e8f0;
font-size: 30rpx; line-height: 38rpx;
text-align: center;
border: 1rpx solid rgba(255,255,255,0.2);
}
.img-add {
width: 192rpx; height: 192rpx;
border-radius: 16rpx;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8rpx;
background: rgba(30, 41, 59, 0.5);
border: 2rpx dashed rgba(103, 232, 249, 0.3);
}
.img-add-icon { font-size: 52rpx; color: #67e8f9; line-height: 1; }
.img-add-label { font-size: 22rpx; color: #94a3b8; }
/* ── 描述输入框 ── */
.desc-wrap {
background: rgba(2, 8, 20, 0.6);
border-radius: 16rpx;
padding: 20rpx;
border: 1rpx solid rgba(100, 116, 139, 0.2);
}
.desc-textarea {
width: 100%;
min-height: 200rpx;
color: #e2e8f0;
font-size: 28rpx;
line-height: 1.7;
}
.desc-placeholder { color: #475569; }
/* ── 字数统计 ── */
.char-count {
display: block;
text-align: right;
font-size: 22rpx;
color: #475569;
margin-top: 8rpx;
}
/* ── 错误提示 ── */
.error-tip {
margin: 16rpx 24rpx 0;
padding: 16rpx 20rpx;
border-radius: 12rpx;
background: rgba(127, 29, 29, 0.35);
border: 1rpx solid rgba(248, 113, 113, 0.3);
font-size: 24rpx;
color: #fca5a5;
}
/* ── AI 生成按钮 ── */
.ai-btn-wrap { margin: 32rpx 24rpx 0; }
.ai-btn {
display: flex; align-items: center; justify-content: center; gap: 14rpx;
height: 100rpx; border-radius: 20rpx;
background: linear-gradient(135deg, #7c3aed 0%, #2563eb 50%, #0891b2 100%);
box-shadow: 0 8rpx 32rpx rgba(124, 58, 237, 0.4);
}
.ai-btn-icon { font-size: 36rpx; color: #fff; }
.ai-btn-text { font-size: 32rpx; font-weight: 700; color: #fff; letter-spacing: 2rpx; }
/* ── AI Loading 卡片 ── */
.ai-loading-card {
display: flex; flex-direction: column;
align-items: center; padding: 56rpx 32rpx;
border-radius: 24rpx;
background: rgba(15, 23, 42, 0.95);
border: 1rpx solid rgba(124, 58, 237, 0.35);
box-shadow: 0 0 48rpx rgba(124, 58, 237, 0.15);
}
.ai-loading-stars {
display: flex; gap: 24rpx; margin-bottom: 32rpx;
}
.star {
font-size: 40rpx; color: #a78bfa;
animation: starPulse 1.4s ease-in-out infinite;
}
.star2 { animation-delay: 0.28s; }
.star3 { animation-delay: 0.56s; }
.ai-loading-title {
font-size: 34rpx; font-weight: 700;
color: #e2e8f0; margin-bottom: 12rpx;
}
.ai-loading-desc {
font-size: 24rpx; color: #64748b;
text-align: center; line-height: 1.6; margin-bottom: 40rpx;
}
.ai-loading-bar {
width: 100%; height: 6rpx;
background: rgba(100, 116, 139, 0.2);
border-radius: 999rpx;
overflow: hidden;
}
.ai-loading-bar-fill {
height: 100%;
background: linear-gradient(90deg, #7c3aed, #2563eb, #0891b2);
border-radius: 999rpx;
animation: barSlide 2s ease-in-out infinite;
}
/* ── 预览/编辑区域 ── */
.field-header {
display: flex; align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.field-label { font-size: 26rpx; font-weight: 600; color: #94a3b8; }
.input-wrap {
background: rgba(2, 8, 20, 0.6);
border-radius: 16rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(100, 116, 139, 0.2);
}
.input { width: 100%; color: #f1f5f9; font-size: 30rpx; }
.textarea-wrap {
background: rgba(2, 8, 20, 0.6);
border-radius: 16rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(100, 116, 139, 0.2);
}
.textarea { width: 100%; min-height: 480rpx; color: #f1f5f9; font-size: 28rpx; line-height: 1.7; }
/* ── 工具栏 ── */
.toolbar {
margin: 16rpx 24rpx 0;
display: flex; gap: 12rpx;
}
.label { display: block; font-size: 24rpx; color: #cbd5e1; margin-bottom: 12rpx; }
.input-wrap, .textarea-wrap { background: rgba(2, 6, 23, 0.75); border-radius: 16rpx; padding: 18rpx 20rpx; }
.input { width: 100%; color: #fff; font-size: 28rpx; }
.toolbar { margin-top: 12rpx; margin-bottom: 12rpx; display: flex; gap: 12rpx; }
.tool-btn {
padding: 10rpx 18rpx; border-radius: 999rpx; font-size: 22rpx;
color: #67e8f9; border: 1rpx solid rgba(34, 211, 238, 0.35); background: rgba(8, 47, 73, 0.45);
padding: 10rpx 20rpx; border-radius: 999rpx;
font-size: 22rpx; color: #67e8f9;
border: 1rpx solid rgba(34, 211, 238, 0.35);
background: rgba(8, 47, 73, 0.45);
}
.textarea { width: 100%; min-height: 380rpx; color: #fff; font-size: 28rpx; line-height: 1.65; }
.hint { display: block; margin-top: 10rpx; color: #94a3b8; font-size: 22rpx; }
.submit-btn {
margin-top: 24rpx; height: 88rpx; border-radius: 16rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35; font-size: 30rpx; font-weight: 700;
/* ── 底部操作栏 ── */
.action-bar {
position: fixed; left: 0; right: 0; bottom: 0;
display: flex; gap: 16rpx; align-items: center;
padding: 16rpx 24rpx;
background: rgba(8, 15, 30, 0.95);
border-top: 1rpx solid rgba(148, 163, 184, 0.12);
}
.action-secondary {
flex: 0 0 auto;
height: 88rpx; padding: 0 36rpx;
border-radius: 16rpx;
display: flex; align-items: center; justify-content: center;
background: rgba(30, 41, 59, 0.7);
border: 1rpx solid rgba(100, 116, 139, 0.3);
color: #94a3b8;
font-size: 26rpx;
}
.action-primary {
flex: 1;
height: 88rpx; border-radius: 16rpx;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35;
font-size: 30rpx; font-weight: 700;
}
.action-primary--loading { opacity: 0.7; }
/* ── 动画 ── */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes starPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.75); }
}
@keyframes barSlide {
0% { width: 0%; margin-left: 0; }
50% { width: 70%; margin-left: 0; }
100% { width: 0%; margin-left: 100%; }
}

View File

@@ -5,6 +5,8 @@ const { trackClick } = require('../../utils/trackClick')
Page({
data: {
/** 通过审核模式校验后再展示付费页,避免闪屏与违规曝光 */
contentVisible: false,
statusBarHeight: 44,
isVip: false,
daysRemaining: 0,
@@ -28,11 +30,49 @@ Page({
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this._enterVipPage()
},
onShow() {
if (this.data.contentVisible) {
this._revalidateAuditOnShow()
}
},
/** 审核模式:本页为虚拟支付,禁止进入(与 /config/audit-mode 一致) */
async _enterVipPage() {
try {
await app.getAuditMode()
} catch (e) {
// ignore
}
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
}, 500)
return
}
this.setData({ contentVisible: true })
wx.showShareMenu({ withShareTimeline: true })
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.loadVipInfo()
},
async _revalidateAuditOnShow() {
try {
await app.getAuditMode()
} catch (e) {
// ignore
}
if (!app.globalData.auditMode) return
this.setData({ contentVisible: false })
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
}, 400)
},
async loadVipInfo() {
const userId = app.globalData.userInfo?.id
if (!userId) return
@@ -66,6 +106,10 @@ Page({
},
async handlePurchase() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法购买', icon: 'none' })
return
}
trackClick('vip', 'btn_click', '开通VIP')
let userId = app.globalData.userInfo?.id
if (!userId) {

View File

@@ -8,6 +8,13 @@
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="vip-gate-loading" wx:if="{{!contentVisible}}">
<view class="vip-gate-dot"></view>
<text class="vip-gate-txt">加载中...</text>
</view>
<block wx:if="{{contentVisible}}">
<!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<view class="vip-hero-title">加入卡若</view>
@@ -51,4 +58,5 @@
</view>
<view class="bottom-space"></view>
</block>
</view>

View File

@@ -5,6 +5,21 @@
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; }
.nav-placeholder-r { width: 60rpx; }
/* 审核校验前占位,避免露出价格与支付按钮 */
.vip-gate-loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 45vh; padding: 48rpx;
}
.vip-gate-dot {
width: 48rpx; height: 48rpx; border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.15);
border-top-color: #FFD700;
animation: vip-gate-spin 0.75s linear infinite;
margin-bottom: 24rpx;
}
.vip-gate-txt { font-size: 26rpx; color: rgba(255, 255, 255, 0.45); }
@keyframes vip-gate-spin { to { transform: rotate(360deg); } }
.vip-hero { margin: 24rpx; padding: 48rpx 32rpx; border-radius: 24rpx; background: linear-gradient(135deg, rgba(0,206,209,0.08), rgba(255,215,0,0.06)); border: 1rpx solid rgba(0,206,209,0.2); }
.vip-hero-active { border-color: rgba(255,215,0,0.4); background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(0,206,209,0.08)); }
.vip-hero-tag { display: inline-block; background: rgba(0,206,209,0.15); color: #00CED1; font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; margin-bottom: 20rpx; }

View File

@@ -61,6 +61,11 @@ type Config struct {
// Redis 连接地址(如 redis://localhost:6379/0空表示不使用 Redis
RedisURL string
// AI 文章生成(兼容 OpenAI / DeepSeek / OpenRouter 等同协议服务)
OpenAIAPIKey string // OPENAI_API_KEY
OpenAIBaseURL string // OPENAI_BASE_URL默认 https://api.openai.com/v1
OpenAIModel string // OPENAI_MODEL默认 gpt-4o-mini
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -294,6 +299,17 @@ func Load() (*Config, error) {
h5ReadPublic = strings.TrimSuffix(h5ReadPublic, "/")
h5ReadDefaultOg := strings.TrimSpace(os.Getenv("H5_READ_DEFAULT_OG_IMAGE"))
// AI 生成
aiAPIKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
aiBaseURL := strings.TrimSpace(os.Getenv("OPENAI_BASE_URL"))
if aiBaseURL == "" {
aiBaseURL = "https://api.openai.com/v1"
}
aiModel := strings.TrimSpace(os.Getenv("OPENAI_MODEL"))
if aiModel == "" {
aiModel = "gpt-4o-mini"
}
cfg := &Config{
Port: port,
Mode: mode,
@@ -324,6 +340,9 @@ func Load() (*Config, error) {
SyncOrdersIntervalMinutes: syncOrdersInterval,
UploadDir: uploadDir,
RedisURL: redisURL,
OpenAIAPIKey: aiAPIKey,
OpenAIBaseURL: aiBaseURL,
OpenAIModel: aiModel,
}
// 生产环境GIN_MODE=release强制校验敏感配置禁止使用默认值

View File

@@ -0,0 +1,220 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"github.com/gin-gonic/gin"
)
const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
写作要求:
- 标题精炼有力≤30字有吸引力点明核心价值
- 正文600-1000字有真实故事或案例有观点洞察语气自然真诚
- 风格:像朋友分享经验,而非说教;有画面感,接地气
- 不要使用"首先、其次、最后"等模板化结构词
严格输出 JSON不要有任何额外文字
{"title":"文章标题","content":"文章正文"}`
// MiniprogramSuperArticleGenerate POST /api/miniprogram/super/articles/generate
// 根据描述 + 图片 URL调用 AI 生成文章草稿(仅超级个体可用)。
func MiniprogramSuperArticleGenerate(c *gin.Context) {
var req struct {
UserID string `json:"userId"`
Description string `json:"description"`
ImageURLs []string `json:"imageUrls"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
req.UserID = strings.TrimSpace(req.UserID)
req.Description = strings.TrimSpace(req.Description)
if req.UserID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
return
}
if req.Description == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写描述内容"})
return
}
db := database.DB()
if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可使用 AI 生成", "forbidden": true})
return
}
cfg := config.Get()
if cfg == nil || strings.TrimSpace(cfg.OpenAIAPIKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请联系管理员配置"})
return
}
// 过滤空 URL
var validImageURLs []string
for _, u := range req.ImageURLs {
if u = strings.TrimSpace(u); u != "" {
validImageURLs = append(validImageURLs, u)
}
}
title, content, err := aiGenerateArticle(cfg, req.Description, validImageURLs)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 生成失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"title": title, "content": content}})
}
// aiGenerateArticle 调用 OpenAI 兼容 API 生成文章标题和正文。
// 若有图片 URL 且模型支持视觉,以多模态格式传入;否则退化为纯文本。
func aiGenerateArticle(cfg *config.Config, description string, imageURLs []string) (title, content string, err error) {
apiKey := strings.TrimSpace(cfg.OpenAIAPIKey)
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.OpenAIBaseURL), "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := strings.TrimSpace(cfg.OpenAIModel)
if model == "" {
model = "gpt-4o-mini"
}
// 判断模型是否支持视觉OpenAI gpt-4 系列 / claude-3 系列)
supportsVision := len(imageURLs) > 0 && (
strings.Contains(model, "gpt-4") ||
strings.Contains(model, "claude-3") ||
strings.Contains(model, "vision"))
var userContent interface{}
if supportsVision {
parts := []map[string]interface{}{
{"type": "text", "text": buildUserPrompt(description, imageURLs, true)},
}
for _, u := range imageURLs {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]string{"url": u, "detail": "low"},
})
}
userContent = parts
} else {
userContent = buildUserPrompt(description, imageURLs, false)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"messages": []map[string]interface{}{
{"role": "system", "content": aiArticleSystemPrompt},
{"role": "user", "content": userContent},
},
"temperature": 0.8,
"max_tokens": 2000,
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/chat/completions", bytes.NewReader(reqBody))
if err != nil {
return "", "", fmt.Errorf("构建请求失败: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return "", "", fmt.Errorf("请求 AI 服务失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var apiResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("解析 AI 响应失败")
}
if apiResp.Error != nil {
return "", "", fmt.Errorf("%s", apiResp.Error.Message)
}
if len(apiResp.Choices) == 0 || apiResp.Choices[0].Message.Content == "" {
return "", "", fmt.Errorf("AI 无返回内容")
}
return parseArticleJSON(apiResp.Choices[0].Message.Content)
}
func buildUserPrompt(description string, imageURLs []string, visionMode bool) string {
var sb strings.Builder
sb.WriteString("根据以下素材,创作一篇精彩的文章:\n\n")
sb.WriteString("用户描述:")
sb.WriteString(description)
if len(imageURLs) > 0 {
if visionMode {
sb.WriteString(fmt.Sprintf("\n\n附%d张图片请结合图片内容写作", len(imageURLs)))
} else {
sb.WriteString(fmt.Sprintf("\n\n用户上传了%d张图片作为参考素材请在文章中适当体现图片场景", len(imageURLs)))
}
}
sb.WriteString("\n\n请严格输出 JSON 格式:{\"title\":\"标题\",\"content\":\"正文\"}")
return sb.String()
}
func parseArticleJSON(raw string) (title, content string, err error) {
raw = strings.TrimSpace(raw)
// 去掉 markdown 代码块包裹
if idx := strings.Index(raw, "{"); idx >= 0 {
if end := strings.LastIndex(raw, "}"); end > idx {
raw = raw[idx : end+1]
}
}
var article struct {
Title string `json:"title"`
Content string `json:"content"`
}
if jsonErr := json.Unmarshal([]byte(raw), &article); jsonErr != nil {
// 降级:第一行当标题,其余为正文
lines := strings.SplitN(raw, "\n", 2)
if len(lines) >= 2 {
return strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]), nil
}
return "", "", fmt.Errorf("解析 AI 输出失败,请重试")
}
title = strings.TrimSpace(article.Title)
content = strings.TrimSpace(article.Content)
if title == "" || content == "" {
return "", "", fmt.Errorf("AI 生成内容不完整,请重试")
}
// 截断超限内容
if tr := []rune(title); len(tr) > 40 {
title = string(tr[:40])
}
if cr := []rune(content); len(cr) > 5000 {
content = string(cr[:5000])
}
return title, content, nil
}

View File

@@ -450,6 +450,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/super/articles", handler.MiniprogramSuperArticleList)
miniprogram.GET("/super/articles/:id", handler.MiniprogramSuperArticleDetail)
miniprogram.POST("/super/articles", handler.MiniprogramSuperArticleCreate)
miniprogram.POST("/super/articles/generate", handler.MiniprogramSuperArticleGenerate)
}
// ----- 提现 -----