feat: update API base URLs and improve UI elements

- Changed API base URLs in app.js to point to the production server for deployment.
- Updated the my page UI by refining labels and removing unnecessary text for a cleaner look.
- Adjusted CSS styles to enhance layout and reduce whitespace, improving overall user experience.

This update aims to ensure proper API connectivity and enhance the visual presentation of the user interface.
This commit is contained in:
乘风
2026-05-09 17:26:55 +08:00
parent b1db1efeb8
commit c84c53515e
26 changed files with 1230 additions and 206 deletions

View File

@@ -7,6 +7,7 @@
"pages/index/index",
"pages/chapters/chapters",
"pages/match/match",
"pages/super-moments/super-moments",
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
@@ -64,6 +65,10 @@
"pagePath": "pages/match/match",
"text": "找伙伴"
},
{
"pagePath": "pages/super-moments/super-moments",
"text": "动态"
},
{
"pagePath": "pages/my/my",
"text": "我的"

View File

@@ -1,157 +1,181 @@
/**
* Soul创业实验 - 自定义TabBar组件
* 根据后台配置动态显示/隐藏"找伙伴"按钮
* Soul创业实验 - 自定义 TabBar
* 支持:首页 / 目录 / [找伙伴] / [动态,审核模式隐藏] / 我的
*/
console.log('[TabBar] ===== 组件文件开始加载 =====')
const app = getApp()
console.log('[TabBar] App 对象:', app)
Component({
data: {
selected: 0,
color: '#8e8e93',
selectedColor: '#00CED1',
matchEnabled: false, // 找伙伴功能开关,默认关闭
list: [
matchEnabled: false,
tabList: [
{
pagePath: '/pages/index/index',
text: '首页',
iconType: 'home'
iconPath: '/assets/icons/home.svg',
isSpecial: false,
},
{
pagePath: '/pages/chapters/chapters',
text: '目录',
iconType: 'list'
iconPath: '/assets/icons/list.svg',
isSpecial: false,
},
{
pagePath: '/pages/match/match',
text: '找伙伴',
iconType: 'match',
isSpecial: true
pagePath: '/pages/super-moments/super-moments',
text: '动态',
iconPath: '/assets/icons/sparkles.svg',
isSpecial: false,
},
{
pagePath: '/pages/my/my',
text: '我的',
iconType: 'user'
}
]
iconPath: '/assets/icons/user.svg',
isSpecial: false,
},
],
},
lifetimes: {
attached() {
console.log('[TabBar] Component attached 生命周期触发')
this.loadFeatureConfig()
},
ready() {
console.log('[TabBar] Component ready 生命周期触发')
// 如果 attached 中没有成功加载,在 ready 中再次尝试
if (this.data.matchEnabled === undefined || this.data.matchEnabled === null) {
console.log('[TabBar] 在 ready 中重新加载配置')
this.loadFeatureConfig()
}
}
},
// 页面加载时也调用(兼容性更好)
attached() {
console.log('[TabBar] attached() 方法触发')
this.loadFeatureConfig()
},
},
methods: {
// 加载功能配置
buildTabList(matchEnabled, tabUi, auditMode) {
const audit = !!auditMode
const ui = tabUi || {}
const h = ui.home ? String(ui.home) : '首页'
const ch = ui.chapters ? String(ui.chapters) : '目录'
const ma = ui.match ? String(ui.match) : '找伙伴'
const mo = ui.moments ? String(ui.moments) : ui.feed ? String(ui.feed) : '动态'
const my = ui.my ? String(ui.my) : '我的'
const tabs = [
{
pagePath: '/pages/index/index',
text: h,
iconPath: '/assets/icons/home.svg',
isSpecial: false,
},
{
pagePath: '/pages/chapters/chapters',
text: ch,
iconPath: '/assets/icons/list.svg',
isSpecial: false,
},
]
if (matchEnabled) {
tabs.push({
pagePath: '/pages/match/match',
text: ma,
iconPath: '/assets/icons/partners.svg',
isSpecial: true,
})
}
if (!audit) {
tabs.push({
pagePath: '/pages/super-moments/super-moments',
text: mo,
iconPath: '/assets/icons/sparkles.svg',
isSpecial: false,
})
}
tabs.push({
pagePath: '/pages/my/my',
text: my,
iconPath: '/assets/icons/user.svg',
isSpecial: false,
})
return tabs
},
async loadFeatureConfig() {
try {
console.log('[TabBar] 开始加载功能配置...')
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config')
const res = await app.getConfig()
// 兼容两种返回格式
let matchEnabled = false
if (res && res.success && res.features) {
console.log('[TabBar] features配置:', JSON.stringify(res.features))
matchEnabled = res.features.matchEnabled === true
console.log('[TabBar] matchEnabled值:', matchEnabled)
} else if (res && res.configs && res.configs.feature_config) {
// 备用格式:从 configs.feature_config 读取
console.log('[TabBar] 使用备用格式从configs读取')
matchEnabled = res.configs.feature_config.matchEnabled === true
console.log('[TabBar] matchEnabled值:', matchEnabled)
} else {
console.log('[TabBar] ⚠️ 未找到features配置使用默认值false')
console.log('[TabBar] res对象keys:', Object.keys(res || {}))
}
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
const list = [...this.data.list]
if (tabUi.home) list[0] = { ...list[0], text: String(tabUi.home) }
if (tabUi.chapters) list[1] = { ...list[1], text: String(tabUi.chapters) }
if (tabUi.match) list[2] = { ...list[2], text: String(tabUi.match) }
if (tabUi.my) list[3] = { ...list[3], text: String(tabUi.my) }
this.setData({ matchEnabled, list }, () => {
console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
// 配置加载完成后,根据当前路由设置选中状态
let matchEnabled = false
if (res && res.success && res.features) {
matchEnabled = res.features.matchEnabled === true
} else if (res && res.configs && res.configs.feature_config) {
matchEnabled = res.configs.feature_config.matchEnabled === true
}
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
try {
await app.getAuditMode()
} catch (_) {}
const auditMode = !!app.globalData.auditMode
const tabList = this.buildTabList(matchEnabled, tabUi, auditMode)
this.setData({ matchEnabled, tabList }, () => {
this.updateSelected()
})
// 如果当前在找伙伴页面,但功能已关闭,跳转到首页
if (!matchEnabled) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/match/match') {
console.log('[TabBar] 找伙伴功能已关闭从match页面跳转到首页')
wx.switchTab({ url: '/pages/index/index' })
}
}
} catch (error) {
console.log('[TabBar] ❌ 加载功能配置失败:', error)
console.log('[TabBar] 错误详情:', error.message || error)
// 默认关闭找伙伴功能
this.setData({ matchEnabled: false }, () => {
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
wx.switchTab({ url: '/pages/index/index' })
}
}
} catch (_) {
try {
await app.getAuditMode()
} catch (__) {}
const auditMode = !!app.globalData.auditMode
const tabList = this.buildTabList(false, {}, auditMode)
this.setData({ matchEnabled: false, tabList }, () => {
this.updateSelected()
})
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
wx.switchTab({ url: '/pages/index/index' })
}
}
}
},
// 根据当前路由更新选中状态
updateSelected() {
const pages = getCurrentPages()
if (pages.length === 0) return
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const route = pages[pages.length - 1].route
const tabList = this.data.tabList || []
let selected = 0
const { matchEnabled } = this.data
// 根据路由匹配对应的索引
if (route === 'pages/index/index') {
selected = 0
} else if (route === 'pages/chapters/chapters') {
selected = 1
} else if (route === 'pages/match/match') {
selected = 2
} else if (route === 'pages/my/my') {
selected = matchEnabled ? 3 : 2
for (let i = 0; i < tabList.length; i++) {
const p = tabList[i].pagePath.replace(/^\//, '')
if (route === p) {
selected = i
break
}
}
this.setData({ selected })
},
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
const index = data.index
const url = e.currentTarget.dataset.path
const index = Number(e.currentTarget.dataset.index)
if (Number.isNaN(index)) return
if (this.data.selected === index) return
wx.switchTab({ url })
}
}
},
},
})

