- 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.
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|