Add linked mini program functionality and enhance link tag handling

- Introduced `navigateToMiniProgramAppIdList` in app.json for mini program navigation.
- Updated link tag handling in the read page to support mini program keys and app IDs.
- Enhanced content parsing to include app ID and mini program key in link tags.
- Added linked mini programs management in the admin panel with API endpoints for CRUD operations.
- Improved UI for selecting linked mini programs in the content creation page.
This commit is contained in:
Alex-larget
2026-03-12 16:51:12 +08:00
parent 41ebc70a50
commit db4b4b8b87
13 changed files with 696 additions and 34 deletions

View File

@@ -121,6 +121,8 @@ const LinkTagExtension = Node.create({
tagType: { default: 'url', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-type') || 'url' },
tagId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-id') || '' },
pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' },
appId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-app-id') || '' },
mpKey: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-mp-key') || '' },
}
},
@@ -131,6 +133,8 @@ const LinkTagExtension = Node.create({
tagType: el.getAttribute('data-tag-type') || 'url',
tagId: el.getAttribute('data-tag-id') || '',
pagePath: el.getAttribute('data-page-path')|| '',
appId: el.getAttribute('data-app-id') || '',
mpKey: el.getAttribute('data-mp-key') || '',
}) }]
},
@@ -142,6 +146,8 @@ const LinkTagExtension = Node.create({
'data-tag-type': node.attrs.tagType,
'data-tag-id': node.attrs.tagId,
'data-page-path': node.attrs.pagePath,
'data-app-id': node.attrs.appId || '',
'data-mp-key': node.attrs.mpKey || node.attrs.appId || '',
class: 'link-tag-node',
}), `#${node.attrs.label}`]
},
@@ -297,6 +303,8 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
tagType: tag.type || 'url',
tagId: tag.id || '',
pagePath: tag.pagePath || '',
appId: tag.appId || '',
mpKey: tag.type === 'miniprogram' ? (tag.appId || '') : '',
},
}).run()
}, [editor])

View File