View File

@@ -1,47 +1,36 @@
<!--custom-tab-bar/index.wxml-->
<view class="tab-bar {{matchEnabled ? 'tab-bar-four' : 'tab-bar-three'}}">
<view class="tab-bar" wx:if="{{tabList.length}}">
<view class="tab-bar-border"></view>
<!-- 首页 -->
<view class="tab-bar-item" data-path="{{list[0].pagePath}}" data-index="0" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{selected === 0 ? 'icon-active' : ''}}"
src="/assets/icons/home.svg"
mode="aspectFit"
style="color: {{selected === 0 ? selectedColor : color}}"></image>
</view>
<view class="tab-bar-text" style="color: {{selected === 0 ? selectedColor : color}}">{{list[0].text}}</view>
</view>
<!-- 目录 -->
<view class="tab-bar-item" data-path="{{list[1].pagePath}}" data-index="1" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{selected === 1 ? 'icon-active' : ''}}"
src="/assets/icons/list.svg"
mode="aspectFit"
style="color: {{selected === 1 ? selectedColor : color}}"></image>
<block wx:for="{{tabList}}" wx:key="pagePath">
<view
wx:if="{{item.isSpecial}}"
class="tab-bar-item special-item"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="special-button {{selected === index ? 'special-active' : ''}}">
<image class="special-icon" src="{{item.iconPath}}" mode="aspectFit"></image>
</view>
<view class="tab-bar-text special-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
<view class="tab-bar-text" style="color: {{selected === 1 ? selectedColor : color}}">{{list[1].text}}</view>
</view>
<!-- 找伙伴 - 中间突出按钮(根据配置显示) -->
<view class="tab-bar-item special-item" wx:if="{{matchEnabled}}" data-path="{{list[2].pagePath}}" data-index="2" bindtap="switchTab">
<view class="special-button {{selected === 2 ? 'special-active' : ''}}">
<image class="special-icon"
src="/assets/icons/partners.svg"
mode="aspectFit"></image>
<view
wx:else
class="tab-bar-item"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="icon-wrapper">
<image
class="tab-icon {{selected === index ? 'icon-active' : ''}}"
src="{{item.iconPath}}"
mode="aspectFit"
></image>
</view>
<view class="tab-bar-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
<view class="tab-bar-text special-text" style="color: {{selected === 2 ? selectedColor : color}}">{{list[2].text}}</view>
</view>
<!-- 我的 -->
<view class="tab-bar-item" data-path="{{list[3].pagePath}}" data-index="{{matchEnabled ? 3 : 2}}" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? 'icon-active' : ''}}"
src="/assets/icons/user.svg"
mode="aspectFit"
style="color: {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? selectedColor : color}}"></image>
</view>
<view class="tab-bar-text" style="color: {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? selectedColor : color}}">{{list[3].text}}</view>
</view>
</block>
</view>

View File

@@ -91,7 +91,7 @@ Page({
// 我的余额
walletBalanceText: '--',
// 超级个体:快捷入口「客资 / 链接轨迹」
// 超级个体:快捷入口「链接轨迹」
showSuperCrmEntry: false,
// 超级个体:发文章入口
showSuperArticleEntry: false,

View File

@@ -100,35 +100,30 @@
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-num">订单</text>
<text class="stat-label">我的订单</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="giftPay">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/share-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-num">代付</text>
<text class="stat-label">我的代付</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/wallet-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-num stat-num-balance">{{walletBalanceText}}</text>
<text class="stat-label">我的余额</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{showSuperCrmEntry}}" bindtap="handleMenuTap" data-id="superCrm">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-num">客资</text>
<text class="stat-label">链接与轨迹</text>
<text class="stat-label">链接轨迹</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{showSuperArticleEntry && !auditMode}}" bindtap="handleMenuTap" data-id="superArticle">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-num">发文</text>
<text class="stat-label">发布文章</text>
</view>
</view>

View File

