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:
@@ -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": "我的"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,7 +91,7 @@ Page({
|
||||
// 我的余额
|
||||
walletBalanceText: '--',
|
||||
|
||||
// 超级个体:快捷入口「客资 / 链接与轨迹」
|
||||
// 超级个体:快捷入口「链接轨迹」
|
||||
showSuperCrmEntry: false,
|
||||
// 超级个体:发文章入口
|
||||
showSuperArticleEntry: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
miniprogram/pages/super-moments/super-moments.js
Normal file
157
miniprogram/pages/super-moments/super-moments.js
Normal 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 })
|
||||
},
|
||||
})
|
||||
9
miniprogram/pages/super-moments/super-moments.json
Normal file
9
miniprogram/pages/super-moments/super-moments.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"icon": "/components/icon/icon"
|
||||
},
|
||||
"enablePullDownRefresh": false,
|
||||
"onReachBottomDistance": 160,
|
||||
"backgroundTextStyle": "light",
|
||||
"backgroundColor": "#0b1220"
|
||||
}
|
||||
61
miniprogram/pages/super-moments/super-moments.wxml
Normal file
61
miniprogram/pages/super-moments/super-moments.wxml
Normal 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>
|
||||
164
miniprogram/pages/super-moments/super-moments.wxss
Normal file
164
miniprogram/pages/super-moments/super-moments.wxss
Normal 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;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:重启 API(SKIP_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
151
配置.txy
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user