Files
soul-yongping/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx
Alex-larget db4b4b8b87 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.
2026-03-12 16:51:12 +08:00

284 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import 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>
)
}