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:
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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://)` })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 将路径拼接到 BaseURL,path 应以 / 开头
|
||||
@@ -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)强制校验敏感配置,禁止使用默认值
|
||||
|
||||
220
soul-api/internal/handler/miniprogram_ai_article.go
Normal file
220
soul-api/internal/handler/miniprogram_ai_article.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
|
||||
Reference in New Issue
Block a user