优化首页逻辑以支持动态标题生成,提升用户体验。更新管理后台资源文件,替换旧的 JavaScript 和 CSS 文件,增强页面性能和样式一致性。同时,调整数据库结构以支持更细粒度的推送状态。
This commit is contained in:
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-BfljfNs2.css
vendored
Normal file
1
soul-admin/dist/assets/index-BfljfNs2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
486
soul-admin/src/pages/settings/MpUiPopupTableSection.tsx
Normal file
486
soul-admin/src/pages/settings/MpUiPopupTableSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">小程序界面文案 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>
|
||||
<p className="text-xs text-gray-500 pt-2 border-t border-gray-700/50">
|
||||
弹窗类文案在「弹窗文案」子 Tab 按页面路径 + 英文键维护(pagePopupItems);目录、Tab、首页板块等仍由其它配置决定。
|
||||
</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">
|
||||
|
||||
342
soul-admin/src/pages/settings/mpUiCopyConfig.ts
Normal file
342
soul-admin/src/pages/settings/mpUiCopyConfig.ts
Normal 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() }))
|
||||
}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user