更新小程序配置,切换 API 地址为本地开发环境。新增会员详情页面的头像逻辑,确保用户信息展示一致性。优化多个页面的交互提示,提升用户体验。调整图标组件,更新图标映射以支持新样式。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -42,38 +42,127 @@ Component({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// iconfont 映射:将业务 name(lucide 风格)映射到 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',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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": "个人资料",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
"""
|
||||
soul-api 一键部署到宝塔【测试环境】
|
||||
|
||||
打包原则:优先使用本地已有资源,不边打包边下载(省时)。
|
||||
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
|
||||
- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存
|
||||
|
||||
两种模式均部署到测试环境 /www/wwwroot/self/soul-dev:
|
||||
|
||||
- binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development
|
||||
- docker:Docker 镜像 + 蓝绿无缝切换,用 .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/.env;certs/ 由 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(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_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.gz(soul-api + redis)...")
|
||||
print("[3/5] 导出镜像为 tar.gz(soul-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: 不打进 tar;docker: 不上传服务器目录 .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:
|
||||
|
||||
50
soul-api/internal/cache/cache.go
vendored
50
soul-api/internal/cache/cache.go
vendored
@@ -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 写入 Redis,Redis 不可用时写入内存备用;失败仅打日志不阻塞
|
||||
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 删除 key,Redis 不可用时删除内存备用
|
||||
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,适合大文本 content),Redis 不可用时尝试内存备用
|
||||
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,适合大文本 content),Redis 不可用时写入内存备用
|
||||
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
52
soul-api/internal/cache/memory.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user