feat: enhance member detail page with dynamic tabs and image handling

- Introduced member business tabs for profile and dynamics, improving navigation and user experience.
- Implemented image parsing and normalization functions to handle article images effectively.
- Updated the UI to display article thumbnails and manage loading states, enhancing visual feedback.
- Refactored data synchronization methods to ensure member business tabs reflect current content accurately.

This update aims to streamline member profile interactions and improve the overall presentation of member-related content.
This commit is contained in:
乘风
2026-05-09 18:15:45 +08:00
parent 99d65dc664
commit 0cc8e2b270
21 changed files with 1013 additions and 249 deletions

View File

@@ -17,8 +17,67 @@ const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 从「我的」登录成功后 reLaunch 回本页并自动继续链接流程 */
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
const THUMB_MAX = 3
function parseImagesField(images) {
if (images == null) return []
if (typeof images === 'string') {
const s = images.trim()
if (!s) return []
try {
const p = JSON.parse(s)
return Array.isArray(p) ? p : []
} catch (_) {
return []
}
}
return Array.isArray(images) ? images : []
}
function normalizeArticleImageSrc(raw) {
const u = String(raw || '').trim()
if (!u || u === 'undefined' || u === 'null') return ''
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return `https:${u}`
if (u.startsWith('wxfile://') || u.startsWith('cloud://')) return u
const base = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '')
if (u.startsWith('/') && base) return `${base}${u}`
return u
}
function buildArticleThumbSlots(imagesField) {
const rawList = parseImagesField(imagesField)
const n = Math.min(rawList.length, THUMB_MAX)
const thumbSlots = []
for (let i = 0; i < n; i++) {
const srcNorm = normalizeArticleImageSrc(String(rawList[i] || '').trim())
const ok =
!!srcNorm &&
(/^https?:\/\//i.test(srcNorm) ||
srcNorm.startsWith('wxfile://') ||
srcNorm.startsWith('cloud://'))
thumbSlots.push({ slotIdx: i, src: ok ? srcNorm : '', loadFailed: false })
}
return {
showThumbRow: rawList.length > 0,
thumbSlots,
thumbMoreCount: rawList.length > THUMB_MAX ? rawList.length - THUMB_MAX : 0,
}
}
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false, auditMode: false, articleList: [], articleLoading: false },
data: {
statusBarHeight: 44,
navBarTotalPx: 88,
member: null,
loading: true,
isOwnProfile: false,
auditMode: false,
articleList: [],
articleLoading: false,
memberBizTab: 'profile',
showMemberBizTabs: false,
},
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
@@ -140,6 +199,7 @@ Page({
}),
loading: false
})
this._syncMemberBizTabs()
this.loadMemberArticles(String(id))
this._scheduleResumeLinkFlowIfNeeded()
return
@@ -153,6 +213,7 @@ Page({
const d = Array.isArray(res.data) ? res.data[0] : res.data
if (d) {
this.setData({ member: this.enrichAndFormat(d), loading: false })
this._syncMemberBizTabs()
this.loadMemberArticles(String(d.id || id))
this._scheduleResumeLinkFlowIfNeeded()
return
@@ -180,6 +241,7 @@ Page({
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
this._syncMemberBizTabs()
this.loadMemberArticles(String(u.id || id))
this._scheduleResumeLinkFlowIfNeeded()
return
@@ -187,6 +249,7 @@ Page({
}
} catch (e) {}
this.setData({ loading: false })
this._syncMemberBizTabs()
this._resumeLinkAfterLoad = false
},
@@ -623,9 +686,69 @@ Page({
if (!id) return
wx.navigateTo({ url: `/pages/super-article-detail/super-article-detail?id=${encodeURIComponent(String(id))}` })
},
/** 有档案块(职业画像/擅长/故事/互助)或动态(含加载中)时展示 Tab 区 */
_syncMemberBizTabs() {
const m = this.data.member
if (!m) {
this.setData({ showMemberBizTabs: false })
return
}
const hasCareer = !!(m.industry || m.position || m.businessScale)
const hasProfileExtra = !!(
m.skills ||
m.bestMonth ||
m.achievement ||
m.turningPoint ||
m.canHelp ||
m.needHelp
)
const profileOk = hasCareer || hasProfileExtra
const hasArticles = (this.data.articleList || []).length > 0
const loading = !!this.data.articleLoading
const show = profileOk || hasArticles || loading
if (!show) {
this.setData({ showMemberBizTabs: false })
return
}
let tab = this.data.memberBizTab || 'profile'
if (tab !== 'profile' && tab !== 'dynamics') tab = 'profile'
const dynamicsOk = hasArticles || loading
if (tab === 'profile' && !profileOk && dynamicsOk) tab = 'dynamics'
else if (tab === 'dynamics' && !dynamicsOk && profileOk) tab = 'profile'
this.setData({ showMemberBizTabs: true, memberBizTab: tab })
},
switchMemberBizTab(e) {
const tab = e.currentTarget?.dataset?.tab
if (tab !== 'dynamics' && tab !== 'profile') return
this.setData({ memberBizTab: tab })
},
onMemberArticleThumbError(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
const slotIdx = Number(e.currentTarget.dataset.slotidx)
if (Number.isNaN(rowIdx) || Number.isNaN(slotIdx)) return
this.setData({ [`articleList[${rowIdx}].thumbSlots[${slotIdx}].loadFailed`]: true })
},
previewMemberArticleImages(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
if (Number.isNaN(rowIdx)) return
const row = this.data.articleList[rowIdx]
if (!row) return
const urls = parseImagesField(row.images)
.map((u) => normalizeArticleImageSrc(String(u || '').trim()))
.filter((u) => /^https?:\/\//i.test(u))
if (!urls.length) return
const current = String(e.currentTarget.dataset.current || '').trim() || urls[0]
wx.previewImage({ urls, current })
},
async loadMemberArticles(memberId) {
if (!memberId) return
this.setData({ articleLoading: true })
this.setData({ articleLoading: true, articleList: [] })
this._syncMemberBizTabs()
try {
const uid = app.globalData.userInfo?.id
const mine = !!(this.data.isOwnProfile && uid && String(memberId) === String(uid))
@@ -646,14 +769,20 @@ Page({
else auditLabel = '已发布'
}
const norm = st === 'pending' || st === 'rejected' ? st : 'approved'
const thumb = buildArticleThumbSlots(it.images)
return Object.assign({}, it, {
auditStatus: mine ? norm : '',
auditLabel,
showThumbRow: thumb.showThumbRow,
thumbSlots: thumb.thumbSlots,
thumbMoreCount: thumb.thumbMoreCount,
})
})
this.setData({ articleList: list, articleLoading: false })
this._syncMemberBizTabs()
} catch (_) {
this.setData({ articleLoading: false, articleList: [] })
this._syncMemberBizTabs()
}
},
goBack() { getApp().goBackOrToHome() },

View File

@@ -110,112 +110,163 @@
</view>
</view>
<!-- 超级个体文章仅当有公开文章时展示整块0 篇不显示 TA文章 Tab 卡片 -->
<view class="article-spotlight" wx:if="{{articleList.length > 0}}">
<view class="article-spotlight-head">
<text class="article-spotlight-title">{{isOwnProfile ? '我的文章' : 'TA文章'}}</text>
<text class="article-spotlight-count">{{articleList.length}} 篇</text>
</view>
<view class="article-spotlight-list">
<view class="article-spotlight-item" wx:for="{{articleList}}" wx:key="id" data-id="{{item.id}}" bindtap="goToSuperArticleDetail">
<view wx:if="{{isOwnProfile && item.auditStatus}}" class="article-audit-pill article-audit-{{item.auditStatus}}">{{item.auditLabel}}</view>
<text class="article-spotlight-item-title">{{item.title}}</text>
<text class="article-spotlight-item-preview">{{item.preview || item.content}}</text>
<!-- 档案 | 动态Tab档案含职业画像/擅长/故事/互助 -->
<view class="article-spotlight member-biz-tab-card" wx:if="{{showMemberBizTabs}}">
<view class="mdb-tab-bar">
<view
class="mdb-tab {{memberBizTab === 'profile' ? 'mdb-tab-active' : ''}}"
data-tab="profile"
bindtap="switchMemberBizTab"
>
<text>{{isOwnProfile ? '我的档案' : 'TA档案'}}</text>
</view>
<view
class="mdb-tab {{memberBizTab === 'dynamics' ? 'mdb-tab-active' : ''}}"
data-tab="dynamics"
bindtap="switchMemberBizTab"
>
<text>{{isOwnProfile ? '我的动态' : 'TA动态'}}</text>
<text class="mdb-tab-badge" wx:if="{{articleList.length}}">{{articleList.length}}</text>
</view>
</view>
</view>
<!-- 一体化信息区(单卡片内分区) -->
<view class="mono-card mono-card-compact" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp || member.project}}">
<!-- 职业画像 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.industry || member.position || member.businessScale}}">
<view class="mono-sec-head mono-sec-head-tight">
<view class="mono-sec-title-row">
<!-- 档案:职业画像 + 我擅长 + 个人故事 + 互助需求 -->
<view class="mdb-panel mdb-panel-profile" wx:if="{{memberBizTab === 'profile'}}">
<view class="mono-sec mono-sec-tight" wx:if="{{member.industry || member.position || member.businessScale}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">职业画像</text>
<view class="member-article-tag" wx:if="{{articleList.length > 0}}">
<text>TA文章</text>
</view>
<view class="kv-grid">
<view class="kv-cell" wx:if="{{member.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{member.industry}}</text>
</view>
<view class="kv-cell" wx:if="{{member.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{member.position}}</text>
</view>
<view class="kv-cell kv-cell-full" wx:if="{{member.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{member.businessScale}}</text>
</view>
</view>
</view>
<view class="kv-grid">
<view class="kv-cell" wx:if="{{member.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{member.industry}}</text>
<view
class="mono-divider mdb-inner-divider"
wx:if="{{(member.industry || member.position || member.businessScale) && (member.skills || member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp)}}"
></view>
<view class="mono-sec mono-sec-tight skills-showcase" wx:if="{{member.skills}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="kv-cell" wx:if="{{member.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{member.position}}</text>
<view class="skills-quote">
<text class="skills-quote-text">{{member.skills}}</text>
</view>
<view class="kv-cell kv-cell-full" wx:if="{{member.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{member.businessScale}}</text>
</view>
<view
class="mono-divider mdb-inner-divider"
wx:if="{{member.skills && (member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp)}}"
></view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story story-compact" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{member.bestMonth}}</text>
</view>
<view class="story-gap story-gap-tight" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view>
<view class="story story-compact" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{member.achievement}}</text>
</view>
<view class="story-gap story-gap-tight" wx:if="{{member.achievement && member.turningPoint}}"></view>
<view class="story story-compact" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{member.turningPoint}}</text>
</view>
</view>
<view class="mono-divider mdb-inner-divider" wx:if="{{(member.bestMonth || member.achievement || member.turningPoint) && (member.canHelp || member.needHelp)}}"></view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.canHelp || member.needHelp}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">互动需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{member.canHelp}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{member.canHelp}}</text>
</view>
<view class="help-tile help-need" wx:if="{{member.needHelp}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{member.needHelp}}</text>
</view>
</view>
</view>
<view
wx:if="{{!(member.industry || member.position || member.businessScale) && !member.skills && !(member.bestMonth || member.achievement || member.turningPoint) && !(member.canHelp || member.needHelp)}}"
class="article-spotlight-empty mdb-panel-hint"
>
<text>暂未填写档案资料</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.industry || member.position || member.businessScale) && member.skills}}"></view>
<!-- 核心能力 -->
<view class="mono-sec mono-sec-tight skills-showcase" wx:if="{{member.skills}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="skills-quote">
<text class="skills-quote-text">{{member.skills}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{member.skills && (member.bestMonth || member.achievement || member.turningPoint)}}"></view>
<!-- 个人故事 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story story-compact" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{member.bestMonth}}</text>
</view>
<view class="story-gap story-gap-tight" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view>
<view class="story story-compact" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{member.achievement}}</text>
</view>
<view class="story-gap story-gap-tight" wx:if="{{member.achievement && member.turningPoint}}"></view>
<view class="story story-compact" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{member.turningPoint}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.bestMonth || member.achievement || member.turningPoint) && (member.canHelp || member.needHelp)}}"></view>
<!-- 互助 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.canHelp || member.needHelp}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">互助需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{member.canHelp}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{member.canHelp}}</text>
</view>
<view class="help-tile help-need" wx:if="{{member.needHelp}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{member.needHelp}}</text>
<view class="mdb-panel" wx:if="{{memberBizTab === 'dynamics'}}">
<view wx:if="{{articleLoading}}" class="article-spotlight-empty mdb-panel-hint"><text>加载中…</text></view>
<view wx:elif="{{articleList.length > 0}}" class="article-spotlight-list">
<view
class="article-spotlight-item"
wx:for="{{articleList}}"
wx:for-index="rowIdx"
wx:key="id"
data-id="{{item.id}}"
bindtap="goToSuperArticleDetail"
>
<view wx:if="{{isOwnProfile && item.auditStatus}}" class="article-audit-pill article-audit-{{item.auditStatus}}">{{item.auditLabel}}</view>
<text class="article-spotlight-item-title">{{item.title}}</text>
<text class="article-spotlight-item-preview">{{item.preview || item.content}}</text>
<view class="mdb-thumb-row" wx:if="{{item.showThumbRow}}" catchtap="">
<view class="mdb-thumb-cell" wx:for="{{item.thumbSlots}}" wx:for-item="th" wx:key="slotIdx">
<block wx:if="{{th.src && !th.loadFailed}}">
<image
class="mdb-thumb-img"
mode="aspectFill"
src="{{th.src}}"
data-rowidx="{{rowIdx}}"
data-slotidx="{{index}}"
data-current="{{th.src}}"
binderror="onMemberArticleThumbError"
bindtap="previewMemberArticleImages"
/>
</block>
<view wx:else class="mdb-thumb-ph">
<text class="mdb-thumb-ph-text">{{th.loadFailed ? '加载失败' : '无效'}}</text>
</view>
</view>
<view wx:if="{{item.thumbMoreCount > 0}}" class="mdb-thumb-more">
<text>+{{item.thumbMoreCount}}</text>
</view>
</view>
</view>
</view>
<view wx:else class="article-spotlight-empty mdb-panel-hint"><text>暂无动态</text></view>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.canHelp || member.needHelp) && member.project}}"></view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.project}}">
<!-- 项目介绍(单独卡片) -->
<view class="mono-card mono-card-compact" wx:if="{{member.project}}">
<view class="mono-sec mono-sec-tight">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">项目介绍</text>
</view>
<text class="proj-body proj-body-compact">{{member.project}}</text>
</view>
</view>
<!-- 底部:分享 + 双入口(同一视觉块) -->

