Files
Mycontent/soul-admin/src/pages/settings/SettingsPage.tsx
卡若 fa3da12b16 feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- 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
2026-03-23 18:38:23 +08:00

1057 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"> mpUiJSON</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">
endpointbucketaccessKey /
</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>
)
}