@@ -187,6 +187,11 @@
.card-icon-img { width: 40rpx; height: 40rpx; flex-shrink: 0; }
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
/* 快捷入口:略减卡片底边距,避免第二行下方大块留白 */
.stats-card {
padding-bottom: 24rpx;
}
/* ===== 一键收款卡片 ===== */
.receive-card { padding: 28rpx 32rpx; }
.receive-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
@@ -219,15 +224,15 @@
/* 快捷入口:列数由页面 quickEntryGridCols 绑定≤4 铺满一行;>4 时为 4 列换行) */
.stats-grid {
display: grid;
gap: 24rpx;
gap: 20rpx 16rpx;
align-items: stretch;
}
.stat-box {
background: linear-gradient(155deg, rgba(42, 42, 42, 0.98) 0%, rgba(28, 35, 34, 0.99) 100%);
border-radius: 22rpx;
padding: 28rpx 16rpx 24rpx;
padding: 18rpx 12rpx 16rpx;
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
min-height: 168rpx;
min-height: 128rpx;
border: 1rpx solid rgba(79, 209, 197, 0.22);
box-shadow:
0 6rpx 22rpx rgba(0, 0, 0, 0.35),
@@ -247,7 +252,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 14rpx;
margin-bottom: 8rpx;
background: linear-gradient(145deg, rgba(79, 209, 197, 0.28) 0%, rgba(79, 209, 197, 0.06) 55%, rgba(20, 60, 55, 0.35) 100%);
border: 1rpx solid rgba(79, 209, 197, 0.38);
box-shadow:
@@ -256,22 +261,7 @@
inset 0 1rpx 0 rgba(255, 255, 255, 0.12);
}
.stat-icon-img { width: 48rpx; height: 48rpx; flex-shrink: 0; display: block; }
.stat-num {
font-size: 32rpx;
font-weight: 700;
color: #fff;
line-height: 1.25;
letter-spacing: 0.02em;
}
.stat-num-balance {
font-size: 30rpx;
font-variant-numeric: tabular-nums;
background: linear-gradient(90deg, #5eead4 0%, #4fd1c5 50%, #2dd4bf 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.stat-label { font-size: 20rpx; color: #8B939E; margin-top: 6rpx; line-height: 1.25; text-align: center; }
.stat-label { font-size: 22rpx; color: #8B939E; margin-top: 4rpx; line-height: 1.25; text-align: center; }
/* 最近阅读 */
.recent-list { display: flex; flex-direction: column; gap: 24rpx; }

View File

@@ -80,12 +80,15 @@ Page({
this.setData({ loading: false })
return
}
const imgs = Array.isArray(article.images)
? article.images.map((u) => String(u || '').trim()).filter(Boolean)
: []
this.setData({
article: {
...article,
article: Object.assign({}, article, {
createdAtText: formatTime(article.createdAt),
imageUrls: imgs,
contentHtml: renderArticleHtml(article.content),
},
}),
articleId: String(id),
h5ReadUrl: buildH5ReadUrl(id),
loading: false,
@@ -97,6 +100,15 @@ Page({
goBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) })
},
previewArticleImage(e) {
const current = e.currentTarget.dataset.current || ''
const urls = (this.data.article && this.data.article.imageUrls) || []
if (!urls.length) return
wx.previewImage({
urls,
current: current || urls[0],
})
},
copyH5Link() {
const url = this.data.h5ReadUrl
if (!url) {

View File

@@ -8,9 +8,20 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="card" wx:if="{{article}}">
<view class="article-body" wx:if="{{article}}">
<text class="title">{{article.title}}</text>
<text class="meta">{{article.authorNickname || '超级个体'}} · {{article.createdAtText}}</text>
<view class="img-gallery" wx:if="{{article.imageUrls.length}}">
<image
wx:for="{{article.imageUrls}}"
wx:key="*this"
class="article-gallery-img"
mode="widthFix"
src="{{item}}"
bindtap="previewArticleImage"
data-current="{{item}}"
/>
</view>
<view class="h5-row" wx:if="{{h5ReadUrl}}">
<view class="h5-btn" bindtap="copyH5Link">复制 H5 全文链接</view>
<text class="h5-hint">浏览器或微信内打开可阅读全文,方便分享</text>

View File

@@ -6,16 +6,30 @@
}
.nav-back, .nav-placeholder { width: 64rpx; }
.nav-title { font-size: 32rpx; font-weight: 700; }
.card {
margin: 24rpx; padding: 30rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
.article-body {
padding: 8rpx 24rpx 48rpx;
}
.title { font-size: 36rpx; font-weight: 700; line-height: 1.4; display: block; }
.meta { margin-top: 12rpx; font-size: 22rpx; color: #94a3b8; display: block; }
.h5-row { margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid rgba(148, 163, 184, 0.2); }
.img-gallery {
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.article-gallery-img {
width: 100%;
display: block;
border-radius: 0;
}
.h5-row { margin-top: 28rpx; padding-top: 0; border-top: none; }
.h5-btn {
display: inline-block; padding: 12rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 600;
color: #0ea5e9; background: rgba(14, 165, 233, 0.12); border: 1rpx solid rgba(14, 165, 233, 0.45);
display: inline-block;
padding: 12rpx 0;
font-size: 24rpx;
font-weight: 600;
color: #22d3ee;
background: transparent;
}
.h5-hint { display: block; margin-top: 12rpx; font-size: 20rpx; color: #64748b; line-height: 1.5; }
.content {

View File

@@ -15,10 +15,13 @@ function newAttachId() {
Page({
data: {
/** 临时隐藏「AI 写文章」卡片;需上线时再设为 true */
showAiWriteSection: false,
statusBarHeight: 44,
auditMode: false,
title: '',
content: '',
bodyImages: [],
saving: false,
meNickname: '',
aiPrompt: '',
@@ -99,6 +102,117 @@ Page({
this.setData({ content: `${this.data.content}#链接(https://)` })
},
/** 编辑区配图(发布后展示在正文前),最多 9 张 */
chooseArticleImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
const max = 9
const cur = this.data.bodyImages.length
if (cur >= max) {
wx.showToast({ title: '最多 9 张配图', icon: 'none' })
return
}
const pickCount = max - cur
const mapFromChooseMedia = (res) => {
const files = res.tempFiles || []
return files.map((f, i) => ({
path: f.tempFilePath,
name: `配图_${Date.now()}_${i}.jpg`,
size: f.size || 0,
}))
}
const mapFromChooseImage = (res) => {
const paths = res.tempFilePaths || []
const t = Date.now()
return paths.map((path, i) => ({
path,
name: `配图_${t}_${i}.jpg`,
size: 0,
}))
}
const runUpload = async (tempFiles) => {
for (const f of tempFiles) {
if (this.data.bodyImages.length >= max) break
await this._uploadOneArticleBodyImage(f)
}
}
if (typeof wx.chooseMedia === 'function') {
wx.chooseMedia({
count: pickCount,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
success: (res) => {
void runUpload(mapFromChooseMedia(res))
},
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
if (typeof wx.chooseImage === 'function') {
wx.chooseImage({
count: pickCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
void runUpload(mapFromChooseImage(res))
},
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
wx.showToast({ title: '当前环境不支持选图片', icon: 'none' })
},
async _uploadOneArticleBodyImage(f) {
const filePath = f.path
const name = String(f.name || '配图').trim() || '配图'
const size = f.size || 0
if (!filePath) return
if (size > 29 * 1024 * 1024) {
wx.showToast({ title: '单张请勿超过 30MB', icon: 'none' })
return
}
const id = newAttachId()
this.setData({
bodyImages: this.data.bodyImages.concat([{ id, url: '', uploading: true }]),
})
try {
const up = await uploadByApi({ filePath, folder: 'super-article-images' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
const next = this.data.bodyImages.map((x) =>
x.id === id ? Object.assign({}, x, { url, uploading: false }) : x,
)
this.setData({ bodyImages: next })
} catch (_) {
this.setData({
bodyImages: this.data.bodyImages.filter((x) => x.id !== id),
})
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
},
removeArticleImage(e) {
if (this.data.auditMode) return
const id = e.currentTarget.dataset.id
if (!id) return
this.setData({
bodyImages: this.data.bodyImages.filter((x) => x.id !== id),
})
},
/** 参考素材:仅从相册/相机选图片并上传AI 侧按图理解) */
chooseReferenceImages() {
this.syncAuditMode()
@@ -334,19 +448,30 @@ Page({
wx.showToast({ title: '请填写标题和正文', icon: 'none' })
return
}
if (this.data.bodyImages.some((x) => x.uploading)) {
wx.showToast({ title: '请等待配图上传完成', icon: 'none' })
return
}
const images = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
this.setData({ saving: true })
wx.showLoading({ title: '发布中…', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/super/articles',
method: 'POST',
data: { userId, title, content },
data: { userId, title, content, images },
})
if (!res?.success) throw new Error(res?.error || '发布失败')
this.setData({ title: '', content: '', bodyImages: [] })
wx.showToast({ title: '发布成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 400)
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' })
} finally {
try {
wx.hideLoading()
} catch (_) {}
this.setData({ saving: false })
}
},

View File

@@ -8,8 +8,8 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- AI参考图片 + 提示词 -->
<view class="card">
<!-- AI参考图片 + 提示词开关showAiWriteSection -->
<view wx:if="{{showAiWriteSection}}" class="card">
<text class="section-title">AI 写文章</text>
<text class="label">参考图片(可多选)</text>
<view class="file-actions">
@@ -42,6 +42,19 @@
<input class="input" maxlength="40" placeholder="写个标题最多40字" value="{{title}}" bindinput="onTitleInput" disabled="{{auditMode}}" />
</view>
<text class="label">配图(可选,最多 9 张)</text>
<view class="file-actions">
<view class="add-file-btn" bindtap="chooseArticleImages"> 添加配图</view>
</view>
<view class="body-img-grid" wx:if="{{bodyImages.length}}">
<view wx:for="{{bodyImages}}" wx:key="id" class="body-img-cell">
<image wx:if="{{item.url}}" class="body-img-thumb" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="body-img-mask">上传中…</view>
<text class="body-img-remove" data-id="{{item.id}}" bindtap="removeArticleImage">×</text>
</view>
</view>
<text class="hint">配图会展示在正文上方(与书籍正文分离存储);单张建议不超 30MB。</text>
<text class="label">正文</text>
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
@@ -52,7 +65,7 @@
</view>
<text class="hint">提示:可直接输入 @昵称 或 #链接(https://xxx)</text>
<view class="submit-btn" bindtap="submitArticle">{{saving ? '发布中…' : '发布文章'}}</view>
<view class="submit-btn {{saving ? 'submit-btn-loading' : ''}}" bindtap="submitArticle">{{saving ? '发布中…' : '发布文章'}}</view>
</view>
<view wx:if="{{auditMode}}" class="audit-mode-mask" catchtouchmove="preventMove">

View File

@@ -36,6 +36,54 @@
.file-remove {
flex-shrink: 0; font-size: 24rpx; color: #f97316; padding: 8rpx 12rpx;
}
.body-img-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 12rpx;
}
.body-img-cell {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.body-img-thumb {
width: 100%;
height: 100%;
display: block;
}
.body-img-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
}
.body-img-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.prompt-textarea { min-height: 200rpx; }
.generate-btn {
margin-top: 20rpx; height: 88rpx; border-radius: 16rpx;
@@ -60,6 +108,7 @@
color: #032b35; font-size: 30rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.submit-btn-loading { opacity: 0.72; pointer-events: none; }
/* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */
.audit-mode-mask {

View File

@@ -0,0 +1,157 @@
/**
* 动态广场:超级个体图文列表(触底分页)
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const PAGE_SIZE = 10
function avatarLetter(name) {
const s = String(name || '').trim()
if (!s) return '会'
const ch = s[0]
return /[\u4e00-\u9fff]/.test(ch) ? ch : '会'
}
function relativeTime(iso) {
if (!iso) return ''
const d = new Date(iso)
const t = d.getTime()
if (Number.isNaN(t)) return ''
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
if (sec < 60) return '刚刚'
if (sec < 3600) return `${Math.floor(sec / 60)}分钟前`
if (sec < 86400) return `${Math.floor(sec / 3600)}小时前`
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}天前`
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${m}-${day}`
}
function mapFeedRows(rows) {
return (rows || []).map((r) => {
const nick = String(r.authorNickname || '').trim() || '超级个体'
const av = String(r.authorAvatar || '').trim()
const avatarOk = isSafeImageSrc(av)
const imgs = Array.isArray(r.images) ? r.images : []
const displayImages = imgs.map((u) => String(u || '').trim()).filter((u) => isSafeImageSrc(u))
return Object.assign({}, r, {
authorNickname: nick,
avatarSrc: av,
avatarOk,
avatarLetter: avatarLetter(nick),
timeText: relativeTime(r.createdAt),
preview: String(r.preview || '').trim(),
displayImages,
})
})
}
Page({
data: {
statusBarHeight: 44,
feedList: [],
page: 1,
total: 0,
hasMore: true,
loading: true,
loadingMore: false,
},
async onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
try {
await app.getAuditMode()
} catch (_) {}
if (app.globalData.auditMode) {
wx.switchTab({ url: '/pages/index/index' })
return
}
this.reloadFeed()
},
async onShow() {
try {
await app.getAuditMode()
} catch (_) {}
if (app.globalData.auditMode) {
wx.switchTab({ url: '/pages/index/index' })
return
}
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
if (tabBar && tabBar.loadFeatureConfig) tabBar.loadFeatureConfig()
if (tabBar && tabBar.updateSelected) tabBar.updateSelected()
}
},
onReachBottom() {
this.loadMore()
},
async reloadFeed() {
this.setData({
loading: true,
page: 1,
feedList: [],
hasMore: true,
total: 0,
})
try {
await this.fetchPage(1, true)
} finally {
this.setData({ loading: false })
}
},
async loadMore() {
if (this.data.loading || this.data.loadingMore || !this.data.hasMore) return
const next = this.data.page + 1
this.setData({ loadingMore: true })
try {
await this.fetchPage(next, false)
} finally {
this.setData({ loadingMore: false })
}
},
async fetchPage(page, replace) {
const res = await app.request({
url: `/api/miniprogram/super/articles/feed?page=${encodeURIComponent(String(page))}&pageSize=${PAGE_SIZE}`,
silent: true,
})
if (!res?.success) {
wx.showToast({ title: String(res?.error || '加载失败'), icon: 'none' })
if (replace) this.setData({ feedList: [], hasMore: false, total: 0 })
return
}
const list = mapFeedRows(res.list || [])
const total = Number(res.total) || 0
const merged = replace ? list : this.data.feedList.concat(list)
const hasMore = merged.length < total
this.setData({
feedList: merged,
page,
total,
hasMore,
})
},
openArticle(e) {
const id = e.currentTarget.dataset.id
if (!id) return
wx.navigateTo({
url: `/pages/super-article-detail/super-article-detail?id=${encodeURIComponent(String(id))}`,
})
},
previewArticleImages(e) {
const fi = Number(e.currentTarget.dataset.feedIndex)
const ii = Number(e.currentTarget.dataset.imgIndex)
const row = this.data.feedList[fi]
const urls = row && row.displayImages ? row.displayImages : []
if (!urls.length) return
const current = urls[ii] || urls[0]
wx.previewImage({ urls, current })
},
})

View File

@@ -0,0 +1,9 @@
{
"usingComponents": {
"icon": "/components/icon/icon"
},
"enablePullDownRefresh": false,
"onReachBottomDistance": 160,
"backgroundTextStyle": "light",
"backgroundColor": "#0b1220"
}

View File

@@ -0,0 +1,61 @@
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-placeholder"></view>
<text class="nav-title">动态广场</text>
<view class="nav-placeholder"></view>
</view>
<view class="nav-offset" style="height: {{statusBarHeight + 44}}px;"></view>
<view class="feed-wrap">
<view class="empty-block" wx:if="{{!loading && feedList.length === 0}}">
<text class="empty-text">暂无动态</text>
<text class="empty-sub">超级个体发文后将出现在这里</text>
</view>
<block wx:for="{{feedList}}" wx:key="id">
<view class="feed-item" bindtap="openArticle" data-id="{{item.id}}">
<view class="feed-head">
<image
wx:if="{{item.avatarOk}}"
class="feed-avatar"
mode="aspectFill"
src="{{item.avatarSrc}}"
/>
<view wx:else class="feed-avatar feed-avatar-ph">{{item.avatarLetter}}</view>
<view class="feed-head-mid">
<text class="feed-name">{{item.authorNickname || '超级个体'}}</text>
<text class="feed-time">{{item.timeText}}</text>
</view>
</view>
<text class="feed-title" wx:if="{{item.title}}">{{item.title}}</text>
<text class="feed-preview">{{item.preview}}</text>
<view class="feed-imgs" wx:if="{{item.displayImages.length}}">
<image
wx:for="{{item.displayImages}}"
wx:for-item="img"
wx:for-index="ii"
wx:key="*this"
class="feed-img {{item.displayImages.length === 1 ? 'feed-img-one' : ''}}"
mode="aspectFill"
src="{{img}}"
catchtap="previewArticleImages"
data-feed-index="{{index}}"
data-img-index="{{ii}}"
/>
</view>
<view class="feed-foot">
<text class="feed-more">查看全文</text>
<icon name="chevron-right" size="28" color="rgba(34,211,238,0.75)" customClass="feed-more-icon"></icon>
</view>
</view>
</block>
<view class="load-more" wx:if="{{feedList.length}}">
<text wx:if="{{loadingMore}}">加载中…</text>
<text wx:elif="{{!hasMore}}">已经到底啦</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,164 @@
.page {
min-height: 100vh;
background: #0b1220;
color: #e2e8f0;
padding-bottom: calc(220rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.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.92);
border-bottom: 1rpx solid rgba(148, 163, 184, 0.08);
}
.nav-placeholder {
width: 64rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 700;
color: #f8fafc;
}
.feed-wrap {
padding: 8rpx 0 32rpx;
}
.empty-block {
padding: 120rpx 32rpx;
text-align: center;
}
.empty-text {
display: block;
font-size: 30rpx;
color: #94a3b8;
}
.empty-sub {
display: block;
margin-top: 16rpx;
font-size: 24rpx;
color: #64748b;
}
/* 扁平信息流:无卡片底、无描边、无阴影 */
.feed-item {
padding: 28rpx 24rpx 36rpx;
}
.feed-head {
display: flex;
align-items: center;
gap: 16rpx;
}
.feed-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
flex-shrink: 0;
background: #1e293b;
}
.feed-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: #67e8f9;
background: rgba(30, 41, 59, 0.65);
}
.feed-head-mid {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.feed-name {
font-size: 28rpx;
font-weight: 600;
color: #f1f5f9;
}
.feed-time {
font-size: 22rpx;
color: #94a3b8;
}
.feed-title {
display: block;
margin-top: 18rpx;
font-size: 30rpx;
font-weight: 700;
color: #f8fafc;
line-height: 1.45;
}
.feed-preview {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: #cbd5e1;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.feed-imgs {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.feed-img {
width: calc((100% - 20rpx) / 3);
height: 200rpx;
border-radius: 0;
}
.feed-img-one {
width: 100%;
height: 320rpx;
}
.feed-foot {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4rpx;
}
.feed-more {
font-size: 24rpx;
color: rgba(34, 211, 238, 0.85);
font-weight: 500;
}
.feed-more-icon {
opacity: 0.85;
}
.load-more {
text-align: center;
padding: 24rpx 0 48rpx;
font-size: 24rpx;
color: #64748b;
}

View File

@@ -64,6 +64,8 @@ func Init(dsn string) error {
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
ensureUserPasswordHashColumn(db)
ensureSuperArticlesTableRaw(db)
ensureSuperArticleImagesColumn(db)
log.Println("database: connected")
return nil
}
@@ -154,6 +156,8 @@ func Init(dsn string) error {
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
ensureUserPasswordHashColumn(db)
ensureSuperArticlesTableRaw(db)
ensureSuperArticleImagesColumn(db)
log.Println("database: connected")
return nil
}
@@ -235,6 +239,48 @@ CREATE TABLE IF NOT EXISTS open_platform_api_logs (
}
}
// ensureSuperArticlesTableRaw SKIP_AUTO_MIGRATE 或未跑 AutoMigrate 时兜底建表(与 model.SuperArticle、scripts/create_super_articles.sql 一致)
func ensureSuperArticlesTableRaw(db *gorm.DB) {
m := db.Migrator()
if m.HasTable(&model.SuperArticle{}) {
return
}
sqlStmt := `
CREATE TABLE IF NOT EXISTS super_articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id VARCHAR(50) NOT NULL COMMENT '作者 users.id',
title VARCHAR(200) NOT NULL DEFAULT '',
content LONGTEXT COMMENT '正文',
images LONGTEXT NULL COMMENT '配图 URL JSON 数组',
created_at DATETIME(3) NULL,
updated_at DATETIME(3) NULL,
PRIMARY KEY (id),
KEY idx_super_articles_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='超级个体发文(小程序 /s/:id H5'`
if err := db.Exec(sqlStmt).Error; err != nil {
log.Printf("database: ensureSuperArticlesTableRaw warning: %v", err)
}
}
// ensureSuperArticleImagesColumn 旧库补 images 列SKIP_AUTO_MIGRATE / 早期建表无该列)
func ensureSuperArticleImagesColumn(db *gorm.DB) {
m := db.Migrator()
if !m.HasTable(&model.SuperArticle{}) {
return
}
if m.HasColumn(&model.SuperArticle{}, "Images") {
return
}
if err := db.Exec("ALTER TABLE super_articles ADD COLUMN images LONGTEXT NULL COMMENT '配图 URL JSON 数组'").Error; err != nil {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "duplicate column") {
return
}
log.Printf("database: super_articles add images column warning: %v", err)
}
}
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {

View File

@@ -37,6 +37,26 @@ func h5SuperArticleBodyHTML(text string) string {
return strings.ReplaceAll(src, "\n", "<br/>")
}
func h5SuperArticleGalleryHTML(urls []string) string {
if len(urls) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<div class="sa-gallery">`)
for _, u := range urls {
u = strings.TrimSpace(u)
if u == "" {
continue
}
esc := html.EscapeString(u)
b.WriteString(`<div class="sa-img-wrap"><img src="`)
b.WriteString(esc)
b.WriteString(`" alt="" loading="lazy"/></div>`)
}
b.WriteString(`</div>`)
return b.String()
}
type h5SuperArticleData struct {
Title, MetaLine, ContentHTML string
Canonical string
@@ -111,9 +131,16 @@ func H5SuperArticlePage(c *gin.Context) {
ogDesc = p
}
}
ogImage := strings.TrimSpace(author.Avatar)
if ogImage != "" {
ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL)
articleImages := parseSuperArticleImagesJSON(row.Images)
ogImage := ""
if len(articleImages) > 0 && strings.TrimSpace(articleImages[0]) != "" {
ogImage = h5AbsolutizeImage(strings.TrimSpace(articleImages[0]), cfg.BaseURL)
}
if ogImage == "" {
ogImage = strings.TrimSpace(author.Avatar)
if ogImage != "" {
ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL)
}
}
shareLinkJSON, _ := json.Marshal(canonical)
signURL := h5RequestSignURL(c)
@@ -131,7 +158,7 @@ func H5SuperArticlePage(c *gin.Context) {
page := h5BuildSuperArticlePage(h5SuperArticleData{
Title: ti,
MetaLine: html.EscapeString(metaLine),
ContentHTML: h5SuperArticleBodyHTML(row.Content),
ContentHTML: h5SuperArticleGalleryHTML(articleImages) + h5SuperArticleBodyHTML(row.Content),
Canonical: canonical,
OgTagsHTML: h5BuildOgTags(canonical, plainTitle, ogDesc, ogImage),
ShareLinkJSON: string(shareLinkJSON),
@@ -181,6 +208,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue"
.hdr{padding:8px 0 20px;border-bottom:1px solid rgba(255,255,255,0.08);margin-bottom:20px}
.hdr h1{font-size:22px;color:#f8fafc;line-height:1.45;font-weight:700}
.hdr .meta{font-size:13px;color:rgba(148,163,184,0.95);margin-top:10px}
.sa-gallery{display:flex;flex-direction:column;gap:12px;margin-bottom:20px}
.sa-img-wrap{border-radius:12px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)}
.sa-gallery img{display:block;width:100%;height:auto}
.content{font-size:16px;color:#e2e8f0;line-height:1.8;word-break:break-word}
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(11,18,32,0.98) 45%,transparent);padding:10px 16px calc(16px + env(safe-area-inset-bottom));z-index:100}
.cta-inner{max-width:680px;margin:0 auto}

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
@@ -9,6 +10,7 @@ import (
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type superArticleAuthorRow struct {
@@ -35,13 +37,163 @@ func loadSuperArticleAuthorMap(ids []string) map[string]superArticleAuthorRow {
return out
}
const (
superArticleMaxImages = 9
superArticleMaxImageURLLen = 2048
)
func parseSuperArticleImagesJSON(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil
}
out := make([]string, 0, len(arr))
for _, u := range arr {
u = strings.TrimSpace(u)
if u != "" {
out = append(out, u)
}
}
return out
}
// superArticleFeedAllowedAuthorIDs 文章中出现过、且当前仍为超级个体的用户 ID用于动态广场
func superArticleFeedAllowedAuthorIDs(db *gorm.DB) []string {
var raw []string
_ = db.Model(&model.SuperArticle{}).Distinct("user_id").Pluck("user_id", &raw).Error
out := make([]string, 0, len(raw))
seen := map[string]bool{}
for _, uid := range raw {
uid = strings.TrimSpace(uid)
if uid == "" || seen[uid] {
continue
}
seen[uid] = true
if _, ok := miniprogramSuperIndividualPerson(db, uid); ok {
out = append(out, uid)
}
}
return out
}
// MiniprogramSuperArticleFeed GET /api/miniprogram/super/articles/feed?page=&pageSize=
// 动态广场:当前仍为超级个体的作者所发文章,按创建时间倒序分页。
func MiniprogramSuperArticleFeed(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 30 {
pageSize = 10
}
db := database.DB()
allowed := superArticleFeedAllowedAuthorIDs(db)
if len(allowed) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "list": []gin.H{}, "total": int64(0), "page": page, "pageSize": pageSize})
return
}
q := db.Model(&model.SuperArticle{}).Where("user_id IN ?", allowed)
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
var rows []model.SuperArticle
if err := db.Where("user_id IN ?", allowed).
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
idSet := map[string]bool{}
var authorIDs []string
for _, r := range rows {
uid := strings.TrimSpace(r.UserID)
if uid == "" || idSet[uid] {
continue
}
idSet[uid] = true
authorIDs = append(authorIDs, uid)
}
authorMap := loadSuperArticleAuthorMap(authorIDs)
list := make([]gin.H, 0, len(rows))
for _, r := range rows {
content := strings.TrimSpace(r.Content)
preview := content
if len([]rune(preview)) > 120 {
preview = string([]rune(preview)[:120]) + "..."
}
a := authorMap[strings.TrimSpace(r.UserID)]
list = append(list, gin.H{
"id": r.ID,
"userId": r.UserID,
"title": r.Title,
"content": r.Content,
"preview": preview,
"images": parseSuperArticleImagesJSON(r.Images),
"authorNickname": strings.TrimSpace(a.Nickname),
"authorAvatar": strings.TrimSpace(a.Avatar),
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
func normalizeSuperArticleImageURLs(in []string) ([]string, string) {
if len(in) == 0 {
return []string{}, ""
}
if len(in) > superArticleMaxImages {
return nil, "配图最多 9 张"
}
out := make([]string, 0, len(in))
for _, u := range in {
u = strings.TrimSpace(u)
if u == "" {
continue
}
if len(u) > superArticleMaxImageURLLen {
return nil, "图片链接过长"
}
low := strings.ToLower(u)
if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") {
return nil, "图片链接须以 http(s):// 开头"
}
out = append(out, u)
}
if len(out) > superArticleMaxImages {
return nil, "配图最多 9 张"
}
return out, ""
}
// MiniprogramSuperArticleCreate POST /api/miniprogram/super/articles
// 仅超级个体本人可发文章。
func MiniprogramSuperArticleCreate(c *gin.Context) {
var req struct {
UserID string `json:"userId"`
Title string `json:"title"`
Content string `json:"content"`
UserID string `json:"userId"`
Title string `json:"title"`
Content string `json:"content"`
Images []string `json:"images"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
@@ -63,6 +215,17 @@ func MiniprogramSuperArticleCreate(c *gin.Context) {
return
}
imgURLs, imgErr := normalizeSuperArticleImageURLs(req.Images)
if imgErr != "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": imgErr})
return
}
imgJSONBytes, jerr := json.Marshal(imgURLs)
if jerr != nil {
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": "仅超级个体可发文章", "forbidden": true})
@@ -73,6 +236,7 @@ func MiniprogramSuperArticleCreate(c *gin.Context) {
UserID: req.UserID,
Title: req.Title,
Content: req.Content,
Images: string(imgJSONBytes),
}
if err := db.Create(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
@@ -130,6 +294,7 @@ func MiniprogramSuperArticleList(c *gin.Context) {
"title": r.Title,
"content": r.Content,
"preview": preview,
"images": parseSuperArticleImagesJSON(r.Images),
"authorNickname": strings.TrimSpace(author.Nickname),
"authorAvatar": strings.TrimSpace(author.Avatar),
"createdAt": r.CreatedAt,
@@ -172,6 +337,7 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
"userId": row.UserID,
"title": row.Title,
"content": row.Content,
"images": parseSuperArticleImagesJSON(row.Images),
"authorNickname": strings.TrimSpace(author.Nickname),
"authorAvatar": strings.TrimSpace(author.Avatar),
"createdAt": row.CreatedAt,

View File

@@ -170,7 +170,7 @@ func UploadPost(c *gin.Context) {
func uploadPostSaveLocal(c *gin.Context, file *multipart.FileHeader, folder, name, ct string) {
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
uploadDir = "./uploads"
}
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
@@ -240,7 +240,7 @@ func UploadDelete(c *gin.Context) {
}
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
uploadDir = "./uploads"
}
fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel))
if err := os.Remove(fullPath); err != nil {

View File

@@ -19,6 +19,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/oss"
@@ -31,6 +32,17 @@ const (
defaultImageQuality = 85
)
// effectiveUploadDiskDir 与 router.Static("/uploads")、upload.go uploadPostSaveLocal 一致;
// 避免配置了 UPLOAD_DIR绝对路径时仍写入进程 cwd 下 ./uploads导致 GET /uploads/… 读挂载目录 404。
func effectiveUploadDiskDir() string {
if cfg := config.Get(); cfg != nil {
if d := strings.TrimSpace(cfg.UploadDir); d != "" {
return d
}
}
return "./uploads"
}
var (
allowedImageTypes = map[string]bool{
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
@@ -140,7 +152,7 @@ func UploadImagePost(c *gin.Context) {
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
dir := filepath.Join(effectiveUploadDiskDir(), folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := os.WriteFile(dst, finalData, 0644); err != nil {
@@ -214,7 +226,7 @@ func UploadVideoPost(c *gin.Context) {
}
// 回退本地存储
dir := filepath.Join(uploadDirContent, folder)
dir := filepath.Join(effectiveUploadDiskDir(), folder)
_ = os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {

View File

@@ -8,6 +8,7 @@ type SuperArticle struct {
UserID string `gorm:"column:user_id;size:50;index:idx_super_articles_user_time" json:"userId"`
Title string `gorm:"column:title;size:200" json:"title"`
Content string `gorm:"column:content;type:text" json:"content"`
Images string `gorm:"column:images;type:text" json:"-"` // JSON 数组 ["https://..."],最多 9 条
CreatedAt time.Time `gorm:"column:created_at;index:idx_super_articles_user_time" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -9,6 +9,7 @@ import (
"log"
"net/url"
"strings"
"sync"
"soul-api/internal/config"
"soul-api/internal/database"
@@ -180,23 +181,14 @@ func finalizeOssConfig(cfg *Config) bool {
return true
}
func logOssConfigRejected(cfg *Config) {
if cfg == nil {
log.Printf("oss: oss_config rejected: nil cfg")
return
}
switch {
case cfg.Bucket == "":
log.Printf("oss: oss_config rejected: missing bucket")
case cfg.AccessKeyID == "":
log.Printf("oss: oss_config rejected: missing accessKeyId")
case cfg.AccessKeySecret == "" || ossSecretIsPlaceholder(cfg.AccessKeySecret):
log.Printf("oss: oss_config rejected: missing or placeholder accessKeySecret")
case strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(cfg.Endpoint), "https://"), "http://") == "" && inferEndpointFromRegion(cfg.Region) == "":
log.Printf("oss: oss_config rejected: missing endpoint and region")
default:
log.Printf("oss: oss_config rejected: incomplete")
}
// 配置不完整或 Secret 为占位时走本地上传兜底;进程内只提示一次,避免并发上传刷屏。
var logLocalFallbackOnce sync.Once
var logOssParseOnce sync.Once
func noteLocalUploadFallback() {
logLocalFallbackOnce.Do(func() {
log.Print("oss: 未启用(配置不完整或 Secret 为占位),上传已使用本地兜底;可在管理端填写 OSS 或设置环境变量 ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET")
})
}
// applyAliyunOSEnvFallback 库中 Secret 为空/占位或 AccessKeyId 为空时,用环境变量 ALIYUN_OSS_ACCESS_KEY_* 补齐(不上传 .env 到公开仓库)。
@@ -228,12 +220,14 @@ func LoadConfig() *Config {
}
cfg, err := decodeOssConfigBytes(row.ConfigValue, 0)
if err != nil {
log.Printf("oss: oss_config parse error: %v", err)
logOssParseOnce.Do(func() {
log.Printf("oss: oss_config parse error: %v", err)
})
return nil
}
applyAliyunOSEnvFallback(&cfg)
if !finalizeOssConfig(&cfg) {
logOssConfigRejected(&cfg)
noteLocalUploadFallback()
return nil
}
return &cfg

View File

@@ -456,6 +456,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats)
miniprogram.GET("/my/super-leads", handler.MiniprogramMySuperLeads)
miniprogram.GET("/my/super-lead-tracks", handler.MiniprogramMySuperLeadTracks)
miniprogram.GET("/super/articles/feed", handler.MiniprogramSuperArticleFeed)
miniprogram.GET("/super/articles", handler.MiniprogramSuperArticleList)
miniprogram.GET("/super/articles/:id", handler.MiniprogramSuperArticleDetail)
miniprogram.POST("/super/articles", handler.MiniprogramSuperArticleCreate)

View File

@@ -1,16 +1,21 @@
-- 超级个体文章表(与 soul-api/internal/model/super_article.go、AutoMigrate 一致)
-- 线上库缺表时报Error 1146 (42S02): Table 'soul_miniprogram.super_articles' doesn't exist
-- 在目标库执行mysql soul_miniprogram < create_super_articles.sql
-- 或使用 Navicat 选中 soul_miniprogram 后运行本脚本。
-- 缺表时报Error 1146 (42S02): Table 'xxx.super_articles' doesn't exist(如 souldev、soul_miniprogram
-- 在目标库执行mysql -u... -p souldev < create_super_articles.sql
-- 或使用 Navicat 选中对应库后运行本脚本。
-- 若 soul-api 已含 ensureSuperArticlesTableRaw重启 APISKIP_AUTO_MIGRATE=1 也会自动建表)即可。
CREATE TABLE IF NOT EXISTS `super_articles` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` varchar(50) NOT NULL COMMENT '作者 users.id',
`title` varchar(200) NOT NULL DEFAULT '',
`content` longtext COMMENT '正文',
`images` longtext COMMENT '配图 URL JSON 数组,最多 9 张',
`created_at` datetime(3) DEFAULT NULL,
`updated_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_super_articles_user_time` (`user_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='超级个体发文(小程序 /s/:id H5';
-- 已有表缺列时执行:
-- ALTER TABLE super_articles ADD COLUMN images LONGTEXT NULL COMMENT '配图 URL JSON 数组,最多 9 张';

151
配置.txy Normal file
View File

@@ -0,0 +1,151 @@
server {
listen 80;
listen [::]:80;
server_name souldev.quwanzhi.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/wwwroot/souldev.quwanzhi.com;
# 以下若有自定义 location ^~ /uploads走站点 root 静态),会与 soul-api 落盘路径不一致导致 404应删掉 extension 里同类配置或改为反代。
include /www/server/panel/vhost/nginx/extension/souldev.quwanzhi.com/*.conf;
#CERT-APPLY-CHECK--START
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
include /www/server/panel/vhost/nginx/well-known/souldev.quwanzhi.com.conf;
#CERT-APPLY-CHECK--END
#SSL-START SSL相关配置请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
#SSL-END
#REDIRECT START
#REDIRECT END
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
#error_page 404 /404.html;
#error_page 502 /502.html;
#ERROR-PAGE-END
#PHP-INFO-START PHP引用配置可以注释或修改
include enable-php-00.conf;
#PHP-INFO-END
#IP-RESTRICT-START 限制访问ip的配置IP黑白名单
#IP-RESTRICT-END
#BASICAUTH START
#BASICAUTH END
#SUB_FILTER START
#SUB_FILTER END
#GZIP START
#GZIP END
#GLOBAL-CACHE START
#GLOBAL-CACHE END
# soul-api 上传目录直出(与服务器 .env 中 UPLOAD_DIR 保持一致;勿在 extension 里再写 location /uploads 以免冲突)
location ^~ /uploads/ {
alias /www/wwwroot/self/soul-api/uploads/;
access_log off;
expires 7d;
add_header Cache-Control "public";
}
#WEBSOCKET-SUPPORT START
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
#WEBSOCKET-SUPPORT END
#PROXY-CONF-START
location ^~ / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host 127.0.0.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# 支持websocket链接
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
#PROXY-CONF-END
#SERVER-BLOCK START
#SERVER-BLOCK END
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) {
return 404;
}
#一键申请SSL证书验证目录相关设置
location /.well-known {
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
#LOG START
access_log /www/wwwlogs/souldev.quwanzhi.com.log;
error_log /www/wwwlogs/souldev.quwanzhi.com.error.log;
#LOG END
}
# SSL server — 由脚本追加(面板有证书但未写入 443 时)
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name souldev.quwanzhi.com;
ssl_certificate /www/server/panel/vhost/ssl/souldev.quwanzhi.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/ssl/souldev.quwanzhi.com/privkey.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
location ^~ /uploads/ {
alias /www/wwwroot/self/soul-api/uploads/;
access_log off;
expires 7d;
add_header Cache-Control "public";
}
location ^~ / {
proxy_pass http://127.0.0.1:9092;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
access_log /www/wwwlogs/souldev.quwanzhi.com.log;
error_log /www/wwwlogs/souldev.quwanzhi.com.error.log;
}