优化首页逻辑以支持动态标题生成,提升用户体验。更新管理后台资源文件,替换旧的 JavaScript 和 CSS 文件,增强页面性能和样式一致性。同时,调整数据库结构以支持更细粒度的推送状态。

This commit is contained in:
Alex-larget
2026-03-27 16:09:26 +08:00
parent 159ce035f2
commit d6c8aabbe8
15 changed files with 1170 additions and 403 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-i0PBc3Gp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHhAT-JW.css">
<script type="module" crossorigin src="/assets/index-BRyXRtx1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfljfNs2.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,486 @@
import { useEffect, useMemo, useState } from 'react'
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 { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Pagination } from '@/components/ui/Pagination'
import { MessageSquare, RotateCcw, Search, Pencil, Trash2, PlusCircle } from 'lucide-react'
import toast from '@/utils/toast'
import type { PagePopupItem } from './mpUiCopyConfig'
import {
DEFAULT_POPUP_PAGE_PATH,
POPUP_PAGE_PATH_OPTIONS,
displayPageName,
isValidPopupKey,
newPagePopupId,
scopeLabel,
seedPagePopupItemsWithIds,
summarizePopupRow,
} from './mpUiCopyConfig'
type Props = {
pagePopupItems: PagePopupItem[]
setPagePopupItems: React.Dispatch<React.SetStateAction<PagePopupItem[]>>
extraKeysCount: number
}
/** 列表分页(全量仍保存在 mpConfig.mpUi.pagePopupItems点「保存设置」写库 */
const PAGE_SIZE = 10
const emptyDraft = (): PagePopupItem => ({
id: newPagePopupId(),
pageName: '',
pagePath: DEFAULT_POPUP_PAGE_PATH,
scope: 'fullApp',
key: '',
behavior: '',
content: '',
})
export function MpUiPopupTableSection({
pagePopupItems,
setPagePopupItems,
extraKeysCount,
}: Props) {
const [search, setSearch] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add')
const [draft, setDraft] = useState<PagePopupItem>(emptyDraft())
const [deleteId, setDeleteId] = useState<string | null>(null)
const [tablePage, setTablePage] = useState(1)
const pathSelectValue = useMemo(() => {
const t = draft.pagePath.trim()
return POPUP_PAGE_PATH_OPTIONS.some((o) => o.value === t) ? t : '__other__'
}, [draft.pagePath])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return pagePopupItems
return pagePopupItems.filter((p) => {
const blob = `${p.pageName} ${p.pagePath} ${scopeLabel(p.scope)} ${p.key} ${p.behavior} ${p.content} ${displayPageName(p)}`.toLowerCase()
return blob.includes(q)
})
}, [search, pagePopupItems])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)),
[filtered.length],
)
const pagedRows = useMemo(() => {
const start = (tablePage - 1) * PAGE_SIZE
return filtered.slice(start, start + PAGE_SIZE)
}, [filtered, tablePage])
useEffect(() => {
setTablePage(1)
}, [search])
useEffect(() => {
setTablePage((p) => Math.min(p, totalPages))
}, [totalPages])
const openAdd = () => {
setDraft(emptyDraft())
setDialogMode('add')
setDialogOpen(true)
}
const openEdit = (row: PagePopupItem) => {
setDraft({
...row,
scope: row.scope === 'singlePage' ? 'singlePage' : 'fullApp',
})
setDialogMode('edit')
setDialogOpen(true)
}
const saveDialog = () => {
const path = draft.pagePath.trim()
const key = draft.key.trim()
if (!path.startsWith('/')) {
toast.error('页面路径须以 / 开头')
return
}
if (!isValidPopupKey(key)) {
toast.error('键名须为英文:字母开头,仅字母、数字、下划线')
return
}
if (!draft.behavior.trim()) {
toast.error('请填写行为说明')
return
}
const dup = pagePopupItems.some(
(p) => p.pagePath === path && p.key === key && p.id !== draft.id,
)
if (dup) {
toast.error('同一页面下键名不能重复')
return
}
if (dialogMode === 'add') {
setPagePopupItems((prev) => [...prev, { ...draft, id: draft.id || newPagePopupId() }])
toast.success('已添加,请点击右上角「保存设置」提交')
} else {
setPagePopupItems((prev) => prev.map((p) => (p.id === draft.id ? { ...draft } : p)))
toast.success('已更新,请点击「保存设置」提交')
}
setDialogOpen(false)
}
const confirmDelete = () => {
if (!deleteId) return
setPagePopupItems((prev) => prev.filter((p) => p.id !== deleteId))
toast.success('已删除,请点击「保存设置」提交')
setDeleteId(null)
}
return (
<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">
<MessageSquare className="w-5 h-5 text-[#38bdac]" />
+
</CardTitle>
<CardDescription className="text-gray-400">
<strong className="text-gray-300"></strong><strong className="text-gray-300"></strong><strong className="text-gray-300"></strong> / <strong className="text-[#38bdac]"></strong>
<code className="text-[#38bdac]/90">pagePath + key</code> <code className="text-[#38bdac]/90">mpConfig.mpUi.pagePopupItems</code>
key {PAGE_SIZE}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-gray-500">
{extraKeysCount > 0
? `另有 ${extraKeysCount} 类其它 mpUi 已保留,保存时一并写回。`
: '保存后约 5 分钟内随配置缓存刷新。'}
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200"
title="用与小程序对齐的 5 条标准文案替换当前列表,再点「保存设置」写入数据库。迁移完成后可移除此按钮。"
onClick={() => {
setPagePopupItems(seedPagePopupItemsWithIds())
toast.info('已填入标准 5 条,请点击「保存设置」写入数据库')
}}
>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
</Button>
<Button
type="button"
size="sm"
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
onClick={openAdd}
>
<PlusCircle className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
</div>
<div className="space-y-2 max-w-xl w-full">
<Label className="text-gray-400 text-xs flex items-center gap-1.5">
<Search className="w-3.5 h-3.5" />
/ / / / /
</Label>
<div className="rounded-md border border-gray-700 bg-[#0a1628] px-3 h-10 flex items-center">
<Input
className="border-0 bg-transparent text-white h-9 px-0 shadow-none focus-visible:ring-0 placeholder:text-gray-600"
placeholder="例如read、beforeLogin、singlePage…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="rounded-lg border border-gray-700/80 overflow-x-auto w-full">
<Table className="w-full min-w-[1320px] table-fixed">
<TableHeader>
<TableRow className="border-gray-700 hover:bg-[#0a1628]/80 bg-[#0a1628]">
<TableHead className="text-gray-300 w-[11%] min-w-[108px]"></TableHead>
<TableHead className="text-gray-300 w-[8%] min-w-[88px]"></TableHead>
<TableHead className="text-gray-300 w-[19%] min-w-[200px]"></TableHead>
<TableHead className="text-gray-300 w-[10%] min-w-[108px]"></TableHead>
<TableHead className="text-gray-300 w-[13%] min-w-[108px]"></TableHead>
<TableHead className="text-gray-300 w-[25%] min-w-[220px]"></TableHead>
<TableHead className="text-gray-300 text-right w-[14%] min-w-[200px] whitespace-nowrap">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.map((p) => (
<TableRow key={p.id} className="border-gray-800 hover:bg-[#0f2137]/90">
<TableCell className="text-sm text-gray-200 align-top font-medium">
{displayPageName(p)}
</TableCell>
<TableCell className="align-top">
<Badge
variant={p.scope === 'singlePage' ? 'outline' : 'secondary'}
className={
p.scope === 'singlePage'
? 'border-amber-500/50 text-amber-200/95 text-[11px]'
: 'text-[11px]'
}
>
{scopeLabel(p.scope)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-[#38bdac]/95 align-top break-all">
{p.pagePath}
</TableCell>
<TableCell className="font-mono text-xs text-amber-200/90 align-top break-all">{p.key}</TableCell>
<TableCell className="text-xs text-gray-300 align-top">{p.behavior}</TableCell>
<TableCell className="text-xs align-top max-w-[220px] min-w-0 py-2">
<div
className="line-clamp-1 min-w-0 text-gray-400 break-all cursor-default"
title={
String(p.content ?? '')
.replace(/\s+/g, ' ')
.trim() || '—'
}
>
{summarizePopupRow(p)}
</div>
</TableCell>
<TableCell className="align-middle text-right">
<div className="inline-flex flex-nowrap items-center justify-end gap-1 min-w-[168px]">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2.5 text-[#38bdac]"
onClick={() => openEdit(p)}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2.5 text-gray-400 hover:text-red-400"
onClick={() => setDeleteId(p.id)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<p className="text-center text-sm text-gray-500 py-8"></p>
)}
{filtered.length > 0 && totalPages > 1 && (
<Pagination
page={tablePage}
totalPages={totalPages}
total={filtered.length}
pageSize={PAGE_SIZE}
onPageChange={setTablePage}
/>
)}
{filtered.length > 0 && totalPages <= 1 && (
<div className="flex items-center py-3 px-5 border-t border-gray-700/50 text-sm text-gray-400">
{filtered.length} {PAGE_SIZE}
</div>
)}
</div>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl w-[min(100vw-2rem,42rem)] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{dialogMode === 'add' ? '新增弹窗文案' : '编辑弹窗文案'}</DialogTitle>
<DialogDescription className="text-gray-400">
<code className="text-[#38bdac]/90">pagePath</code> 使
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-600 text-white"
placeholder="如:文章详情 / 阅读"
value={draft.pageName}
onChange={(e) => setDraft((d) => ({ ...d, pageName: e.target.value }))}
/>
<p className="text-[11px] text-gray-500">便</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select
value={pathSelectValue}
onValueChange={(v) => {
if (v === '__other__') {
setDraft((d) => {
const cur = d.pagePath.trim()
const onList = POPUP_PAGE_PATH_OPTIONS.some((o) => o.value === cur)
return { ...d, pagePath: onList ? '/pages/' : d.pagePath }
})
} else {
setDraft((d) => ({ ...d, pagePath: v }))
}
}}
>
<SelectTrigger className="bg-[#0a1628] border-gray-600 text-white font-mono text-sm w-full">
<SelectValue placeholder="选择页面路径" />
</SelectTrigger>
<SelectContent className="max-h-[min(60vh,320px)]">
{POPUP_PAGE_PATH_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="focus:bg-[#1a3a4a] focus:text-white font-mono text-xs"
>
<span className="text-gray-200">{opt.label}</span>
<span className="text-gray-500 ml-2">{opt.value}</span>
</SelectItem>
))}
<SelectItem value="__other__" className="focus:bg-[#1a3a4a] focus:text-white">
</SelectItem>
</SelectContent>
</Select>
{pathSelectValue === '__other__' && (
<Input
className="bg-[#0a1628] border-gray-600 text-white font-mono text-sm"
placeholder="/pages/xxx/xxx"
value={draft.pagePath}
onChange={(e) => setDraft((d) => ({ ...d, pagePath: e.target.value }))}
/>
)}
<p className="text-[11px] text-gray-500">
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select
value={draft.scope}
onValueChange={(v) =>
setDraft((d) => ({ ...d, scope: v === 'singlePage' ? 'singlePage' : 'fullApp' }))
}
>
<SelectTrigger className="bg-[#0a1628] border-gray-600 text-white">
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fullApp" className="focus:bg-[#1a3a4a] focus:text-white">
</SelectItem>
<SelectItem value="singlePage" className="focus:bg-[#1a3a4a] focus:text-white">
</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-gray-500">
1154
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-600 text-white font-mono text-sm"
placeholder="beforeLoginHint"
value={draft.key}
disabled={dialogMode === 'edit'}
onChange={(e) =>
setDraft((d) => ({ ...d, key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') }))
}
/>
{dialogMode === 'edit' && (
<p className="text-[11px] text-gray-500"></p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-600 text-white"
placeholder="如:未登录时付费墙上方展示"
value={draft.behavior}
onChange={(e) => setDraft((d) => ({ ...d, behavior: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-600 text-white min-h-[140px]"
placeholder="弹窗正文、提示语等"
value={draft.content}
onChange={(e) => setDraft((d) => ({ ...d, content: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-gray-600" onClick={() => setDialogOpen(false)}>
</Button>
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white" onClick={saveDialog}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="text-gray-400">
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" className="border-gray-600" onClick={() => setDeleteId(null)}>
</Button>
<Button className="bg-red-600 hover:bg-red-700" onClick={confirmDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -40,11 +40,15 @@ import {
EyeOff,
LayoutGrid,
Sparkles,
MessageSquare,
} 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'
import { MpUiPopupTableSection } from '@/pages/settings/MpUiPopupTableSection'
import type { PagePopupItem } from '@/pages/settings/mpUiCopyConfig'
import { buildMpUiPayload, splitMpUiForPopupEditor } from '@/pages/settings/mpUiCopyConfig'
interface AuthorInfo {
name?: string
@@ -76,7 +80,7 @@ interface MpConfig {
mchId?: string
minWithdraw?: number
auditMode?: boolean
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
/** mpUi含 pagePopupItems 等;服务端与 defaultMpUi 深合并 */
mpUi?: Record<string, unknown>
}
@@ -119,59 +123,10 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
chaptersPage: {
bookTitle: '一场SOUL的创业实验场',
bookSubtitle: '来自Soul派对房的真实商业故事',
newBadgeText: 'NEW',
},
// homePage.linkKaruoAvatar首页「链接卡若」头像 HTTPS空则小程序用「卡」字占位
homePage: {
logoTitle: '卡若创业派对',
logoSubtitle: '来自派对房的真实故事',
linkKaruoText: '点击链接卡若',
linkKaruoAvatar: '',
pinnedTitlePrefix: '派对会员',
pinnedMainTitleTemplate: '',
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',
},
memberDetailPage: {
unlockIntroTitle: '解锁与链接说明',
unlockIntroBody:
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。请先阅读说明,确认后再登录。',
},
readPage: {
beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
singlePageTitle: '解锁全文',
singlePagePaywallHint:
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
},
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'oss', 'features'] as const
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'mp-copy', 'oss', 'features'] as const
type SystemSectionKey = (typeof SYSTEM_SECTION_KEYS)[number]
export function SettingsPage() {
@@ -186,8 +141,10 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [mpUiJson, setMpUiJson] = useState('{}')
const [chaptersNewBadgeText, setChaptersNewBadgeText] = useState('NEW')
/** 不在 pagePopupItems 内的 mpUi 顶层键tabBar、homePage 等),保存时写回 */
const [mpUiExtra, setMpUiExtra] = useState<Record<string, unknown>>({})
/** 弹窗文案列表mpUi.pagePopupItems */
const [pagePopupItems, setPagePopupItems] = useState<PagePopupItem[]>([])
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
@@ -220,22 +177,9 @@ export function SettingsPage() {
if (res.mpConfig && typeof res.mpConfig === 'object') {
const merged = { ...res.mpConfig } as MpConfig
setMpConfig((prev) => ({ ...prev, ...merged }))
const raw = merged.mpUi
const rawObj =
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
const chaptersPage =
rawObj.chaptersPage && typeof rawObj.chaptersPage === 'object' && !Array.isArray(rawObj.chaptersPage)
? (rawObj.chaptersPage as Record<string, unknown>)
: {}
const badgeRaw = chaptersPage.newBadgeText ?? chaptersPage.sectionNewBadgeText
setChaptersNewBadgeText(typeof badgeRaw === 'string' && badgeRaw.trim() ? badgeRaw.trim() : 'NEW')
setMpUiJson(
JSON.stringify(
rawObj,
null,
2,
),
)
const { extra, pagePopupItems: rows } = splitMpUiForPopupEditor(merged.mpUi)
setMpUiExtra(extra)
setPagePopupItems(rows)
}
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
@@ -317,34 +261,7 @@ export function SettingsPage() {
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 chaptersPageRaw = mpUi.chaptersPage
const chaptersPage =
chaptersPageRaw && typeof chaptersPageRaw === 'object' && !Array.isArray(chaptersPageRaw)
? { ...(chaptersPageRaw as Record<string, unknown>) }
: {}
const badgeText = chaptersNewBadgeText.trim()
if (badgeText) chaptersPage.newBadgeText = badgeText
else delete chaptersPage.newBadgeText
delete chaptersPage.sectionNewBadgeText
mpUi.chaptersPage = chaptersPage
const mpUi = buildMpUiPayload(pagePopupItems, mpUiExtra)
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
@@ -487,6 +404,13 @@ export function SettingsPage() {
<Smartphone className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="mp-copy"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<MessageSquare className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="oss"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
@@ -767,46 +691,9 @@ export function SettingsPage() {
/>
</div>
</div>
<div className="space-y-2 pt-2 border-t border-gray-700/50">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> NEW </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="例如NEW / 最新 / 刚更新"
value={chaptersNewBadgeText}
maxLength={8}
onChange={(e) => setChaptersNewBadgeText(e.target.value)}
/>
<p className="text-xs text-gray-500">
5 mpUi JSON
</p>
</div>
</div>
<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>
<p className="text-xs text-gray-500 pt-2 border-t border-gray-700/50">
Tab + pagePopupItemsTab
</p>
</CardContent>
</Card>
@@ -843,7 +730,15 @@ export function SettingsPage() {
/>
</div>
</CardContent>
</Card>
</Card>
</TabsContent>
<TabsContent value="mp-copy" className="space-y-6 mt-0">
<MpUiPopupTableSection
pagePopupItems={pagePopupItems}
setPagePopupItems={setPagePopupItems}
extraKeysCount={Object.keys(mpUiExtra).length}
/>
</TabsContent>
<TabsContent value="oss" className="space-y-6 mt-0">

View File

@@ -0,0 +1,342 @@
/**
* 弹窗文案统一为 mpUi.pagePopupItems[]:每页可多条,按英文 key 在小程序内取用。
* 兼容旧版 memberDetailPage / readPage / customPagePopups加载时迁入保存时不再写出
*/
/** 单页面:朋友圈单页预览等;多页面:完整小程序内 */
export type PagePopupScope = 'singlePage' | 'fullApp'
export type PagePopupItem = {
id: string
/** 页面中文名称(展示用,与路径对应) */
pageName: string
/** 小程序页面路径,如 /pages/read/read */
pagePath: string
/**
* 展示场景单页面scene 1154 等) / 多页面(完整小程序)
* 存 JSON 字段 scope供运营区分与后续小程序按场景过滤
*/
scope: PagePopupScope
/** 英文键,同一 pagePath 下唯一,供代码引用 */
key: string
/** 行为说明(管理端自用,如「未登录付费墙上方」) */
behavior: string
/** 展示文案 */
content: string
}
const MEMBER_PATH = '/pages/member-detail/member-detail'
const READ_PATH = '/pages/read/read'
/** 常见路径的默认页面名称(未单独填写 pageName 时表格展示用) */
export const KNOWN_PAGE_NAMES: Record<string, string> = {
[MEMBER_PATH]: '成员详情',
[READ_PATH]: '文章详情 / 阅读',
}
/**
* 弹窗文案「页面路径」下拉的候选项(与 miniprogram/app.json 的 pages 对齐;新增页面时请同步)
* value 必须以 / 开头,与小程序 wx.navigateTo 路径一致
*/
export const POPUP_PAGE_PATH_OPTIONS: { value: string; label: string }[] = [
{ value: '/pages/index/index', label: '首页' },
{ value: '/pages/chapters/chapters', label: '目录' },
{ value: '/pages/match/match', label: '找伙伴' },
{ value: '/pages/my/my', label: '我的' },
{ value: '/pages/read/read', label: '文章详情 / 阅读' },
{ value: '/pages/link-preview/link-preview', label: '链接预览' },
{ value: '/pages/agreement/agreement', label: '用户协议' },
{ value: '/pages/privacy/privacy', label: '隐私政策' },
{ value: '/pages/referral/referral', label: '邀请' },
{ value: '/pages/purchases/purchases', label: '购买记录' },
{ value: '/pages/reading-records/reading-records', label: '阅读记录' },
{ value: '/pages/settings/settings', label: '设置' },
{ value: '/pages/search/search', label: '搜索' },
{ value: '/pages/addresses/addresses', label: '地址列表' },
{ value: '/pages/addresses/edit', label: '编辑地址' },
{ value: '/pages/withdraw-records/withdraw-records', label: '提现记录' },
{ value: '/pages/wallet/wallet', label: '钱包' },
{ value: '/pages/vip/vip', label: '会员中心' },
{ value: '/pages/member-detail/member-detail', label: '成员详情' },
{ value: '/pages/mentors/mentors', label: '导师列表' },
{ value: '/pages/mentor-detail/mentor-detail', label: '导师详情' },
{ value: '/pages/profile-show/profile-show', label: '资料展示' },
{ value: '/pages/profile-edit/profile-edit', label: '编辑资料' },
{ value: '/pages/avatar-nickname/avatar-nickname', label: '头像昵称' },
{ value: '/pages/gift-pay/detail', label: '礼物支付 · 详情' },
{ value: '/pages/gift-pay/list', label: '礼物支付 · 列表' },
{ value: '/pages/gift-pay/redemption-detail', label: '礼物支付 · 兑换详情' },
{ value: '/pages/dev-login/dev-login', label: '开发登录' },
]
/** 新增弹窗文案时默认选中的页面路径(阅读页,与种子一致) */
export const DEFAULT_POPUP_PAGE_PATH = READ_PATH
export function displayPageName(p: PagePopupItem): string {
const n = String(p.pageName ?? '').trim()
if (n) return n
return KNOWN_PAGE_NAMES[p.pagePath] || '—'
}
export function normalizeScope(raw: unknown): PagePopupScope {
const s = String(raw ?? '').trim()
if (s === 'singlePage' || s === 'single_page') return 'singlePage'
return 'fullApp'
}
export function scopeLabel(scope: PagePopupScope): string {
return scope === 'singlePage' ? '单页面' : '多页面'
}
export function newPagePopupId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return `pp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
/** 英文键:字母开头,仅字母数字下划线 */
export function isValidPopupKey(key: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(key.trim())
}
export function parsePagePopupItems(raw: unknown): PagePopupItem[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
const v = (raw as Record<string, unknown>).pagePopupItems
if (!Array.isArray(v)) return []
const out: PagePopupItem[] = []
v.forEach((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return
const o = item as Record<string, unknown>
const key = String(o.key ?? '').trim()
if (!isValidPopupKey(key)) return
const pagePath = String(o.pagePath ?? '').trim()
if (!pagePath.startsWith('/')) return
out.push({
id: typeof o.id === 'string' && o.id.trim() ? o.id.trim() : newPagePopupId(),
pageName: String(o.pageName ?? '').trim(),
pagePath,
scope: normalizeScope(o.scope),
key,
behavior: String(o.behavior ?? '').trim() || '—',
content: String(o.content ?? ''),
})
})
return out
}
/** 从旧版 mpUi 结构生成 pagePopupItems仅当尚无 pagePopupItems 时) */
export function migrateLegacyPagePopups(raw: unknown): PagePopupItem[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
const obj = raw as Record<string, unknown>
const out: PagePopupItem[] = []
const md = obj.memberDetailPage
if (md && typeof md === 'object' && !Array.isArray(md)) {
const m = md as Record<string, unknown>
if (typeof m.unlockIntroTitle === 'string' && m.unlockIntroTitle.trim()) {
out.push({
id: newPagePopupId(),
pageName: KNOWN_PAGE_NAMES[MEMBER_PATH],
pagePath: MEMBER_PATH,
scope: 'fullApp',
key: 'unlockIntroTitle',
behavior: '解锁前说明弹窗 · 标题wx.showModal title',
content: m.unlockIntroTitle,
})
}
if (typeof m.unlockIntroBody === 'string' && m.unlockIntroBody.trim()) {
out.push({
id: newPagePopupId(),
pageName: KNOWN_PAGE_NAMES[MEMBER_PATH],
pagePath: MEMBER_PATH,
scope: 'fullApp',
key: 'unlockIntroBody',
behavior: '解锁前说明弹窗 · 正文wx.showModal content',
content: m.unlockIntroBody,
})
}
}
const rp = obj.readPage
if (rp && typeof rp === 'object' && !Array.isArray(rp)) {
const r = rp as Record<string, unknown>
const pairs: [string, string, PagePopupScope][] = [
['beforeLoginHint', '未登录时付费墙上方说明', 'fullApp'],
['singlePageTitle', '朋友圈单页 · 付费区标题', 'singlePage'],
['singlePagePaywallHint', '朋友圈单页 · 付费墙说明', 'singlePage'],
]
for (const [k, behavior, scope] of pairs) {
if (typeof r[k] === 'string' && String(r[k]).trim()) {
out.push({
id: newPagePopupId(),
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope,
key: k,
behavior,
content: String(r[k]),
})
}
}
}
const custom = obj.customPagePopups
if (Array.isArray(custom)) {
custom.forEach((c, idx) => {
if (!c || typeof c !== 'object' || Array.isArray(c)) return
const o = c as Record<string, unknown>
const pagePath = String(o.pagePath ?? '').trim()
if (!pagePath.startsWith('/')) return
const customPageName = String(o.pageName ?? '').trim()
const push = (suffix: string, behavior: string, val: unknown, scope: PagePopupScope) => {
if (typeof val !== 'string' || !val.trim()) return
const k = `c${idx}_${suffix}`
if (!isValidPopupKey(k)) return
out.push({
id: newPagePopupId(),
pageName: customPageName || KNOWN_PAGE_NAMES[pagePath] || '',
pagePath,
scope,
key: k,
behavior: `${behavior}(旧版 customPagePopups 迁移)`,
content: val,
})
}
push('fullModalTitle', '完整端弹窗标题', o.fullModalTitle, 'fullApp')
push('fullModalBody', '完整端弹窗正文', o.fullModalBody, 'fullApp')
push('singlePageTitle', '单页标题', o.singlePageTitle, 'singlePage')
push('singlePageBody', '单页说明', o.singlePageBody, 'singlePage')
})
}
return out
}
const LEGACY_KEYS = new Set(['memberDetailPage', 'readPage', 'customPagePopups', 'pagePopupItems'])
/** 其余 mpUi 顶层键原样保留tabBar、homePage 等) */
export function extractMpUiExtra(raw: unknown): Record<string, unknown> {
const extra: Record<string, unknown> = {}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return extra
const obj = raw as Record<string, unknown>
for (const k of Object.keys(obj)) {
if (LEGACY_KEYS.has(k)) continue
extra[k] = obj[k]
}
return extra
}
export function splitMpUiForPopupEditor(raw: unknown): {
extra: Record<string, unknown>
pagePopupItems: PagePopupItem[]
} {
let items = parsePagePopupItems(raw)
if (items.length === 0) items = migrateLegacyPagePopups(raw)
const extra = extractMpUiExtra(raw)
return { extra, pagePopupItems: dedupePagePopupItems(items) }
}
/** 同一 pagePath + key 只保留第一条 */
function dedupePagePopupItems(items: PagePopupItem[]): PagePopupItem[] {
const seen = new Set<string>()
const out: PagePopupItem[] = []
for (const it of items) {
const k = `${it.pagePath}\0${it.key}`
if (seen.has(k)) continue
seen.add(k)
out.push(it)
}
return out
}
export function buildMpUiPayload(
pagePopupItems: PagePopupItem[],
extra: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...extra }
out.pagePopupItems = pagePopupItems.map((p) => ({
id: p.id,
pageName: String(p.pageName ?? '').trim(),
pagePath: p.pagePath.trim(),
scope: p.scope === 'singlePage' ? 'singlePage' : 'fullApp',
key: p.key.trim(),
behavior: p.behavior.trim() || '—',
content: p.content,
}))
delete out.memberDetailPage
delete out.readPage
delete out.customPagePopups
const cp = out.chaptersPage
if (cp && typeof cp === 'object' && !Array.isArray(cp)) {
const m = (cp as Record<string, unknown>) ?? {}
delete m.sectionNewBadgeText
const badge = String(m.newBadgeText ?? '').trim()
if (badge) m.newBadgeText = badge
else delete m.newBadgeText
}
return out
}
export function summarizePopupRow(p: PagePopupItem): string {
const c = String(p.content || '').replace(/\s+/g, ' ').trim()
return c.length > 56 ? `${c.slice(0, 56)}` : c || '—'
}
/**
* 默认种子:与小程序代码引用一一对应(填入默认 + 保存设置 → 写入 mpConfig.mpUi.pagePopupItems
*
* 对照(唯一来源清单):
* - `miniprogram/utils/mpPagePopups.js``getMemberDetailContent` / `getReadPageContent` 使用的 pagePath + key
* - `/pages/member-detail/member-detail.js` → `_showUnlockIntroThenLogin`unlockIntro*;正文兜底与下述 content 一致)
* - `/pages/read/read.js` → `onLoad` 内 beforeLoginHint / singlePage*(标题兜底「解锁全文」)
*
* 当前共 5 条,勿随意改 key改文案请在后台或改此处种子后重新保存。
*/
export const SEED_PAGE_POPUP_ITEMS: Omit<PagePopupItem, 'id'>[] = [
{
pageName: KNOWN_PAGE_NAMES[MEMBER_PATH],
pagePath: MEMBER_PATH,
scope: 'fullApp',
key: 'unlockIntroTitle',
behavior: '解锁前说明弹窗 · 标题wx.showModal title',
content: '解锁与链接说明',
},
{
pageName: KNOWN_PAGE_NAMES[MEMBER_PATH],
pagePath: MEMBER_PATH,
scope: 'fullApp',
key: 'unlockIntroBody',
behavior: '解锁前说明弹窗 · 正文wx.showModal content',
content:
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'fullApp',
key: 'beforeLoginHint',
behavior: '未登录时付费墙上方说明',
content: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePageTitle',
behavior: '朋友圈单页 · 付费区标题',
content: '解锁全文',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePagePaywallHint',
behavior: '朋友圈单页 · 付费墙说明',
content:
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
},
]
export function seedPagePopupItemsWithIds(): PagePopupItem[] {
return SEED_PAGE_POPUP_ITEMS.map((s) => ({ ...s, id: newPagePopupId() }))
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}