- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
1057 lines
42 KiB
TypeScript
1057 lines
42 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useSearchParams, Link } from 'react-router-dom'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@/components/ui/card'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import {
|
||
Save,
|
||
Settings,
|
||
Users,
|
||
DollarSign,
|
||
UserCircle,
|
||
Calendar,
|
||
MapPin,
|
||
BookOpen,
|
||
Gift,
|
||
Smartphone,
|
||
ShieldCheck,
|
||
Link2,
|
||
FileText,
|
||
Cloud,
|
||
Smile,
|
||
Eye,
|
||
EyeOff,
|
||
} from 'lucide-react'
|
||
import { get, post } from '@/api/client'
|
||
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
|
||
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
|
||
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
|
||
|
||
interface AuthorInfo {
|
||
name?: string
|
||
startDate?: string
|
||
bio?: string
|
||
liveTime?: string
|
||
platform?: string
|
||
description?: string
|
||
}
|
||
|
||
interface LocalSettings {
|
||
sectionPrice: number
|
||
baseBookPrice: number
|
||
distributorShare: number
|
||
authorInfo: AuthorInfo
|
||
ckbLeadApiKey: string
|
||
}
|
||
|
||
interface FeatureConfig {
|
||
matchEnabled: boolean
|
||
referralEnabled: boolean
|
||
searchEnabled: boolean
|
||
aboutEnabled: boolean
|
||
}
|
||
|
||
interface MpConfig {
|
||
appId?: string
|
||
withdrawSubscribeTmplId?: string
|
||
mchId?: string
|
||
minWithdraw?: number
|
||
auditMode?: boolean
|
||
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
|
||
mpUi?: Record<string, unknown>
|
||
}
|
||
|
||
interface OssConfig {
|
||
endpoint?: string
|
||
bucket?: string
|
||
region?: string
|
||
accessKeyId?: string
|
||
accessKeySecret?: string
|
||
}
|
||
|
||
const defaultMpConfig: MpConfig = {
|
||
appId: 'wxb8bbb2b10dec74aa',
|
||
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
|
||
mchId: '1318592501',
|
||
minWithdraw: 10,
|
||
}
|
||
|
||
const defaultAuthor: AuthorInfo = {
|
||
name: '卡若',
|
||
startDate: '2025年10月15日',
|
||
bio: '连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事',
|
||
liveTime: '06:00-09:00',
|
||
platform: 'Soul派对房',
|
||
description: '连续创业者,私域运营专家',
|
||
}
|
||
|
||
const defaultSettings: LocalSettings = {
|
||
sectionPrice: 1,
|
||
baseBookPrice: 9.9,
|
||
distributorShare: 90,
|
||
authorInfo: { ...defaultAuthor },
|
||
ckbLeadApiKey: '',
|
||
}
|
||
|
||
const defaultFeatures: FeatureConfig = {
|
||
matchEnabled: true,
|
||
referralEnabled: true,
|
||
searchEnabled: true,
|
||
aboutEnabled: true,
|
||
}
|
||
|
||
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
|
||
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
|
||
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
|
||
chaptersPage: {
|
||
bookTitle: '一场SOUL的创业实验场',
|
||
bookSubtitle: '来自Soul派对房的真实商业故事',
|
||
},
|
||
homePage: {
|
||
logoTitle: '卡若创业派对',
|
||
logoSubtitle: '来自派对房的真实故事',
|
||
linkKaruoText: '点击链接卡若',
|
||
searchPlaceholder: '搜索章节标题或内容...',
|
||
bannerTag: '推荐',
|
||
bannerReadMoreText: '点击阅读',
|
||
superSectionTitle: '超级个体',
|
||
superSectionLinkText: '获客入口',
|
||
superSectionLinkPath: '/pages/match/match',
|
||
pickSectionTitle: '精选推荐',
|
||
latestSectionTitle: '最新新增',
|
||
},
|
||
myPage: {
|
||
cardLabel: '名片',
|
||
vipLabelVip: '会员中心',
|
||
vipLabelGuest: '成为会员',
|
||
cardPath: '',
|
||
vipPath: '/pages/vip/vip',
|
||
readStatLabel: '已读章节',
|
||
recentReadTitle: '最近阅读',
|
||
readStatPath: '/pages/reading-records/reading-records?focus=all',
|
||
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
|
||
},
|
||
}
|
||
|
||
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
|
||
type TabKey = (typeof TAB_KEYS)[number]
|
||
|
||
export function SettingsPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const tabParam = searchParams.get('tab') ?? 'system'
|
||
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
|
||
|
||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||
const [mpUiJson, setMpUiJson] = useState('{}')
|
||
const [ossConfig, setOssConfig] = useState<OssConfig>({})
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [dialogTitle, setDialogTitle] = useState('')
|
||
const [dialogMessage, setDialogMessage] = useState('')
|
||
const [dialogIsError, setDialogIsError] = useState(false)
|
||
const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false)
|
||
|
||
const MBTI_TYPES = [
|
||
'INTJ','INTP','ENTJ','ENTP',
|
||
'INFJ','INFP','ENFJ','ENFP',
|
||
'ISTJ','ISFJ','ESTJ','ESFJ',
|
||
'ISTP','ISFP','ESTP','ESFP',
|
||
]
|
||
const [mbtiAvatars, setMbtiAvatars] = useState<Record<string, string>>({})
|
||
const [mbtiLoading, setMbtiLoading] = useState(false)
|
||
const [mbtiSaving, setMbtiSaving] = useState(false)
|
||
|
||
const loadMbtiAvatars = async () => {
|
||
setMbtiLoading(true)
|
||
try {
|
||
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
|
||
if (res?.avatars) setMbtiAvatars(res.avatars)
|
||
} catch { /* ignore */ }
|
||
finally { setMbtiLoading(false) }
|
||
}
|
||
|
||
const saveMbtiAvatars = async () => {
|
||
setMbtiSaving(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars: mbtiAvatars })
|
||
if (!res || res.success === false) {
|
||
showResult('保存失败', res?.error ?? '未知错误', true)
|
||
return
|
||
}
|
||
showResult('已保存', 'MBTI 头像映射已保存,无头像的超级个体将自动使用对应性格头像。')
|
||
} catch (error) {
|
||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||
} finally { setMbtiSaving(false) }
|
||
}
|
||
|
||
const showResult = (title: string, message: string, isError = false) => {
|
||
setDialogTitle(title)
|
||
setDialogMessage(message)
|
||
setDialogIsError(isError)
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
const res = await get<{
|
||
success?: boolean
|
||
featureConfig?: Partial<FeatureConfig>
|
||
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string }
|
||
mpConfig?: Partial<MpConfig>
|
||
ossConfig?: Partial<OssConfig>
|
||
}>('/api/admin/settings')
|
||
if (!res || (res as { success?: boolean }).success === false) return
|
||
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||
if (res.mpConfig && typeof res.mpConfig === 'object') {
|
||
const merged = { ...res.mpConfig } as MpConfig
|
||
setMpConfig((prev) => ({ ...prev, ...merged }))
|
||
const raw = merged.mpUi
|
||
setMpUiJson(
|
||
JSON.stringify(
|
||
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
|
||
null,
|
||
2,
|
||
),
|
||
)
|
||
}
|
||
if (res.ossConfig && typeof res.ossConfig === 'object')
|
||
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
|
||
if (res.siteSettings && typeof res.siteSettings === 'object') {
|
||
const s = res.siteSettings
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
...(typeof s.sectionPrice === 'number' && { sectionPrice: s.sectionPrice }),
|
||
...(typeof s.baseBookPrice === 'number' && { baseBookPrice: s.baseBookPrice }),
|
||
...(typeof s.distributorShare === 'number' && { distributorShare: s.distributorShare }),
|
||
...(s.authorInfo && typeof s.authorInfo === 'object' && { authorInfo: { ...prev.authorInfo, ...s.authorInfo } }),
|
||
...(typeof s.ckbLeadApiKey === 'string' && { ckbLeadApiKey: s.ckbLeadApiKey }),
|
||
}))
|
||
}
|
||
} catch (e) {
|
||
console.error('Load settings error:', e)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
load()
|
||
loadMbtiAvatars()
|
||
}, [])
|
||
|
||
const saveFeatureConfigOnly = async (
|
||
nextConfig: FeatureConfig,
|
||
onFailRevert: () => void,
|
||
) => {
|
||
setFeatureSwitchSaving(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||
featureConfig: nextConfig,
|
||
})
|
||
if (!res || (res as { success?: boolean }).success === false) {
|
||
onFailRevert()
|
||
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
|
||
return
|
||
}
|
||
showResult('已保存', '功能开关已更新,相关入口将随之显示或隐藏。')
|
||
} catch (error) {
|
||
console.error('Save feature config error:', error)
|
||
onFailRevert()
|
||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||
} finally {
|
||
setFeatureSwitchSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleFeatureSwitch = (field: keyof FeatureConfig, checked: boolean) => {
|
||
const prev = featureConfig
|
||
const next = { ...prev, [field]: checked }
|
||
setFeatureConfig(next)
|
||
saveFeatureConfigOnly(next, () => setFeatureConfig(prev))
|
||
}
|
||
|
||
const [auditModeSaving, setAuditModeSaving] = useState(false)
|
||
const handleAuditModeSwitch = async (checked: boolean) => {
|
||
const prev = mpConfig
|
||
const next = { ...prev, auditMode: checked }
|
||
setMpConfig(next)
|
||
setAuditModeSaving(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||
mpConfig: next,
|
||
})
|
||
if (!res || (res as { success?: boolean }).success === false) {
|
||
setMpConfig(prev)
|
||
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
|
||
return
|
||
}
|
||
showResult('已保存', checked ? '审核模式已开启,小程序将隐藏所有支付入口。' : '审核模式已关闭,支付功能已恢复。')
|
||
} catch (error) {
|
||
setMpConfig(prev)
|
||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||
} finally {
|
||
setAuditModeSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setIsSaving(true)
|
||
try {
|
||
let mpUi: Record<string, unknown> = {}
|
||
try {
|
||
const t = mpUiJson.trim()
|
||
if (t) {
|
||
const parsed: unknown = JSON.parse(t)
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
mpUi = parsed as Record<string, unknown>
|
||
} else {
|
||
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
|
||
setIsSaving(false)
|
||
return
|
||
}
|
||
}
|
||
} catch {
|
||
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
|
||
setIsSaving(false)
|
||
return
|
||
}
|
||
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||
featureConfig,
|
||
siteSettings: {
|
||
sectionPrice: localSettings.sectionPrice,
|
||
baseBookPrice: localSettings.baseBookPrice,
|
||
distributorShare: localSettings.distributorShare,
|
||
authorInfo: localSettings.authorInfo,
|
||
ckbLeadApiKey: localSettings.ckbLeadApiKey || undefined,
|
||
},
|
||
mpConfig: {
|
||
...mpConfig,
|
||
appId: mpConfig.appId || '',
|
||
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
|
||
mchId: mpConfig.mchId || '',
|
||
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
|
||
auditMode: mpConfig.auditMode ?? false,
|
||
mpUi,
|
||
},
|
||
ossConfig: Object.keys(ossConfig).length
|
||
? {
|
||
endpoint: ossConfig.endpoint ?? '',
|
||
bucket: ossConfig.bucket ?? '',
|
||
region: ossConfig.region ?? '',
|
||
accessKeyId: ossConfig.accessKeyId ?? '',
|
||
accessKeySecret: ossConfig.accessKeySecret ?? '',
|
||
}
|
||
: undefined,
|
||
})
|
||
if (!res || (res as { success?: boolean }).success === false) {
|
||
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
|
||
return
|
||
}
|
||
showResult('已保存', '设置已保存成功。')
|
||
} catch (error) {
|
||
console.error('Save settings error:', error)
|
||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleTabChange = (v: string) => {
|
||
setSearchParams(v === 'system' ? {} : { tab: v })
|
||
}
|
||
|
||
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
||
|
||
return (
|
||
<div className="p-8 w-full">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">系统设置</h2>
|
||
<p className="text-gray-400 mt-1">配置全站基础参数与开关</p>
|
||
</div>
|
||
{activeTab === 'system' && (
|
||
<Button
|
||
onClick={handleSave}
|
||
disabled={isSaving}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
<Save className="w-4 h-4 mr-2" />
|
||
{isSaving ? '保存中...' : '保存设置'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||
<TabsList className="mb-6 bg-[#0f2137] border border-gray-700/50 p-1">
|
||
<TabsTrigger
|
||
value="system"
|
||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||
>
|
||
<Settings className="w-4 h-4 mr-2" />
|
||
系统设置
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="author"
|
||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||
>
|
||
<UserCircle className="w-4 h-4 mr-2" />
|
||
作者详情
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="admin"
|
||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||
>
|
||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||
管理员
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="api-docs"
|
||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
window.open('/api-docs', '_blank')
|
||
}}
|
||
>
|
||
<FileText className="w-4 h-4 mr-2" />
|
||
API 文档 ↗
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="system" className="mt-0">
|
||
<div className="space-y-6">
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<UserCircle className="w-5 h-5 text-[#38bdac]" />
|
||
关于作者
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
配置作者信息,将在"关于作者"页面显示
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="author-name" className="text-gray-300 flex items-center gap-1">
|
||
<UserCircle className="w-3 h-3" />
|
||
主理人名称
|
||
</Label>
|
||
<Input
|
||
id="author-name"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={localSettings.authorInfo.name ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, name: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="start-date" className="text-gray-300 flex items-center gap-1">
|
||
<Calendar className="w-3 h-3" />
|
||
开播日期
|
||
</Label>
|
||
<Input
|
||
id="start-date"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="例如: 2025年10月15日"
|
||
value={localSettings.authorInfo.startDate ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, startDate: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="live-time" className="text-gray-300 flex items-center gap-1">
|
||
<Calendar className="w-3 h-3" />
|
||
直播时间
|
||
</Label>
|
||
<Input
|
||
id="live-time"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="例如: 06:00-09:00"
|
||
value={localSettings.authorInfo.liveTime ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, liveTime: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="platform" className="text-gray-300 flex items-center gap-1">
|
||
<MapPin className="w-3 h-3" />
|
||
直播平台
|
||
</Label>
|
||
<Input
|
||
id="platform"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="例如: Soul派对房"
|
||
value={localSettings.authorInfo.platform ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, platform: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="description" className="text-gray-300 flex items-center gap-1">
|
||
<BookOpen className="w-3 h-3" />
|
||
简介描述
|
||
</Label>
|
||
<Input
|
||
id="description"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={localSettings.authorInfo.description ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, description: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="bio" className="text-gray-300">
|
||
详细介绍
|
||
</Label>
|
||
<Textarea
|
||
id="bio"
|
||
className="bg-[#0a1628] border-gray-700 text-white min-h-[100px]"
|
||
placeholder="输入作者详细介绍..."
|
||
value={localSettings.authorInfo.bio ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
authorInfo: { ...prev.authorInfo, bio: e.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ckb-lead-api-key" className="text-gray-300 flex items-center gap-1">
|
||
<Link2 className="w-3 h-3" />
|
||
链接卡若存客宝密钥
|
||
</Label>
|
||
<Input
|
||
id="ckb-lead-api-key"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="如 xxxxx-xxxxx-xxxxx-xxxxx(留空则用 .env 默认)"
|
||
value={localSettings.ckbLeadApiKey ?? ''}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
ckbLeadApiKey: e.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
<p className="text-xs text-gray-500">小程序首页「链接卡若」留资接口使用的存客宝 API Key,优先于 .env 配置</p>
|
||
</div>
|
||
<div className="mt-4 p-4 rounded-xl bg-[#0a1628] border border-[#38bdac]/30">
|
||
<p className="text-xs text-gray-500 mb-2">预览效果</p>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
|
||
{(localSettings.authorInfo.name ?? 'K').charAt(0)}
|
||
</div>
|
||
<div>
|
||
<p className="text-white font-semibold">{localSettings.authorInfo.name}</p>
|
||
<p className="text-gray-400 text-xs">{localSettings.authorInfo.description}</p>
|
||
<p className="text-[#38bdac] text-xs mt-1">
|
||
每日 {localSettings.authorInfo.liveTime} · {localSettings.authorInfo.platform}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<DollarSign className="w-5 h-5 text-[#38bdac]" />
|
||
价格设置
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
配置书籍和章节的定价
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">单节价格 (元)</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={localSettings.sectionPrice}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
sectionPrice: Number.parseFloat(e.target.value) || 1,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">整本价格 (元)</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={localSettings.baseBookPrice}
|
||
onChange={(e) =>
|
||
setLocalSettings((prev) => ({
|
||
...prev,
|
||
baseBookPrice: Number.parseFloat(e.target.value) || 9.9,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Smartphone className="w-5 h-5 text-[#38bdac]" />
|
||
小程序配置
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
订阅消息模板、支付商户号等,小程序从 /api/miniprogram/config 读取(API 地址由 app.js baseUrl 控制)
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">小程序 AppID</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="wxb8bbb2b10dec74aa"
|
||
value={mpConfig.appId ?? ''}
|
||
onChange={(e) =>
|
||
setMpConfig((prev) => ({ ...prev, appId: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">提现订阅模板 ID</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="用户申请提现时需授权"
|
||
value={mpConfig.withdrawSubscribeTmplId ?? ''}
|
||
onChange={(e) =>
|
||
setMpConfig((prev) => ({ ...prev, withdrawSubscribeTmplId: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">微信支付商户号</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="1318592501"
|
||
value={mpConfig.mchId ?? ''}
|
||
onChange={(e) =>
|
||
setMpConfig((prev) => ({ ...prev, mchId: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">最低提现金额 (元)</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={mpConfig.minWithdraw ?? 10}
|
||
onChange={(e) =>
|
||
setMpConfig((prev) => ({
|
||
...prev,
|
||
minWithdraw: Number.parseFloat(e.target.value) || 10,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 pt-2 border-t border-gray-700/50">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<Label className="text-gray-300">小程序界面文案 mpUi(JSON)</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-gray-600 text-gray-200"
|
||
onClick={() => setMpUiJson(JSON.stringify(MP_UI_TEMPLATE_OBJECT, null, 2))}
|
||
>
|
||
填入默认模板
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-gray-500">
|
||
覆盖 Tab 文案、首页/目录标题、我的页名片与阅读记录路径等;仅填需要改的字段也可(与后端默认值深合并)。保存后小程序约 5
|
||
分钟内通过 config 缓存刷新。
|
||
</p>
|
||
<Textarea
|
||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm min-h-[280px]"
|
||
spellCheck={false}
|
||
value={mpUiJson}
|
||
onChange={(e) => setMpUiJson(e.target.value)}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Cloud className="w-5 h-5 text-[#38bdac]" />
|
||
OSS 配置(阿里云对象存储)
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
endpoint、bucket、accessKey 等,用于图片/文件上传
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">Endpoint</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="oss-cn-hangzhou.aliyuncs.com"
|
||
value={ossConfig.endpoint ?? ''}
|
||
onChange={(e) =>
|
||
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">Bucket</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="bucket 名称"
|
||
value={ossConfig.bucket ?? ''}
|
||
onChange={(e) =>
|
||
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">Region</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="oss-cn-hangzhou"
|
||
value={ossConfig.region ?? ''}
|
||
onChange={(e) =>
|
||
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">AccessKey ID</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="AccessKey ID"
|
||
value={ossConfig.accessKeyId ?? ''}
|
||
onChange={(e) =>
|
||
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">AccessKey Secret</Label>
|
||
<Input
|
||
type="password"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="AccessKey Secret"
|
||
value={ossConfig.accessKeySecret ?? ''}
|
||
onChange={(e) =>
|
||
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<ShieldCheck className="w-5 h-5 text-amber-400" />
|
||
小程序审核模式
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
提交微信审核前开启,审核通过后关闭即可恢复支付功能
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
|
||
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
|
||
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 ml-6">
|
||
{mpConfig.auditMode
|
||
? '当前已隐藏所有支付、VIP、充值、收益等入口,审核员看不到任何付费内容'
|
||
: '关闭状态,小程序正常显示所有功能(含支付、VIP 等)'}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="audit-mode"
|
||
checked={mpConfig.auditMode ?? false}
|
||
disabled={auditModeSaving}
|
||
onCheckedChange={handleAuditModeSwitch}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Settings className="w-5 h-5 text-[#38bdac]" />
|
||
功能开关
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
控制各个功能模块的显示/隐藏
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<Users className="w-4 h-4 text-[#38bdac]" />
|
||
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
|
||
找伙伴功能
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 ml-6">控制小程序和Web端的找伙伴功能显示</p>
|
||
</div>
|
||
<Switch
|
||
id="match-enabled"
|
||
checked={featureConfig.matchEnabled}
|
||
disabled={featureSwitchSaving}
|
||
onCheckedChange={(checked) => handleFeatureSwitch('matchEnabled', checked)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<Gift className="w-4 h-4 text-[#38bdac]" />
|
||
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
|
||
推广功能
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 ml-6">控制推广中心的显示(我的页面入口)</p>
|
||
<p className="text-xs text-amber-400/80 ml-6 mt-1">
|
||
佣金比例、绑定期、提现规则等与
|
||
<Link to="/distribution?tab=settings" className="underline mx-1 text-[#38bdac]">
|
||
推广中心 → 推广设置
|
||
</Link>
|
||
为同一套接口,在此仅控制是否展示入口。
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="referral-enabled"
|
||
checked={featureConfig.referralEnabled}
|
||
disabled={featureSwitchSaving}
|
||
onCheckedChange={(checked) => handleFeatureSwitch('referralEnabled', checked)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<BookOpen className="w-4 h-4 text-[#38bdac]" />
|
||
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
|
||
搜索功能
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 ml-6">控制首页、目录页搜索栏的显示</p>
|
||
</div>
|
||
<Switch
|
||
id="search-enabled"
|
||
checked={featureConfig.searchEnabled}
|
||
disabled={featureSwitchSaving}
|
||
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<Settings className="w-4 h-4 text-[#38bdac]" />
|
||
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
|
||
关于页面
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 ml-6">控制关于页面的访问</p>
|
||
</div>
|
||
<Switch
|
||
id="about-enabled"
|
||
checked={featureConfig.aboutEnabled}
|
||
disabled={featureSwitchSaving}
|
||
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
|
||
<p className="text-xs text-blue-300">
|
||
💡 关闭功能后,相关入口会自动隐藏。建议在功能开发完成后再开启。
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Smile className="w-5 h-5 text-[#38bdac]" />
|
||
MBTI 头像组
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
为 16 种 MBTI 性格类型配置默认头像 URL。无头像的超级个体将自动使用对应性格的头像。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{mbtiLoading ? (
|
||
<p className="text-gray-500 text-sm">加载中...</p>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{MBTI_TYPES.map((t) => (
|
||
<div key={t} className="flex items-center gap-2">
|
||
<span className="text-xs text-[#38bdac] font-mono w-10 flex-shrink-0">{t}</span>
|
||
{mbtiAvatars[t] && (
|
||
<img src={mbtiAvatars[t]} alt={t} className="w-8 h-8 rounded-full flex-shrink-0 object-cover border border-gray-600" />
|
||
)}
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white text-xs h-8 flex-1"
|
||
placeholder="头像 URL"
|
||
value={mbtiAvatars[t] ?? ''}
|
||
onChange={(e) =>
|
||
setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Button
|
||
onClick={saveMbtiAvatars}
|
||
disabled={mbtiSaving}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
size="sm"
|
||
>
|
||
<Save className="w-3 h-3 mr-1" />
|
||
{mbtiSaving ? '保存中...' : '保存头像映射'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Eye className="w-5 h-5 text-[#38bdac]" />
|
||
小程序模块显隐说明
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
以下模块受上方开关和审核模式共同控制
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
{[
|
||
{ mod: '找伙伴', ctrl: '找伙伴功能开关', icon: <Users className="w-3 h-3" /> },
|
||
{ mod: '推广中心 / 推荐好友', ctrl: '推广功能开关', icon: <Gift className="w-3 h-3" /> },
|
||
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
|
||
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
|
||
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
|
||
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Smile className="w-3 h-3" /> },
|
||
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
|
||
].map((r) => (
|
||
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
|
||
{r.icon}
|
||
<div>
|
||
<span className="text-white">{r.mod}</span>
|
||
<span className="text-gray-500 ml-1">← {r.ctrl}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="author" className="mt-0">
|
||
<AuthorSettingsPage />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="admin" className="mt-0">
|
||
<AdminUsersPage />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="api-docs" className="mt-0">
|
||
<ApiDocsPage />
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent
|
||
className="bg-[#0f2137] border-gray-700 text-white"
|
||
showCloseButton={true}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle className={dialogIsError ? 'text-red-400' : 'text-[#38bdac]'}>
|
||
{dialogTitle}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-gray-400 whitespace-pre-wrap pt-2">
|
||
{dialogMessage}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter className="mt-4">
|
||
<Button
|
||
onClick={() => setDialogOpen(false)}
|
||
className={dialogIsError ? 'bg-gray-600 hover:bg-gray-500' : 'bg-[#38bdac] hover:bg-[#2da396]'}
|
||
>
|
||
确定
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|