更新小程序配置,切换 API 地址为本地开发环境。新增会员详情页面的头像逻辑,确保用户信息展示一致性。优化多个页面的交互提示,提升用户体验。调整图标组件,更新图标映射以支持新样式。

This commit is contained in:
Alex-larget
2026-03-20 10:58:25 +08:00
parent 181f092402
commit e79152c80b
21 changed files with 658 additions and 158 deletions

View File

@@ -13,8 +13,8 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
App({
globalData: {
// API 基础地址:开发时修改下面一行切换环境
baseUrl: "https://soulapi.quwanzhi.com",
// baseUrl: 'http://localhost:8080', // 开发
// baseUrl: "https://soulapi.quwanzhi.com",
baseUrl: 'http://localhost:8080', // 开发
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,

View File

@@ -42,38 +42,127 @@ Component({
},
methods: {
// iconfont 映射:将业务 namelucide 风格)映射到 iconfont 的 unicode形如 "\ue6aa"
// iconfont 映射:与 static/iconfont.wxss 一一对应icon-xxx -> xxx
// 小程序不支持通过 :before { content } 渲染,因此必须直接输出 unicode 字符
getFontGlyph(name) {
const map = {
// 基础高频(来自 static/iconfont.css 的 content 值)
'wallet': '\ue6c8',
// === 来自 iconfont.wxss 完整映射 ===
'qianbao': '\ue6c8',
'gift': '\ue6c9',
'zap1': '\ue75c',
'user': '\ue6b9',
'upload': '\ue6ba',
'work': '\ue6bb',
'training': '\ue6bc',
'warning': '\ue6bd',
'zoom-in': '\ue6be',
'zoom-out': '\ue6bf',
'arrow-left-bold': '\ue6c1',
'arrow-up-bold': '\ue6c2',
'close-bold': '\ue6c3',
'arrow-down-bold': '\ue6c4',
'minus-bold': '\ue6c5',
'arrow-right-bold': '\ue6c6',
'select-bold': '\ue6c7',
'money-wallet': '\ue833',
'book-open': '\ue993',
'biaoshilei_yonghuzu': '\ue61b',
'add': '\ue664',
'add-circle': '\ue665',
'adjust': '\ue666',
'arrow-up-circle': '\ue667',
'arrow-right-circle': '\ue668',
'arrow-down': '\ue669',
'ashbin': '\ue66a',
'arrow-right': '\ue66b',
'browse': '\ue66c',
'bottom': '\ue66d',
'back': '\ue66e',
'bad': '\ue66f',
'arrow-left-circle': '\ue670',
'camera': '\ue671',
'chart-bar': '\ue672',
'attachment': '\ue673',
'code': '\ue674',
'close': '\ue675',
'check-item': '\ue676',
'calendar': '\ue677',
'comment': '\ue678',
'complete': '\ue679',
'direction-down': '\ue67a',
'direction-down-circle': '\ue67b',
'direction-right': '\ue67c',
'direction-up': '\ue67d',
'discount': '\ue67e',
'electronics': '\ue681',
'elipsis': '\ue682',
'export': '\ue683',
'explain': '\ue684',
'edit': '\ue685',
'eye-close': '\ue686',
'email': '\ue687',
'error': '\ue688',
'favorite': '\ue689',
'file-common': '\ue68a',
'file-delete': '\ue68b',
'file-add': '\ue68c',
'film': '\ue68d',
'fabulous': '\ue68e',
'file': '\ue68f',
'folder-close': '\ue690',
'filter': '\ue691',
'good': '\ue692',
'hide': '\ue693',
'home': '\ue694',
'file-open': '\ue695',
'forward': '\ue696',
'import': '\ue697',
'layers': '\ue698',
'lock': '\ue699',
'map': '\ue69a',
'menu': '\ue69b',
'help': '\ue69c',
'minus-circle': '\ue69d',
'notification': '\ue69e',
'more': '\ue69f',
'mobile-phone': '\ue6a0',
'minus': '\ue6a1',
'navigation': '\ue6a2',
'prompt': '\ue6a3',
'refresh': '\ue6a4',
'run-up': '\ue6a5',
'picture': '\ue6a6',
'run-in': '\ue6a7',
'pin': '\ue6a8',
'save': '\ue6a9',
'search': '\ue6aa',
'share': '\ue6ab',
'home': '\ue694',
'lock': '\ue699',
'camera': '\ue671',
'warning': '\ue6bd',
'scanning': '\ue6ac',
'security': '\ue6ad',
'sign-out': '\ue6ae',
'select': '\ue6af',
'stop': '\ue6b0',
'success': '\ue6b1',
'switch': '\ue6b2',
'setting': '\ue6b3',
'survey': '\ue6b4',
'time': '\ue6b5',
'telephone': '\ue6b6',
'top': '\ue6b7',
'unlock': '\ue6b8',
// 箭头/展开
// === 业务别名(兼容 lucide 等命名)===
'wallet': '\ue6c8',
'chevron-left': '\ue6c1',
'chevron-right': '\ue6c6',
'chevron-down': '\ue6c4',
'chevron-up': '\ue6c2',
'arrow-up-right': '\ue6c2',
// 交互/状态
'x': '\ue6c3',
'check': '\ue6c7',
'plus': '\ue664',
'trash-2': '\ue66a',
'pencil': '\ue685',
'zap': '\ue75c',
'info': '\ue69c',
// 语义近似映射iconfont 不一定有同名)
'map-pin': '\ue6a8',
'message-circle': '\ue678',
'smartphone': '\ue6a0',
@@ -81,9 +170,6 @@ Component({
'shield': '\ue6ad',
'star': '\ue689',
'heart': '\ue68e',
// 其他:若 iconfont 里不存在,则继续走 SVG 兜底
'book-open': '\ue993',
'bar-chart': '\ue672',
'clock': '\ue6b5',
}

View File

@@ -38,18 +38,19 @@
<!-- Banner卡片 - 最新章节(异步加载) -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">开始阅读</text>
<icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon>
<text class="banner-action-text">点击阅读123</text>
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon></view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏) -->
@@ -78,7 +79,7 @@
>
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{item.name[0] || '会'}}</text>
<text class="super-avatar-text" wx:else>{{(item.name && item.name[0]) || '会'}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
@@ -87,7 +88,7 @@
<!-- 已加载无数据 -->
<view wx:else class="super-empty">
<text class="super-empty-text">成为会员,展示你的项目</text>
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 <icon name="chevron-right" size="28" color="#00CED1" customClass="inline-arrow"></icon></view>
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 </view>
</view>
</view>
@@ -112,7 +113,7 @@
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag || '精选'}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
@@ -142,13 +143,7 @@
<view class="timeline-dot"></view>
<view class="timeline-content">
<view class="timeline-row">
<view class="timeline-left">
<text class="latest-new-tag">NEW</text>
<text class="timeline-title">{{item.title}}</text>
</view>
<view class="timeline-right">
<text class="timeline-price">¥{{item.price}}</text>
</view>
<text class="timeline-title">{{item.title}}</text>
</view>
</view>
</view>

View File

@@ -18,7 +18,7 @@
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
@@ -132,7 +132,7 @@
<view class="bottom-wrap">
<view class="btn-super" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#00CED1" customClass="btn-arrow"></icon>
<icon name="chevron-right" size="36" color="#F59E0B" customClass="btn-arrow"></icon>
</view>
</view>
<view style="height:160rpx;"></view>

View File

@@ -429,9 +429,12 @@ Page({
return
}
const d = res.data
// 我的收益 = 累计佣金;我的余额 = 可提现金额(兼容 snake_case
const totalCommission = d.totalCommission ?? d.total_commission ?? 0
const availableEarnings = d.availableEarnings ?? d.available_earnings ?? 0
this.setData({
earnings: formatMoney(d.totalCommission),
pendingEarnings: formatMoney(d.availableEarnings),
earnings: formatMoney(totalCommission),
pendingEarnings: formatMoney(availableEarnings),
referralCount: d.referralCount ?? this.data.referralCount,
earningsLoading: false,
earningsRefreshing: false

View File

@@ -88,10 +88,7 @@
</view>
<view class="receive-bottom">
<text class="receive-tip">将依次调起微信收款页完成领取</text>
<view class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">
<text>查看提现记录</text>
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.6)" customClass="receive-link-arrow"></icon>
</view>
<text class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">查看提现记录 </text>
</view>
</view>
@@ -144,7 +141,7 @@
</view>
<view class="recent-empty" wx:else>
<text class="recent-empty-text">暂无阅读记录</text>
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 <icon name="chevron-right" size="24" color="#00CED1" customClass="recent-empty-arrow"></icon></view>
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 </view>
</view>
</view>
@@ -200,7 +197,7 @@
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="stopPropagation">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">手机号必填,微信号建议填写,以便使用提现和找伙伴功能</view>
<view class="contact-modal-hint">需完善手机号或微信号才能使用提现和找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">

View File

@@ -134,7 +134,7 @@
<view class="user-avatar {{item.status === 'converted' ? 'avatar-converted' : item.status === 'expired' ? 'avatar-expired' : ''}}">
<icon wx:if="{{item.status === 'converted'}}" name="check" size="28" color="#34C759"></icon>
<icon wx:elif="{{item.status === 'expired'}}" name="clock" size="28" color="#ff9500"></icon>
<text wx:else>{{item.nickname[0] || '用'}}</text>
<text wx:else>{{(item.nickname && item.nickname[0]) || '用'}}</text>
</view>
<view class="user-info">
<text class="user-name">{{item.nickname || '匿名用户'}}</text>

View File

@@ -4,23 +4,22 @@
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-icon"></icon>
</view>
<text class="nav-title">卡若创业派对</text>
<text class="nav-title">卡若创业派对VIP会员</text>
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 会员宣传区(已去掉 VIP PREMIUM 标签) -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<view class="vip-hero-title">加入卡若</view>
<view class="vip-hero-title gold">创业派对 会员</view>
<view class="vip-hero-title">加入卡若</view>
<view class="vip-hero-title gold">创业派对 VIP会员</view>
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
</view>
<!-- 双列权益:内容权益 + 社交权益 -->
<view class="rights-grid">
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-teal"></text>
<text class="rights-col-title">内容权益</text>
<text class="rights-col-title">会员权利</text>
</view>
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
<icon name="{{item.icon || 'check'}}" size="40" color="#00CED1" customClass="benefit-icon"></icon>
@@ -33,7 +32,7 @@
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-gold"></text>
<text class="rights-col-title rights-col-title-gold">社交权益</text>
<text class="rights-col-title rights-col-title-gold">派对权利</text>
</view>
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
<icon name="{{item.icon || 'check'}}" size="40" color="#FFD700" customClass="benefit-icon benefit-icon-gold"></icon>
@@ -47,7 +46,7 @@
<!-- 底部固定购买按钮(非 VIP 时显示,用 view 避让 button 默认 margin -->
<view class="buy-footer" wx:if="{{!isVip}}">
<view class="buy-btn-fixed {{purchasing ? 'buy-btn-disabled' : ''}}" bindtap="handlePurchase">
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
{{purchasing ? "处理中..." : "立即支付" + price + " 加入创业派对"}}
</view>
</view>

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/member-detail/member-detail",
"pathName": "pages/member-detail/member-detail",
"query": "id=ogpTW5cVMxd5afBBtXdvmeMO8aho",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/my/my",
"pathName": "pages/my/my",
"query": "",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "个人资料",

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { get, post } from '@/api/client'
@@ -33,6 +33,13 @@ interface Stats {
totalParts: number
}
/** 篇内章区间(按章条数,非节数) */
function chapterRangeLabel(chapterCount: number): string {
if (chapterCount <= 0) return '暂无章节'
if (chapterCount === 1) return '第1章'
return `第1章 ~ 第${chapterCount}`
}
export function ChaptersPage() {
const [structure, setStructure] = useState<Part[]>([])
const [stats, setStats] = useState<Stats | null>(null)
@@ -204,7 +211,8 @@ export function ChaptersPage() {
</span>
<span className="font-semibold text-white">{part.title}</span>
<span className="text-white/40 text-sm">
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
({chapterRangeLabel(part.chapters.length)} ·{' '}
{part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
</span>
</div>
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>

View File

@@ -2,7 +2,7 @@
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -12,6 +12,8 @@ export interface SectionItem {
id: string
title: string
price: number
/** 数据库自增序号,仅作兜底展示 */
mid?: number
isFree?: boolean
isNew?: boolean
clickCount?: number
@@ -34,6 +36,33 @@ export interface PartItem {
type DragType = 'part' | 'chapter' | 'section'
function isPrefacePart(p: PartItem) {
return p.title === '序言' || p.title.includes('序言')
}
/** 篇内按章数量展示第1章 ~ 第n章仅章数与节数无关 */
function chapterRangeSubtitle(chapterCount: number): string {
if (chapterCount <= 0) return '暂无章节'
if (chapterCount === 1) return '第1章'
return `第1章 ~ 第${chapterCount}`
}
/** 全书节序号:跳过序言篇后从 1 连续编号(与列表拖拽顺序一致) */
function buildBodySectionOrderMap(parts: PartItem[]): Map<string, number> {
const m = new Map<string, number>()
let n = 0
for (const part of parts) {
if (isPrefacePart(part)) continue
for (const ch of part.chapters) {
for (const s of ch.sections) {
n += 1
m.set(s.id, n)
}
}
}
return m
}
function parseDragData(data: string): { type: DragType; id: string } | null {
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
@@ -84,6 +113,8 @@ export function ChapterTree({
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
const bodySectionOrderMap = useMemo(() => buildBodySectionOrderMap(parts), [parts])
const isDragging = (type: DragType, id: string) => draggingItem?.type === type && draggingItem?.id === id
const isDragOver = (type: DragType, id: string) => dragOverTarget?.type === type && dragOverTarget?.id === id
@@ -267,6 +298,33 @@ export function ChapterTree({
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
/** 篇角标:去掉序言后第一篇为「一」 */
const bodyPartOrdinal = (partIndex: number) =>
parts.slice(0, partIndex).filter((p) => !isPrefacePart(p)).length
const sectionTitleLine = (section: SectionItem) => {
const ord = bodySectionOrderMap.get(section.id)
if (ord != null) {
return (
<>
<span
className="text-gray-500 font-mono text-xs tabular-nums shrink-0 mr-1.5"
title={`节序号(不含序言篇)· id: ${section.id}`}
>
{ord}.
</span>
<span className="truncate">{section.title}</span>
</>
)
}
return (
<span className="truncate" title={section.id}>
{section.mid != null && section.mid > 0 ? `${section.mid}. ` : ''}
{section.title}
</span>
)
}
return (
<div className="space-y-3">
{parts.map((part, partIndex) => {
@@ -381,7 +439,7 @@ export function ChapterTree({
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
<p className="text-xs text-gray-500 mt-0.5">{chapterRangeSubtitle(chapterCount)}</p>
</div>
</div>
<div
@@ -404,7 +462,9 @@ export function ChapterTree({
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
<span className="text-xs text-gray-500" title="本篇章数与节数">
{chapterCount} · {sectionCount}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -470,7 +530,9 @@ export function ChapterTree({
/>
</label>
)}
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
<span className="text-sm text-gray-200 truncate flex items-center min-w-0">
{sectionTitleLine(section)}
</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -739,11 +801,11 @@ export function ChapterTree({
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
{partLabel(partIndex)}
{partLabel(bodyPartOrdinal(partIndex))}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
<p className="text-xs text-gray-500 mt-0.5">{chapterRangeSubtitle(chapterCount)}</p>
</div>
</div>
<div
@@ -766,7 +828,9 @@ export function ChapterTree({
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
<span className="text-xs text-gray-500" title="本篇章数与节数">
{chapterCount} · {sectionCount}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -872,8 +936,8 @@ export function ChapterTree({
<div
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
/>
<span className="text-sm text-gray-200 truncate">
{section.id} {section.title}
<span className="text-sm text-gray-200 truncate flex items-center min-w-0">
{sectionTitleLine(section)}
</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>

View File

@@ -210,7 +210,17 @@ export function ContentPage() {
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
const [isSavingPartTitle, setIsSavingPartTitle] = useState(false)
const [showNewPartModal, setShowNewPartModal] = useState(false)
const [editingChapter, setEditingChapter] = useState<{ part: Part; chapter: Chapter; title: string } | null>(null)
const [editingChapter, setEditingChapter] = useState<{
part: Part
chapter: Chapter
title: string
price: number
isFree: boolean
priceMixed: boolean
initialTitle: string
initialPrice: number
initialIsFree: boolean
} | null>(null)
const [isSavingChapterTitle, setIsSavingChapterTitle] = useState(false)
const [selectedSectionIds, setSelectedSectionIds] = useState<string[]>([])
const [showBatchMoveModal, setShowBatchMoveModal] = useState(false)
@@ -914,40 +924,98 @@ export function ContentPage() {
}
const handleEditChapter = (part: Part, chapter: Chapter) => {
setEditingChapter({ part, chapter, title: chapter.title })
const secs = chapter.sections
let price = 1
let isFree = false
let priceMixed = false
if (secs.length > 0) {
const p0 = typeof secs[0].price === 'number' ? secs[0].price : Number(secs[0].price) || 1
const f0 = !!(secs[0].isFree || p0 === 0)
priceMixed = secs.some((s) => {
const p = typeof s.price === 'number' ? s.price : Number(s.price) || 1
const f = !!(s.isFree || p === 0)
return p !== p0 || f !== f0
})
price = f0 ? 0 : p0
isFree = f0
}
setEditingChapter({
part,
chapter,
title: chapter.title,
price,
isFree,
priceMixed,
initialTitle: chapter.title,
initialPrice: price,
initialIsFree: isFree,
})
}
const handleSaveChapterTitle = async () => {
if (!editingChapter?.title?.trim()) return
const ec = editingChapter
const newTitle = ec.title.trim()
const titleChanged = newTitle !== ec.initialTitle
const priceChanged =
ec.isFree !== ec.initialIsFree ||
(!ec.isFree && Number(ec.price) !== Number(ec.initialPrice))
if (!titleChanged && !priceChanged) {
toast.info('未修改任何内容')
setEditingChapter(null)
return
}
if (ec.priceMixed && priceChanged) {
const n = ec.chapter.sections.length
const tip = ec.isFree ? '全部设为免费' : `全部设为 ¥${ec.price}`
if (!confirm(`本章 ${n} 节当前定价不一致,保存后将${tip},确定?`)) return
}
setIsSavingChapterTitle(true)
try {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || editingChapter.part.id,
partTitle: s.partId === editingChapter.part.id ? editingChapter.part.title : (s.partTitle || ''),
chapterId: s.chapterId || editingChapter.chapter.id,
chapterTitle:
s.partId === editingChapter.part.id && s.chapterId === editingChapter.chapter.id
? editingChapter.title.trim()
: (s.chapterTitle || ''),
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success !== false) {
const newTitle = editingChapter.title.trim()
const partId = editingChapter.part.id
const chapterId = editingChapter.chapter.id
if (titleChanged) {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || ec.part.id,
partTitle: s.partId === ec.part.id ? ec.part.title : (s.partTitle || ''),
chapterId: s.chapterId || ec.chapter.id,
chapterTitle:
s.partId === ec.part.id && s.chapterId === ec.chapter.id ? newTitle : (s.chapterTitle || ''),
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success === false) {
toast.error('保存章节名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
return
}
const partId = ec.part.id
const chapterId = ec.chapter.id
setSectionsList((prev) =>
prev.map((s) =>
s.partId === partId && s.chapterId === chapterId
? { ...s, chapterTitle: newTitle }
: s
s.partId === partId && s.chapterId === chapterId ? { ...s, chapterTitle: newTitle } : s
)
)
setEditingChapter(null)
loadList()
} else {
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
if (priceChanged) {
const res2 = await put<{ success?: boolean; error?: string; affected?: number }>('/api/db/book', {
action: 'update-chapter-pricing',
partId: ec.part.id,
chapterId: ec.chapter.id,
price: ec.isFree ? 0 : Number(ec.price) || 0,
isFree: ec.isFree,
})
if (res2 && (res2 as { success?: boolean }).success === false) {
toast.error('保存定价失败: ' + (res2 && typeof res2 === 'object' && 'error' in res2 ? (res2 as { error?: string }).error : '未知错误'))
if (titleChanged) loadList()
return
}
}
setEditingChapter(null)
loadList()
toast.success('已保存')
} catch (e) {
console.error(e)
toast.error('保存失败')
@@ -1471,14 +1539,17 @@ export function ContentPage() {
</DialogContent>
</Dialog>
{/* 编辑章节名称弹窗 */}
{/* 编辑章节名称 + 本章统一定价(对齐新版管理端能力) */}
<Dialog open={!!editingChapter} onOpenChange={(open) => !open && setEditingChapter(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
<p className="text-gray-400 text-sm font-normal pt-1">
</p>
</DialogHeader>
{editingChapter && (
<div className="space-y-4 py-4">
@@ -1491,6 +1562,47 @@ export function ContentPage() {
placeholder="输入章节名称"
/>
</div>
<div className="space-y-2 border-t border-gray-700/60 pt-4">
<Label className="text-gray-300"> {editingChapter.chapter.sections.length} </Label>
{editingChapter.priceMixed && (
<p className="text-amber-400/90 text-xs"></p>
)}
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1 flex-1 min-w-[120px]">
<span className="text-gray-500 text-xs"> ()</span>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={editingChapter.isFree ? 0 : editingChapter.price}
onChange={(e) =>
setEditingChapter({
...editingChapter,
price: Number(e.target.value),
isFree: Number(e.target.value) === 0,
})
}
disabled={editingChapter.isFree}
min={0}
step={0.01}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer pb-2">
<input
type="checkbox"
checked={editingChapter.isFree || editingChapter.price === 0}
onChange={(e) =>
setEditingChapter({
...editingChapter,
isFree: e.target.checked,
price: e.target.checked ? 0 : editingChapter.initialPrice > 0 ? editingChapter.initialPrice : 1,
})
}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac]"
/>
<span className="text-gray-400 text-sm"></span>
</label>
</div>
</div>
</div>
)}
<DialogFooter>

View File

@@ -648,11 +648,26 @@ export function DistributionPage() {
{(overview.todayClicksByPage?.length ?? 0) > 0 && (
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
/
</CardTitle>
<p className="text-gray-400 text-sm mt-1"></p>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
/
</CardTitle>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
@@ -832,8 +847,8 @@ export function DistributionPage() {
{activeTab === 'orders' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -845,7 +860,7 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="completed"></option>
@@ -853,6 +868,16 @@ export function DistributionPage() {
<option value="failed"></option>
<option value="refunded">退</option>
</select>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -1029,8 +1054,8 @@ export function DistributionPage() {
{activeTab === 'bindings' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -1042,13 +1067,23 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="active"></option>
<option value="converted"></option>
<option value="expired"></option>
</select>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -1132,8 +1167,8 @@ export function DistributionPage() {
{activeTab === 'withdrawals' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -1145,13 +1180,23 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="pending"></option>
<option value="completed"></option>
<option value="rejected"></option>
</select>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -1289,8 +1334,14 @@ export function DistributionPage() {
<option value="cancelled"></option>
<option value="expired"></option>
</select>
<Button size="sm" variant="outline" onClick={() => loadTabData('giftPay', true)} className="border-gray-600 text-gray-300">
<RefreshCw className="w-4 h-4 mr-1" />
<Button
size="sm"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300"
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>

View File

@@ -764,8 +764,7 @@ export function UsersPage() {
{/* ===== 获客列表(存客宝) ===== */}
<TabsContent value="leads">
<div className="flex items-center justify-between mb-4">
<p className="text-gray-400 text-sm"></p>
<div className="flex items-center justify-end mb-4">
<Button variant="outline" onClick={loadLeads} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${leadsLoading ? 'animate-spin' : ''}`} />
</Button>

View File

@@ -2,16 +2,16 @@
.git
.gitignore
# 本地开发(部署时 .env.development / .env.production 需打包进镜像
# 本地开发(部署时环境文件需进构建上下文,由 Dockerfile COPY ${ENV_FILE} → /app/.env
.env
.env.*.local
*.local
!.env.development
!.env.production
!.env
# 构建产物
soul-api
soul-api-*
# 注意:不可忽略 soul-api —— Dockerfile.local 需 COPY 本地交叉编译的二进制;多阶段 Dockerfile 内 go build 会覆盖
*.exe
__pycache__
*.pyc

View File

@@ -3,10 +3,18 @@
"""
soul-api 一键部署到宝塔【测试环境】
打包原则:优先使用本地已有资源,不边打包边下载(省时)。
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
- 首次部署前请本地先拉好alpine:3.19、redis:7-alpine后续一律用本地缓存
两种模式均部署到测试环境 /www/wwwroot/self/soul-dev
- binaryGo 二进制 + 宝塔 soulDev 项目,用 .env.development
- dockerDocker 镜像 + 蓝绿无缝切换,用 .env.development 打包进镜像
- docker(默认):本地 go build → Dockerfile.local 打镜像 蓝绿无缝切换
- 镜像内包含二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env
- 环境文件:--env-file 或 DOCKER_ENV_FILE否则自动 .env.development > .env.production > .env.dockerignore 已放行)
- 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网
- 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25
环境变量DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等
"""
@@ -117,7 +125,7 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8081
DEPLOY_PORT = 9001
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -163,22 +171,32 @@ def set_env_mini_program_state(env_path, state):
f.writelines(new_lines)
def resolve_binary_pack_env_src(root):
"""binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。"""
for name in (".env.development", ".env.production", ".env"):
p = os.path.join(root, name)
if os.path.isfile(p):
return p, name
return None, None
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
print("[2/4] 打包部署文件 ...")
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env.development")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.development -> .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if include_env:
env_src, env_label = resolve_binary_pack_env_src(root)
if env_src:
shutil.copy2(env_src, staging_env)
print(" [已包含] %s -> .env" % env_label)
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "developer")
@@ -392,6 +410,37 @@ def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port):
# ==================== Docker 部署(蓝绿无缝切换) ====================
def resolve_docker_env_file(root, explicit=None):
"""
选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。
Dockerfile: COPY ${ENV_FILE} /app/.envcerts/ 由 COPY certs/ 一并打入。
优先级explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env与测试环境默认一致
"""
if explicit:
name = os.path.basename(explicit.replace("\\", "/"))
path = os.path.join(root, name)
if os.path.isfile(path):
print(" [镜像配置] 打入镜像的环境文件: %s--env-file" % name)
return name
print(" [失败] --env-file 不存在: %s" % path)
return None
override = (os.environ.get("DOCKER_ENV_FILE") or "").strip()
if override:
name = os.path.basename(override.replace("\\", "/"))
path = os.path.join(root, name)
if os.path.isfile(path):
print(" [镜像配置] 打入镜像的环境文件: %sDOCKER_ENV_FILE" % name)
return name
print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path)
return None
for name in (".env.development", ".env.production", ".env"):
if os.path.isfile(os.path.join(root, name)):
print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name)
return name
print(" [失败] 未找到 .env.development / .env.production / .env无法 COPY 进镜像")
return None
def run_docker_build(root, env_file=".env.development"):
"""本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)"""
print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)")
@@ -415,14 +464,14 @@ def run_docker_build(root, env_file=".env.development"):
def run_docker_build_local(root, env_file=".env.development"):
"""使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像)"""
"""使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像--pull=false 不拉 base 镜像"""
print("[1/5] 使用本地 Go 交叉编译 ...")
binary_path = run_build(root)
if not binary_path:
return None
print("[2/5] 使用 Dockerfile.local 构建镜像 ...进度见下方 Docker 输出")
print("[2/5] 使用 Dockerfile.local 构建镜像 ...--pull=false 仅用本地缓存")
try:
cmd = ["docker", "build", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
"--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
r = subprocess.run(cmd, cwd=root, shell=False, timeout=120)
if r.returncode != 0:
@@ -444,7 +493,7 @@ def run_docker_build_local(root, env_file=".env.development"):
def pack_docker_image(root):
"""将 soul-api 与 redis 镜像一并导出为 tar.gz服务器无需拉取"""
import gzip
print("[2/5] 导出镜像为 tar.gzsoul-api + redis...")
print("[3/5] 导出镜像为 tar.gzsoul-api + redis...")
out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz")
try:
r = subprocess.run(
@@ -479,7 +528,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF)
script_dir = os.path.dirname(os.path.abspath(__file__))
print("[3/5] SSH 上传镜像与配置 ...")
print("[4/5] SSH 上传镜像与配置 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
@@ -506,9 +555,11 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
if os.path.isfile(deploy_local):
sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh")
print(" [已上传] docker-deploy-remote.sh")
# 注意docker-compose.bluegreen.yml 未配置 env_file容器实际以镜像内 /app/.env 为准;
# 此处上传仅供服务器目录备份或手工改 compose 后使用。
if include_env and os.path.isfile(env_local):
sftp.put(env_local, deploy_path.rstrip("/") + "/.env")
print(" [已上传] .env.production -> .env覆盖镜像内配置")
print(" [已上传] .env.production -> 服务器 %s/.env可选默认不挂载进容器" % deploy_path.rstrip("/"))
# btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx
current_active = "blue"
@@ -523,7 +574,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
sftp.close()
print("[4/5] 执行蓝绿部署 ...")
print("[5/5] 执行蓝绿部署 ...")
env_exports = ""
if nginx_conf:
env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''")
@@ -546,12 +597,12 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
# btapi 模式:通过宝塔 API 更新 Nginx 配置并重载new_port 已在上方计算)
if deploy_method == "btapi" and nginx_conf:
try:
print("[5/5] 宝塔 API 更新 Nginx ...")
print(" [宝塔 API] 更新 Nginx ...")
deploy_nginx_via_bt_api(cfg, nginx_conf, new_port)
except Exception as e:
print(" [警告] 宝塔 Nginx API 失败:", str(e))
print("[5/5] 部署完成,蓝绿无缝切换")
print(" 部署完成,蓝绿无缝切换")
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
@@ -568,14 +619,17 @@ def main():
parser.add_argument("--mode", choices=("binary", "docker"), default="docker",
help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制")
parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建")
parser.add_argument("--no-env", action="store_true", help="不打包 .env")
parser.add_argument("--no-env", action="store_true",
help="binary: 不打进 tardocker: 不上传服务器目录 .env.production镜像内配置不变")
parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启")
parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto",
help="[binary] 重启方式: auto/btapi/ssh")
parser.add_argument("--local-go", action="store_true",
help="[docker] 使用本地 Go 交叉编译后打镜像,不拉取 golang 镜像")
parser.add_argument("--docker-in-go", action="store_true",
help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)")
parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh",
help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)")
parser.add_argument("--env-file", default=None, metavar="NAME",
help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env")
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -592,11 +646,19 @@ def main():
print("=" * 60)
if not args.no_build:
ok = run_docker_build_local(root) if args.local_go else run_docker_build(root)
env_for_image = resolve_docker_env_file(root, explicit=args.env_file)
if env_for_image is None:
return 1
# 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译
ok = (
run_docker_build(root, env_file=env_for_image)
if args.docker_in_go
else run_docker_build_local(root, env_file=env_for_image)
)
if not ok:
return 1
else:
print("[1/5] 跳过构建,使用现有 soul-api:latest")
print("[1/5] 跳过构建,使用现有 soul-api:latest(无需本地环境文件)")
image_tar = pack_docker_image(root)
if not image_tar:

View File

@@ -48,10 +48,15 @@ const KeyConfigAuditMode = "soul:config:audit-mode"
const KeyConfigCore = "soul:config:core"
const KeyConfigReadExtras = "soul:config:read-extras"
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB
// Get 从 Redis 读取,未配置或失败时尝试内存备用;均失败返回 false(调用方回退 DB
func Get(ctx context.Context, key string, dest interface{}) bool {
client := redis.Client()
if client == nil {
// Redis 不可用,使用内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if ctx == nil {
@@ -61,6 +66,11 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
defer cancel()
val, err := client.Get(ctx, key).Bytes()
if err != nil {
// Redis 超时/失败时尝试内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if dest != nil && len(val) > 0 {
@@ -69,10 +79,16 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
return true
}
// Set 写入 Redis失败仅打日志不阻塞
// Set 写入 RedisRedis 不可用时写入内存备用;失败仅打日志不阻塞
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
client := redis.Client()
if client == nil {
memorySet(key, data, ttl)
return
}
if ctx == nil {
@@ -80,22 +96,20 @@ func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
log.Printf("cache.Set %s: %v (非致命)", key, err)
log.Printf("cache.Set %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, data, ttl)
}
}
// Del 删除 key失败仅打日志
// Del 删除 keyRedis 不可用时删除内存备用
func Del(ctx context.Context, key string) {
client := redis.Client()
if client == nil {
memoryDel(key)
return
}
memoryDel(key)
if ctx == nil {
ctx = context.Background()
}
@@ -106,12 +120,14 @@ func Del(ctx context.Context, key string) {
}
}
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*),用于批量失效
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*Redis 不可用时删除内存备
func DelPattern(ctx context.Context, pattern string) {
client := redis.Client()
if client == nil {
memoryDelPattern(pattern)
return
}
memoryDelPattern(pattern)
if ctx == nil {
ctx = context.Background()
}
@@ -183,10 +199,13 @@ func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sp
// ChapterContentTTL 章节正文 TTL后台更新时主动 Del
const ChapterContentTTL = 30 * time.Minute
// GetString 读取字符串(不经过 JSON适合大文本 content
// GetString 读取字符串(不经过 JSON适合大文本 contentRedis 不可用时尝试内存备用
func GetString(ctx context.Context, key string) (string, bool) {
client := redis.Client()
if client == nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
if ctx == nil {
@@ -196,15 +215,19 @@ func GetString(ctx context.Context, key string) (string, bool) {
defer cancel()
val, err := client.Get(ctx, key).Result()
if err != nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
return val, true
}
// SetString 写入字符串(不经过 JSON适合大文本 content
// SetString 写入字符串(不经过 JSON适合大文本 contentRedis 不可用时写入内存备用
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
client := redis.Client()
if client == nil {
memorySet(key, []byte(val), ttl)
return
}
if ctx == nil {
@@ -213,7 +236,8 @@ func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
log.Printf("cache.SetString %s: %v (非致命)", key, err)
log.Printf("cache.SetString %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, []byte(val), ttl)
}
}

52
soul-api/internal/cache/memory.go vendored Normal file
View File

@@ -0,0 +1,52 @@
package cache
import (
"strings"
"sync"
"time"
)
// memoryFallback Redis 不可用时的内存备用缓存,保证服务可用
var (
memoryMu sync.RWMutex
memoryData = make(map[string]*memoryEntry)
)
type memoryEntry struct {
Data []byte
Expiry time.Time
}
func memoryGet(key string) ([]byte, bool) {
memoryMu.RLock()
defer memoryMu.RUnlock()
e, ok := memoryData[key]
if !ok || e == nil || time.Now().After(e.Expiry) {
return nil, false
}
return e.Data, true
}
func memorySet(key string, data []byte, ttl time.Duration) {
memoryMu.Lock()
defer memoryMu.Unlock()
memoryData[key] = &memoryEntry{Data: data, Expiry: time.Now().Add(ttl)}
}
func memoryDel(key string) {
memoryMu.Lock()
defer memoryMu.Unlock()
delete(memoryData, key)
}
// memoryDelPattern 按前缀删除pattern 如 soul:book:chapters-by-part:* 转为前缀 soul:book:chapters-by-part:
func memoryDelPattern(pattern string) {
prefix := strings.TrimSuffix(pattern, "*")
memoryMu.Lock()
defer memoryMu.Unlock()
for k := range memoryData {
if strings.HasPrefix(k, prefix) {
delete(memoryData, k)
}
}
}

View File

@@ -13,7 +13,6 @@ import (
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/redis"
"github.com/gin-gonic/gin"
)
@@ -92,6 +91,20 @@ func buildMiniprogramConfig() gin.H {
}
}
}
// 价格以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
var siteRow model.SystemConfig
if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
var siteVal map[string]interface{}
if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil {
cur := out["prices"].(gin.H)
if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 {
cur["section"] = v
}
if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
cur["fullbook"] = v
}
}
}
// 好友优惠(用于 read 页展示优惠价)
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
@@ -159,14 +172,8 @@ func GetPublicDBConfig(c *gin.Context) {
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
// Redis 时直接返回数据库中的 auditMode 值,保证可
// Redis 不可用时 cache 包自动降级到内存备
func GetAuditMode(c *gin.Context) {
// 无 Redis 时跳过缓存,直接返回数据库值
if redis.Client() == nil {
auditMode := getAuditModeFromDB()
c.JSON(http.StatusOK, gin.H{"auditMode": auditMode})
return
}
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)

View File

@@ -610,6 +610,38 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
// update-chapter-pricing按篇+章批量更新该章下所有「节」行的 price / is_free管理端章节统一定价
if body.Action == "update-chapter-pricing" {
if body.PartID == "" || body.ChapterID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 partId 或 chapterId"})
return
}
p := 1.0
if body.Price != nil {
p = *body.Price
}
free := false
if body.IsFree != nil {
free = *body.IsFree
}
if free {
p = 0
}
up := map[string]interface{}{
"price": p,
"is_free": free,
}
res := db.Model(&model.Chapter{}).Where("part_id = ? AND chapter_id = ?", body.PartID, body.ChapterID).Updates(up)
if res.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
return
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已更新本章全部节的定价", "affected": res.RowsAffected})
return
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return

View File

@@ -21,6 +21,8 @@ func Init(url string) error {
client = redis.NewClient(opt)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
client = nil // 连接失败时清空避免后续使用超时cache 将自动降级到内存备用
log.Printf("redis: 连接失败,已降级到内存缓存(%v", err)
return err
}
log.Printf("redis: connected to %s", opt.Addr)