@@ -446,6 +446,30 @@ export function ContentPage() {
}
}, [])
const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([])
const [mpSearchQuery, setMpSearchQuery] = useState('')
const [mpDropdownOpen, setMpDropdownOpen] = useState(false)
const mpDropdownRef = useRef<HTMLDivElement>(null)
const loadLinkedMps = useCallback(async () => {
try {
const res = await get<{ success?: boolean; data?: { key: string; id?: string; name: string; appId: string; path?: string }[] }>(
'/api/admin/linked-miniprograms',
)
if (res?.success && Array.isArray(res.data)) {
setLinkedMps(res.data.map((m) => ({ ...m, key: m.key || m.id || '' })))
}
} catch { /* ignore */ }
}, [])
const filteredLinkedMps = linkedMps.filter(
(m) =>
!mpSearchQuery.trim() ||
m.name.toLowerCase().includes(mpSearchQuery.toLowerCase()) ||
(m.key && m.key.toLowerCase().includes(mpSearchQuery.toLowerCase())) ||
m.appId.toLowerCase().includes(mpSearchQuery.toLowerCase()),
)
const handleTogglePin = async (sectionId: string) => {
const next = pinnedSectionIds.includes(sectionId)
? pinnedSectionIds.filter((id) => id !== sectionId)
@@ -487,7 +511,13 @@ export function ContentPage() {
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
}
useEffect(() => { loadPinnedSections(); loadPreviewPercent(); loadPersons(); loadLinkTags() }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags])
useEffect(() => {
loadPinnedSections()
loadPreviewPercent()
loadPersons()
loadLinkTags()
loadLinkedMps()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadLinkedMps])
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
@@ -2239,15 +2269,15 @@ export function ContentPage() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-end flex-wrap">
<div className="space-y-1">
<div className="space-y-2">
<Label className="text-gray-400 text-xs">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-24" placeholder="如 team01" value={newLinkTag.tagId} onChange={e => setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} />
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 神仙团队" value={newLinkTag.label} onChange={e => setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Select value={newLinkTag.type} onValueChange={v => setNewLinkTag({ ...newLinkTag, type: v as 'url' | 'miniprogram' | 'ckb' })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8 w-24">
@@ -2260,17 +2290,67 @@ export function ContentPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label className="text-gray-400 text-xs">
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序AppID'}
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序(选密钥)'}
</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-44" placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : 'wx...'} value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId} onChange={e => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
}} />
{newLinkTag.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative w-44">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder="搜索名称或密钥"
value={mpDropdownOpen ? mpSearchQuery : newLinkTag.appId}
onChange={(e) => {
const v = e.target.value
setMpSearchQuery(v)
setMpDropdownOpen(true)
if (!linkedMps.some((m) => m.key === v)) setNewLinkTag({ ...newLinkTag, appId: v })
}}
onFocus={() => {
setMpSearchQuery(newLinkTag.appId)
setMpDropdownOpen(true)
}}
onBlur={() => setTimeout(() => setMpDropdownOpen(false), 150)}
/>
{mpDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-700 bg-[#0a1628] shadow-lg z-50">
{filteredLinkedMps.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-xs"></div>
) : (
filteredLinkedMps.map((m) => (
<button
key={m.key}
type="button"
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#38bdac]/20 flex flex-col gap-0.5"
onMouseDown={(e) => {
e.preventDefault()
setNewLinkTag({ ...newLinkTag, appId: m.key, pagePath: m.path || '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
}}
>
<span>{m.name}</span>
<span className="text-xs text-gray-400 font-mono">{m.key}</span>
</button>
))
)}
</div>
)}
</div>
) : (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : '关联小程序的32位密钥'}
value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId}
onChange={(e) => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
}}
/>
)}
</div>
{newLinkTag.type === 'miniprogram' && (
<div className="space-y-1">
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
</div>
@@ -2284,6 +2364,7 @@ export function ContentPage() {
return
}
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram') payload.url = ''
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setEditingLinkTagId(null)
@@ -2325,7 +2406,9 @@ export function ContentPage() {
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
</Badge>
{t.url && (
{t.type === 'miniprogram' ? (
<span className="text-gray-400 text-xs font-mono">{t.appId} {t.pagePath ? `· ${t.pagePath}` : ''}</span>
) : t.url ? (
<a
href={t.url}
target="_blank"
@@ -2334,7 +2417,7 @@ export function ContentPage() {
>
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
)}
) : null}
</div>
<div className="flex items-center gap-2">
<Button

View File

@@ -0,0 +1,283 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Smartphone, Plus, Pencil, Trash2 } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface LinkedMpItem {
key: string
id?: string // 兼容旧数据
name: string
appId: string
path?: string
sort?: number
}
export function LinkedMpPage() {
const [list, setList] = useState<LinkedMpItem[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<LinkedMpItem | null>(null)
const [form, setForm] = useState({ name: '', appId: '', path: '', sort: 0 })
const [saving, setSaving] = useState(false)
async function loadList() {
setLoading(true)
try {
const res = await get<{ success?: boolean; data?: LinkedMpItem[] }>(
'/api/admin/linked-miniprograms',
)
if (res?.success && Array.isArray(res.data)) {
const sorted = [...res.data].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
setList(sorted)
}
} catch (e) {
console.error('Load linked miniprograms error:', e)
toast.error('加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [])
function openAdd() {
setEditing(null)
setForm({ name: '', appId: '', path: '', sort: list.length })
setModalOpen(true)
}
function openEdit(item: LinkedMpItem) {
setEditing(item)
setForm({
name: item.name,
appId: item.appId,
path: item.path ?? '',
sort: item.sort ?? 0,
})
setModalOpen(true)
}
async function handleSave() {
const name = form.name.trim()
const appId = form.appId.trim()
if (!name || !appId) {
toast.error('请填写小程序名称和 AppID')
return
}
setSaving(true)
try {
if (editing) {
const res = await put<{ success?: boolean; error?: string }>(
'/api/admin/linked-miniprograms',
{ key: editing.key || editing.id, name, appId, path: form.path.trim(), sort: form.sort },
)
if (res?.success) {
toast.success('已更新')
setModalOpen(false)
loadList()
} else {
toast.error(res?.error ?? '更新失败')
}
} else {
const res = await post<{ success?: boolean; error?: string }>(
'/api/admin/linked-miniprograms',
{ name, appId, path: form.path.trim(), sort: form.sort },
)
if (res?.success) {
toast.success('已添加')
setModalOpen(false)
loadList()
} else {
toast.error(res?.error ?? '添加失败')
}
}
} catch (e) {
toast.error('操作失败')
} finally {
setSaving(false)
}
}
async function handleDelete(item: LinkedMpItem) {
if (!confirm(`确定要删除「${item.name}」吗?`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/admin/linked-miniprograms/${item.key || item.id}`,
)
if (res?.success) {
toast.success('已删除')
loadList()
} else {
toast.error(res?.error ?? '删除失败')
}
} catch (e) {
toast.error('删除失败')
}
}
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">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
32 # appId app.json navigateToMiniProgramAppIdList AppID
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button
onClick={openAdd}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">AppID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-24"></TableHead>
<TableHead className="text-gray-400 w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((item) => (
<TableRow key={item.key || item.id || item.name} className="border-gray-700/50">
<TableCell className="text-white">{item.name}</TableCell>
<TableCell className="text-gray-300 font-mono text-xs">{item.key || item.id || '—'}</TableCell>
<TableCell className="text-gray-300 font-mono text-sm">{item.appId}</TableCell>
<TableCell className="text-gray-400 text-sm">{item.path || '—'}</TableCell>
<TableCell className="text-gray-300">{item.sort ?? 0}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="text-[#38bdac] hover:bg-[#38bdac]/20"
onClick={() => openEdit(item)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:bg-red-500/20"
onClick={() => handleDelete(item)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{list.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white">
<DialogHeader>
<DialogTitle>{editing ? '编辑关联小程序' : '添加关联小程序'}</DialogTitle>
<DialogDescription className="text-gray-400">
AppID
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="例如Soul 创业派对"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AppID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white font-mono"
placeholder="例如wxb8bbb2b10dec74aa"
value={form.appId}
onChange={(e) => setForm((p) => ({ ...p, appId: 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="例如pages/index/index"
value={form.path}
onChange={(e) => setForm((p) => ({ ...p, path: 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={form.sort}
onChange={(e) =>
setForm((p) => ({ ...p, sort: parseInt(e.target.value, 10) || 0 }))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)} className="border-gray-600">
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
@@ -37,6 +37,7 @@ import {
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage'
interface AuthorInfo {
name?: string
@@ -98,7 +99,7 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
const TAB_KEYS = ['system', 'author', 'linkedmp', 'admin'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
@@ -261,6 +262,13 @@ export function SettingsPage() {
<UserCircle className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="linkedmp"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<Smartphone 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"
@@ -612,6 +620,10 @@ export function SettingsPage() {
<AuthorSettingsPage />
</TabsContent>
<TabsContent value="linkedmp" className="mt-0">
<LinkedMpPage />
</TabsContent>
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>