View File

@@ -451,6 +451,96 @@
border: 1rpx solid rgba(103, 232, 249, 0.45);
background: rgba(14, 116, 144, 0.35);
}
/* Tab动态 | 职业画像 */
.member-biz-tab-card {
padding-top: 18rpx;
}
.mdb-tab-bar {
display: flex;
flex-direction: row;
gap: 12rpx;
margin-bottom: 18rpx;
padding: 0 4rpx;
}
.mdb-tab {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 16rpx 12rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 600;
color: rgba(203, 213, 225, 0.75);
background: rgba(2, 6, 23, 0.35);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.mdb-tab-active {
color: #67e8f9;
border-color: rgba(103, 232, 249, 0.45);
background: rgba(14, 116, 144, 0.28);
}
.mdb-tab-badge {
padding: 4rpx 12rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
color: #cffafe;
border: 1rpx solid rgba(103, 232, 249, 0.35);
background: rgba(14, 116, 144, 0.35);
}
.mdb-panel-hint {
padding-top: 8rpx;
}
.mdb-inner-divider {
margin: 14rpx 4rpx;
}
.mdb-thumb-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 14rpx;
}
.mdb-thumb-cell {
width: 144rpx;
height: 144rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
background: rgba(15, 23, 42, 0.55);
}
.mdb-thumb-img {
width: 100%;
height: 100%;
display: block;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
}
.mdb-thumb-ph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed rgba(148, 163, 184, 0.35);
box-sizing: border-box;
}
.mdb-thumb-ph-text {
font-size: 20rpx;
color: #64748b;
}
.mdb-thumb-more {
font-size: 26rpx;
font-weight: 600;
color: rgba(34, 211, 238, 0.88);
padding: 0 12rpx;
}
.article-spotlight-list {
display: flex;
flex-direction: column;

View File

@@ -93,7 +93,7 @@ Page({
// 超级个体:快捷入口「链接轨迹」
showSuperCrmEntry: false,
// 超级个体:发文章入口
// 超级个体:发动态入口
showSuperArticleEntry: false,
/** 快捷入口网格列数:与 WXML 四个 wx:if 一致≤4 时等于入口数(三项铺满)、否则为 4自动换行 */
@@ -165,9 +165,9 @@ Page({
const showSuperCrmEntry = !!this.data.showSuperCrmEntry
const showSuperArticleEntry = !!this.data.showSuperArticleEntry
let n = 1
if (!auditMode) n += 2
if (!auditMode) n += 1
if (showSuperCrmEntry) n += 1
if (showSuperArticleEntry && !auditMode) n += 2
if (showSuperArticleEntry && !auditMode) n += 1
const quickEntryGridCols = n <= 4 ? n : 4
if (this.data.quickEntryGridCols !== quickEntryGridCols) {
this.setData({ quickEntryGridCols })
@@ -894,7 +894,7 @@ Page({
return
}
const auditBlockedIds = { giftPay: true, wallet: true, withdrawRecords: true, superArticle: true, superArticleMine: true }
const auditBlockedIds = { giftPay: true, wallet: true, withdrawRecords: true, superArticleMine: true }
if (app.globalData.auditMode && auditBlockedIds[id]) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
@@ -902,12 +902,11 @@ Page({
const routes = {
orders: '/pages/purchases/purchases',
giftPay: '/pages/gift-pay/list',
giftPay: '/pages/purchases/purchases',
referral: '/pages/referral/referral',
withdrawRecords: '/pages/withdraw-records/withdraw-records',
wallet: '/pages/wallet/wallet',
superCrm: '/pages/super-crm/super-crm',
superArticle: '/pages/super-article-editor/super-article-editor',
superArticleMine: '/pages/super-article-mine/super-article-mine',
settings: '/pages/settings/settings'
}

View File

@@ -89,7 +89,7 @@
</view>
</view>
<!-- 快捷入口:我的订单 + 我的代付 -->
<!-- 快捷入口:订单与代付(合并列表) -->
<view class="card stats-card">
<view class="card-header">
<image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
@@ -100,13 +100,7 @@
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
</view>
<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-label">我的代付</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">
@@ -120,12 +114,6 @@
</view>
<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-label">发布文章</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{showSuperArticleEntry && !auditMode}}" bindtap="handleMenuTap" data-id="superArticleMine">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
@@ -135,7 +123,7 @@
</view>
</view>
<!-- 已解锁/充值/代付等流水已迁至「我的订单」页 -->
<!-- 已解锁/充值/代付等流水在「订单与代付」统一列表 -->
<!-- 最近阅读 -->
<view class="card recent-card">

View File

@@ -1,5 +1,5 @@
/**
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等
* Soul创业派对 - 订单与代付(消费流水 + 我发起的代付,按时间合并
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
@@ -25,6 +25,46 @@ function midForSection(sectionId, bookFlat) {
return row?.mid ?? row?.MID ?? 0
}
function giftPayStatusLabel(status) {
const s = String(status || '').toLowerCase()
if (s === 'pending' || s === 'pending_pay') return '待支付'
if (s === 'paid') return '已支付'
if (s === 'refunded') return '已退款'
if (s === 'cancelled') return '已取消'
if (s === 'expired') return '已过期'
return String(status || '').trim() || '--'
}
function parseIsoMs(iso) {
const t = new Date(iso || 0).getTime()
return Number.isFinite(t) ? t : 0
}
function mapGiftPayToRow(item) {
const requestSn = String(item.requestSn || '').trim()
if (!requestSn) return null
const t = parseIsoMs(item.createdAt)
const amt = Number(item.amount)
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
const titleRaw = cleanSingleLineField(String(item.description || '').trim())
const title = titleRaw || '代付'
const st = String(item.status || '').toLowerCase()
const stLabel = giftPayStatusLabel(item.status)
const gpPending = st === 'pending' || st === 'pending_pay'
const gpPaid = st === 'paid'
return {
rowKey: `gp_${requestSn}`,
kind: 'giftPay',
requestSn,
title,
subLine: `¥${amountStr} · ${stLabel} · ${formatShortDate(t)}`,
actionLabel: '查看',
gpPending,
gpPaid,
_sortMs: t,
}
}
function classifyNav(productType, productId, mid) {
const pt = String(productType || '').toLowerCase()
if (pt === 'section' && productId) {
@@ -43,7 +83,7 @@ function classifyNav(productType, productId, mid) {
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
}
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
return { kind: 'page', path: '/pages/purchases/purchases', label: '查看' }
}
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
@@ -72,6 +112,7 @@ function mapApiOrderToRow(item, bookFlat) {
return {
rowKey: String(item.order_sn || item.id || `o_${t}`),
kind: 'order',
title,
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
actionLabel: nav.label,
@@ -120,40 +161,55 @@ Page({
const userId = app.globalData.userInfo?.id
try {
let orderRows = []
if (userId) {
const res = await app.request({
const orderRes = await app.request({
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (res && res.success && Array.isArray(res.data)) {
const rows = res.data
.map((item) => mapApiOrderToRow(item, bookFlat))
.filter(Boolean)
.sort((a, b) => b._sortMs - a._sortMs)
.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
return
silent: true,
}).catch(() => null)
if (orderRes && orderRes.success && Array.isArray(orderRes.data)) {
orderRows = orderRes.data.map((item) => mapApiOrderToRow(item, bookFlat)).filter(Boolean)
}
}
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const rows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' }
if (!orderRows.length) {
const ids = [...(app.globalData.purchasedSections || [])].reverse()
orderRows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
kind: 'order',
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' },
_sortMs: t,
}
})
}
let giftRows = []
if (userId && !app.globalData.auditMode) {
const giftRes = await app
.request(`/api/miniprogram/gift-pay/my-requests?userId=${encodeURIComponent(userId)}`, {
silent: true,
})
.catch(() => null)
if (giftRes && giftRes.success && Array.isArray(giftRes.list)) {
giftRows = giftRes.list.map(mapGiftPayToRow).filter(Boolean)
}
})
this.setData({ allRows: rows, loading: false })
}
const merged = [...orderRows, ...giftRows].sort((a, b) => b._sortMs - a._sortMs)
const stripped = merged.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: stripped, loading: false })
this.applyDisplay(false)
} catch (e) {
console.error('加载订单失败:', e)
@@ -162,10 +218,17 @@ Page({
}
},
onOrderRowTap(e) {
onUnifiedRowTap(e) {
const index = e.currentTarget.dataset.index
const row = (this.data.displayRows || [])[index]
if (!row || !row.nav) return
if (!row) return
if (row.kind === 'giftPay' && row.requestSn) {
wx.navigateTo({
url: `/pages/gift-pay/redemption-detail?requestSn=${encodeURIComponent(String(row.requestSn))}`,
})
return
}
if (!row.nav) return
const { nav } = row
if (nav.kind === 'read' && nav.id) {
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
@@ -174,7 +237,7 @@ Page({
}
if (nav.kind === 'page' && nav.path) {
if (app.globalData.auditMode) {
const block = { '/pages/vip/vip': true, '/pages/wallet/wallet': true, '/pages/gift-pay/list': true }
const block = { '/pages/vip/vip': true, '/pages/wallet/wallet': true }
if (block[nav.path]) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
@@ -188,6 +251,40 @@ Page({
}
},
async onGiftPayCancel(e) {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
const requestSn = e?.currentTarget?.dataset?.sn
if (!requestSn) return
const ok = await new Promise((r) => {
wx.showModal({ title: '取消代付', content: '确定取消该代付请求?', success: (res) => r(res.confirm) })
})
if (!ok) return
try {
const res = await app.request({
url: '/api/miniprogram/gift-pay/cancel',
method: 'POST',
data: { requestSn, userId: app.globalData.userInfo?.id },
})
if (res && res.success) {
wx.showToast({ title: '已取消', icon: 'success' })
this.loadOrders()
} else {
wx.showToast({ title: res?.error || '取消失败', icon: 'none' })
}
} catch (_) {
wx.showToast({ title: '取消失败', icon: 'none' })
}
},
onGiftPayShare(e) {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
const requestSn = e?.currentTarget?.dataset?.sn
if (!requestSn) return
wx.navigateTo({
url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(String(requestSn))}`,
})
},
goBack() {
getApp().goBackOrToHome()
},
@@ -195,13 +292,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: '卡若创业派对 - 购买记录',
title: '卡若创业派对 - 订单与代付',
path: ref ? `/pages/purchases/purchases?ref=${ref}` : '/pages/purchases/purchases'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: '卡若创业派对 - 购买记录', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 订单与代付', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -2,7 +2,7 @@
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
<text class="nav-title">我的订单</text>
<text class="nav-title">订单与代付</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
@@ -19,21 +19,23 @@
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="oh-list">
<view
class="oh-row"
wx:for="{{displayRows}}"
wx:key="rowKey"
bindtap="onOrderRowTap"
data-index="{{index}}"
>
<view class="oh-left">
<text class="oh-index">{{index + 1}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
<view class="oh-block" wx:for="{{displayRows}}" wx:key="rowKey">
<view class="oh-row" bindtap="onUnifiedRowTap" data-index="{{index}}">
<view class="oh-left">
<text class="oh-kind {{item.kind === 'giftPay' ? 'oh-kind-gift' : 'oh-kind-order'}}">{{item.kind === 'giftPay' ? '代付' : '订单'}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
</view>
</view>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
<view class="oh-gift-tools" wx:if="{{item.kind === 'giftPay' && item.gpPending}}" catchtap="">
<text class="oh-tool oh-tool-cancel" catchtap="onGiftPayCancel" data-sn="{{item.requestSn}}">取消</text>
</view>
<view class="oh-gift-tools" wx:if="{{item.kind === 'giftPay' && item.gpPaid}}" catchtap="">
<text class="oh-tool" catchtap="onGiftPayShare" data-sn="{{item.requestSn}}">分享</text>
</view>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
</view>
<view
@@ -49,7 +51,7 @@
<view class="empty" wx:else>
<icon name="package" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
<text class="empty-text">暂无订单</text>
<text class="empty-text">暂无订单与代付记录</text>
</view>
</view>
</view>

View File

@@ -10,15 +10,54 @@
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
.oh-list { display: flex; flex-direction: column; gap: 0; }
.oh-block {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 16rpx;
}
.oh-block:last-child {
margin-bottom: 0;
}
.oh-row {
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.oh-row:active { opacity: 0.92; }
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
.oh-left { display: flex; align-items: flex-start; gap: 16rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; padding-top: 4rpx; }
.oh-kind {
font-size: 20rpx;
padding: 6rpx 14rpx;
border-radius: 10rpx;
flex-shrink: 0;
font-weight: 600;
}
.oh-kind-order {
color: rgba(0, 206, 209, 0.92);
border: 1rpx solid rgba(0, 206, 209, 0.35);
background: rgba(0, 206, 209, 0.08);
}
.oh-kind-gift {
color: #fcd34d;
border: 1rpx solid rgba(252, 211, 77, 0.35);
background: rgba(252, 211, 77, 0.08);
}
.oh-gift-tools {
padding: 0 8rpx 4rpx 8rpx;
display: flex;
justify-content: flex-end;
gap: 32rpx;
}
.oh-tool {
font-size: 24rpx;
color: #00CED1;
font-weight: 500;
}
.oh-tool-cancel {
color: rgba(248, 113, 113, 0.95);
}
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }

View File

@@ -179,7 +179,7 @@ Page({
const a = this.data.article
const id = this.data.articleId
return {
title: a && a.title ? a.title : '文章',
title: a && a.title ? a.title : '动态',
path: id
? `/pages/super-article-detail/super-article-detail?id=${encodeURIComponent(String(id))}`
: '/pages/super-article-detail/super-article-detail',
@@ -189,7 +189,7 @@ Page({
const a = this.data.article
const id = this.data.articleId
return {
title: a && a.title ? a.title : '文章',
title: a && a.title ? a.title : '动态',
query: id ? `id=${encodeURIComponent(String(id))}` : '',
}
},

View File

@@ -3,7 +3,7 @@
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.85)"></icon>
</view>
<text class="nav-title">文章详情</text>
<text class="nav-title">动态详情</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>

View File

@@ -54,6 +54,8 @@
width: 100%;
display: block;
border-radius: 12rpx;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
background: rgba(15, 23, 42, 0.6);
}
.gallery-placeholder {

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 超级个体发文章AI 生成 + 手动编辑发布
* Soul创业派对 - 超级个体发动态AI 辅助 + 朋友圈式编辑
*/
const app = getApp()
const { uploadByApi } = require('../../utils/miniprogramUpload')
@@ -76,7 +76,7 @@ Page({
silent: true,
})
if (!(res?.success && res.data?.isSuperIndividual === true)) {
wx.showToast({ title: '仅超级个体可发文章', icon: 'none' })
wx.showToast({ title: '仅超级个体可发动态', icon: 'none' })
setTimeout(() => wx.navigateBack(), 400)
return
}
@@ -111,7 +111,7 @@ Page({
const bodyImages = imgs.map((url) => ({ id: newAttachId(), url, uploading: false }))
this.setData({
editArticleId: String(id),
title: String(article.title || '').trim(),
title: '',
content: String(article.content || ''),
bodyImages,
})
@@ -129,10 +129,6 @@ Page({
this.setData({ aiPrompt: e.detail.value || '' })
},
onTitleInput(e) {
this.setData({ title: e.detail.value || '' })
},
onContentInput(e) {
this.setData({ content: e.detail.value || '' })
},
@@ -470,9 +466,12 @@ Page({
if (!res?.success) throw new Error(res?.error || '生成失败')
const title = String(res.data?.title || '').trim()
const content = String(res.data?.content || '').trim()
if (!title || !content) throw new Error('生成结果不完整')
this.setData({ title, content })
wx.showToast({ title: '已填入标题和正文', icon: 'success' })
let merged = content
if (title && merged) merged = `${title}\n\n${merged}`
else if (title) merged = title
if (!merged.trim()) throw new Error('生成结果不完整')
this.setData({ title: '', content: merged.trim() })
wx.showToast({ title: '已填入正文', icon: 'success' })
} catch (e) {
wx.showToast({
title: (e && e.message) || '生成失败',
@@ -488,12 +487,11 @@ Page({
if (this.data.auditMode || app.globalData.auditMode) return
if (this.data.saving) return
const userId = app.globalData.userInfo?.id
const title = String(this.data.title || '').trim()
const content = String(this.data.content || '').trim()
if (!title || !content) {
wx.showToast({ title: '请填写标题和正文', icon: 'none' })
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const content = String(this.data.content || '').trim()
if (this.data.bodyImages.some((x) => x.uploading)) {
wx.showToast({ title: '请等待配图上传完成', icon: 'none' })
return
@@ -501,6 +499,10 @@ Page({
const images = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
if (!content && images.length === 0) {
wx.showToast({ title: '请填写文字或添加配图', icon: 'none' })
return
}
const editId = String(this.data.editArticleId || '').trim()
this.setData({ saving: true })
wx.showLoading({ title: editId ? '提交中…' : '发布中…', mask: true })
@@ -510,13 +512,13 @@ Page({
res = await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(editId)}`,
method: 'PUT',
data: { userId, title, content, images },
data: { userId, title: '', content, images },
})
} else {
res = await app.request({
url: '/api/miniprogram/super/articles',
method: 'POST',
data: { userId, title, content, images },
data: { userId, title: '', content, images },
})
}
if (!res?.success) throw new Error(res?.error || '发布失败')

View File

@@ -3,14 +3,14 @@
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.85)"></icon>
</view>
<text class="nav-title">{{editArticleId ? '编辑动态' : '发文章'}}</text>
<text class="nav-title">{{editArticleId ? '编辑动态' : '发动态'}}</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- AI参考图片 + 提示词开关showAiWriteSection -->
<view wx:if="{{showAiWriteSection}}" class="card">
<text class="section-title">AI 写文章</text>
<text class="section-title">AI 帮你写</text>
<text class="label">参考图片(可多选)</text>
<view class="file-actions">
<view class="add-file-btn" bindtap="chooseReferenceImages"> 添加图片</view>
@@ -29,43 +29,49 @@
<text class="label">提示词</text>
<view class="textarea-wrap">
<textarea class="textarea prompt-textarea" maxlength="2000" placeholder="描述主题、受众、语气、必须提到的要点…(可与文件二选一或组合)" value="{{aiPrompt}}" bindinput="onAiPromptInput" disabled="{{auditMode}}"></textarea>
<textarea class="textarea prompt-textarea" maxlength="2000" placeholder="描述主题、语气、要点…(可与参考图组合)" value="{{aiPrompt}}" bindinput="onAiPromptInput" disabled="{{auditMode}}"></textarea>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">{{generating ? '生成中…' : '生成文章'}}</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">{{generating ? '生成中…' : '生成'}}</view>
</view>
<view class="card">
<text class="section-title">编辑与发布</text>
<text class="label">标题</text>
<view class="input-wrap">
<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="card moment-card">
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
<view class="tool-btn" bindtap="insertHashLink">#链接</view>
<view class="tool-btn" bindtap="insertHashLink">#链接</view>
</view>
<view class="textarea-wrap">
<textarea class="textarea" maxlength="5000" placeholder="输入正文内容…" value="{{content}}" bindinput="onContentInput" disabled="{{auditMode}}"></textarea>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="这一刻的想法…"
value="{{content}}"
bindinput="onContentInput"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
<text class="hint">提示:可直接输入 @昵称 或 #链接(https://xxx)</text>
<view class="submit-btn {{saving ? 'submit-btn-loading' : ''}}" bindtap="submitArticle">{{saving ? (editArticleId ? '提交中…' : '发布中…') : (editArticleId ? '提交审核' : '发布文章')}}</view>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint moment-hint">配图 {{bodyImages.length}}/9 · 展示在文字下方 · 单张建议不超 30MB · 可直接输入 @昵称 或 #链接(https://xxx)</text>
</view>
<!-- 底部发布(避让微信胶囊) -->
<view wx:if="{{!auditMode}}" class="bottom-publish-bar">
<view class="bottom-publish-btn {{saving ? 'bottom-publish-btn-disabled' : ''}}" bindtap="submitArticle">
<text>{{saving ? (editArticleId ? '提交中…' : '发布中…') : (editArticleId ? '提交审核' : '发布')}}</text>
</view>
</view>
<view wx:if="{{auditMode}}" class="audit-mode-mask" catchtouchmove="preventMove">

View File

@@ -1,11 +1,46 @@
.page { min-height: 100vh; background: #0b1220; color: #fff; }
.page {
min-height: 100vh;
background: #0b1220;
color: #fff;
box-sizing: border-box;
padding-bottom: calc(168rpx + env(safe-area-inset-bottom));
}
.nav-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 24rpx; background: rgba(5, 11, 20, 0.9);
}
.nav-back, .nav-placeholder { width: 64rpx; }
.nav-title { font-size: 32rpx; font-weight: 700; }
.nav-back { width: 64rpx; flex-shrink: 0; display: flex; align-items: center; }
.nav-placeholder { width: 64rpx; flex-shrink: 0; }
.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; }
.bottom-publish-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(11, 18, 32, 0.96);
border-top: 1rpx solid rgba(94, 234, 212, 0.12);
}
.bottom-publish-btn {
height: 92rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.bottom-publish-btn-disabled {
opacity: 0.65;
pointer-events: none;
}
.card {
margin: 24rpx; padding: 28rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
@@ -102,13 +137,95 @@
background: transparent;
}
.hint { display: block; margin-top: 10rpx; color: #94a3b8; font-size: 22rpx; }
.submit-btn {
margin-top: 24rpx; height: 88rpx; border-radius: 16rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35; font-size: 30rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
/* 发动态 · 配图宫格(参考朋友圈) */
.moment-card .toolbar {
margin-top: 0;
}
.moment-textarea-wrap {
margin-top: 8rpx;
padding: 20rpx 22rpx;
}
.moment-textarea {
min-height: 240rpx;
width: 100%;
font-size: 30rpx;
line-height: 1.65;
}
.moment-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 20rpx;
}
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 12rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-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);
z-index: 1;
}
.moment-photo-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;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
.moment-hint {
margin-top: 16rpx;
}
.submit-btn-loading { opacity: 0.72; pointer-events: none; }
/* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */
.audit-mode-mask {

View File

@@ -94,6 +94,7 @@ function mapRows(rows) {
Page({
data: {
statusBarHeight: 44,
auditMode: false,
list: [],
page: 1,
total: 0,
@@ -103,10 +104,28 @@ Page({
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
auditMode: !!app.globalData.auditMode,
})
try {
app.getAuditMode && app.getAuditMode().catch(() => {})
} catch (_) {}
this.reload()
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
goCompose() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/super-article-editor/super-article-editor' })
},
onReachBottom() {
this.loadMore()
},
@@ -127,6 +146,47 @@ Page({
wx.navigateTo({ url: `/pages/super-article-editor/super-article-editor?id=${encodeURIComponent(String(id))}` })
},
tapDeleteArticle(e) {
const id = e.currentTarget?.dataset?.id
if (!id || this._articleDeleting) return
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
wx.showModal({
title: '删除动态',
content: '删除后不可恢复,确定删除?',
confirmText: '删除',
confirmColor: '#dc2626',
success: async (r) => {
if (!r.confirm) return
this._articleDeleting = true
wx.showLoading({ title: '删除中…', mask: true })
try {
await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(String(id))}?userId=${encodeURIComponent(String(userId))}`,
method: 'DELETE',
})
wx.hideLoading()
this._articleDeleting = false
const list = this.data.list.filter((x) => String(x.id) !== String(id))
const total = Math.max(0, (Number(this.data.total) || 0) - 1)
this.setData({
list,
total,
hasMore: list.length < total,
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch (_) {
wx.hideLoading()
this._articleDeleting = false
/* app.request 已 toast */
}
},
})
},
onThumbError(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
const slotIdx = Number(e.currentTarget.dataset.slotidx)

View File

@@ -8,10 +8,21 @@
</view>
<view class="nav-offset" style="height: {{statusBarHeight + 44}}px;"></view>
<view class="feed-wrap">
<view
class="compose-bar-fixed"
wx:if="{{!auditMode}}"
style="top: calc({{statusBarHeight}}px + 44px);"
>
<view class="compose-bar-btn" bindtap="goCompose">
<text class="compose-bar-plus"></text>
<text>发动态</text>
</view>
</view>
<view class="feed-wrap {{!auditMode ? 'feed-wrap-has-compose' : ''}}">
<view class="empty-block" wx:if="{{!loading && list.length === 0}}">
<text class="empty-text">暂无动态</text>
<text class="empty-sub">去「发布文章」写一条吧</text>
<text class="empty-sub">点击上方「发动态」写一条吧</text>
</view>
<block wx:for="{{list}}" wx:for-index="rowIdx" wx:key="id">
@@ -58,6 +69,7 @@
data-id="{{item.id}}"
bindtap="openEdit"
>编辑</view>
<view class="feed-btn danger" data-id="{{item.id}}" bindtap="tapDeleteArticle">删除</view>
</view>
</view>
</block>

View File

@@ -41,10 +41,45 @@
width: 100%;
}
.compose-bar-fixed {
position: fixed;
left: 0;
right: 0;
z-index: 99;
padding: 10px 14px 12px;
background: rgba(10, 10, 12, 0.96);
border-bottom: 1px solid rgba(34, 211, 238, 0.12);
box-sizing: border-box;
}
.compose-bar-btn {
height: 44px;
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0a0a0c;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
}
.compose-bar-plus {
font-size: 18px;
font-weight: 600;
line-height: 1;
}
.feed-wrap {
padding: 12px 14px 28px;
}
.feed-wrap-has-compose {
padding-top: 72px;
}
.empty-block {
padding: 48px 12px;
text-align: center;
@@ -143,6 +178,8 @@
width: 100%;
height: 100%;
display: block;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
}
.thumb-ph {
@@ -213,6 +250,11 @@
font-weight: 600;
}
.feed-btn.danger {
color: rgba(248, 113, 113, 0.95);
border: 1px solid rgba(248, 113, 113, 0.45);
}
.load-more {
text-align: center;
padding: 12px;

View File

@@ -28,16 +28,53 @@ function relativeTime(iso) {
return `${m}-${day}`
}
function parseImagesField(images) {
if (images == null) return []
if (typeof images === 'string') {
const s = images.trim()
if (!s) return []
try {
const p = JSON.parse(s)
return Array.isArray(p) ? p : []
} catch (_) {
return []
}
}
return Array.isArray(images) ? images : []
}
/** 相对路径配图拼 API 域名,否则真机不显示 */
function normalizeMediaSrc(raw) {
const u = String(raw || '').trim()
if (!u || u === 'undefined' || u === 'null') return ''
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return `https:${u}`
if (u.startsWith('wxfile://') || u.startsWith('cloud://')) return u
const base = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '')
if (u.startsWith('/') && base) return `${base}${u}`
return u
}
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))
const avRaw = String(r.authorAvatar || '').trim()
const av = normalizeMediaSrc(avRaw)
const avatarOk =
!!av &&
(/^https?:\/\//i.test(av) || av.startsWith('wxfile://') || av.startsWith('cloud://')) &&
isSafeImageSrc(av)
const imgs = parseImagesField(r.images)
const displayImages = imgs
.map((u) => normalizeMediaSrc(String(u || '').trim()))
.filter(
(u) =>
!!u &&
(/^https?:\/\//i.test(u) || u.startsWith('wxfile://') || u.startsWith('cloud://')),
)
return Object.assign({}, r, {
authorNickname: nick,
avatarSrc: av,
avatarSrc: avatarOk ? av : avRaw,
avatarOk,
avatarLetter: avatarLetter(nick),
timeText: relativeTime(r.createdAt),

View File

@@ -130,12 +130,16 @@
.feed-img {
width: calc((100% - 20rpx) / 3);
height: 200rpx;
border-radius: 0;
border-radius: 10rpx;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
background: rgba(15, 23, 42, 0.45);
}
.feed-img-one {
width: 100%;
height: 320rpx;
border-radius: 14rpx;
}
.feed-foot {

View File

@@ -164,6 +164,53 @@ func MiniprogramSuperArticleFeed(c *gin.Context) {
})
}
func deriveSuperArticleTitleFromContent(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
parts := strings.SplitN(content, "\n", 2)
line := strings.TrimSpace(parts[0])
line = strings.TrimRight(line, "\r")
if line == "" {
return ""
}
rs := []rune(line)
if len(rs) > 40 {
return string(rs[:40])
}
return line
}
// finalizeSuperArticleTitleAndBody 发动态:标题可省略(由正文首行或默认文案补齐);正文与配图至少一项。
func finalizeSuperArticleTitleAndBody(title, content string, imageCount int) (outTitle, outBody, errMsg string) {
title = strings.TrimSpace(title)
content = strings.TrimSpace(content)
if content == "" && imageCount == 0 {
return "", "", "请填写文字或添加配图"
}
if len([]rune(content)) > 5000 {
return "", "", "正文最多 5000 字"
}
outTitle = title
if outTitle != "" && len([]rune(outTitle)) > 40 {
outTitle = string([]rune(outTitle)[:40])
}
if outTitle == "" {
outTitle = deriveSuperArticleTitleFromContent(content)
}
if outTitle == "" && imageCount > 0 {
outTitle = "分享图片"
}
if outTitle == "" {
outTitle = "动态"
}
if len([]rune(outTitle)) > 40 {
outTitle = string([]rune(outTitle)[:40])
}
return outTitle, content, ""
}
func normalizeSuperArticleImageURLs(in []string) ([]string, string) {
if len(in) == 0 {
return []string{}, ""
@@ -208,16 +255,8 @@ func MiniprogramSuperArticleCreate(c *gin.Context) {
req.UserID = strings.TrimSpace(req.UserID)
req.Title = strings.TrimSpace(req.Title)
req.Content = strings.TrimSpace(req.Content)
if req.UserID == "" || req.Title == "" || req.Content == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题和正文不能为空"})
return
}
if len([]rune(req.Title)) > 40 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题最多 40 字"})
return
}
if len([]rune(req.Content)) > 5000 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "正文最多 5000 字"})
if req.UserID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
@@ -232,6 +271,13 @@ func MiniprogramSuperArticleCreate(c *gin.Context) {
return
}
title, body, verr := finalizeSuperArticleTitleAndBody(req.Title, req.Content, len(imgURLs))
if verr != "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": verr})
return
}
req.Title, req.Content = title, body
db := database.DB()
if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可发文章", "forbidden": true})
@@ -468,16 +514,8 @@ func MiniprogramSuperArticleUpdate(c *gin.Context) {
req.UserID = strings.TrimSpace(req.UserID)
req.Title = strings.TrimSpace(req.Title)
req.Content = strings.TrimSpace(req.Content)
if req.UserID == "" || req.Title == "" || req.Content == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题和正文不能为空"})
return
}
if len([]rune(req.Title)) > 40 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题最多 40 字"})
return
}
if len([]rune(req.Content)) > 5000 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "正文最多 5000 字"})
if req.UserID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
@@ -492,6 +530,13 @@ func MiniprogramSuperArticleUpdate(c *gin.Context) {
return
}
title, body, verr := finalizeSuperArticleTitleAndBody(req.Title, req.Content, len(imgURLs))
if verr != "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": verr})
return
}
req.Title, req.Content = title, body
db := database.DB()
var row model.SuperArticle
if err := db.First(&row, id).Error; err != nil {
@@ -531,3 +576,42 @@ func MiniprogramSuperArticleUpdate(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MiniprogramSuperArticleDelete 删除动态(作者本人 + 超级个体)。
// DELETE /api/miniprogram/super/articles/:id?userId=
// POST /api/miniprogram/super/articles/:id/delete?userId= (与 DELETE 等价,便于不支持 DELETE 的客户端调试)
func MiniprogramSuperArticleDelete(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
userID := strings.TrimSpace(c.Query("userId"))
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
if _, ok := miniprogramSuperIndividualPerson(db, userID); !ok {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可操作", "forbidden": true})
return
}
var row model.SuperArticle
if err := db.First(&row, id).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "动态不存在"})
return
}
if strings.TrimSpace(row.UserID) != userID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权删除"})
return
}
if err := db.Delete(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -463,6 +463,9 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/super/articles", handler.MiniprogramSuperArticleList)
miniprogram.GET("/super/articles/:id", handler.MiniprogramSuperArticleDetail)
miniprogram.PUT("/super/articles/:id", handler.MiniprogramSuperArticleUpdate)
miniprogram.DELETE("/super/articles/:id", handler.MiniprogramSuperArticleDelete)
// 与 DELETE 等价:便于本地用表单/部分网关仅放行 POST 时调试query 仍传 userId
miniprogram.POST("/super/articles/:id/delete", handler.MiniprogramSuperArticleDelete)
miniprogram.POST("/super/articles", handler.MiniprogramSuperArticleCreate)
miniprogram.POST("/super/articles/generate", handler.MiniprogramSuperArticleGenerate)
}