- Added a new feature for sharing profile cards, including special handling for forwarding to friends and displaying a canvas cover with user information. - Updated the mini program's profile-edit page to generate a shareable card with a structured layout, including user avatar, nickname, and additional information. - Improved the documentation to reflect the new sharing capabilities and updated the last modified date for relevant entries.
292 lines
10 KiB
TypeScript
292 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, RefreshCw } from 'lucide-react'
|
||
import { get, post, put, del } from '@/api/client'
|
||
|
||
interface LinkedMpItem {
|
||
key: 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, 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}`,
|
||
)
|
||
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 gap-2 mb-4">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-gray-600 text-gray-400 hover:bg-gray-700/50"
|
||
onClick={() => loadList()}
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</Button>
|
||
<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} className="border-gray-700/50">
|
||
<TableCell className="text-white">{item.name}</TableCell>
|
||
<TableCell className="text-gray-300 font-mono text-xs">{item.key}</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 max-w-md p-4 gap-3">
|
||
<DialogHeader className="gap-1">
|
||
<DialogTitle className="text-base">{editing ? '编辑关联小程序' : '添加关联小程序'}</DialogTitle>
|
||
<DialogDescription className="text-gray-400 text-xs">
|
||
填写目标小程序的名称和 AppID,路径可选(为空则打开首页)
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-300 text-sm">小程序名称</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
||
placeholder="例如:Soul 创业派对"
|
||
value={form.name}
|
||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-300 text-sm">AppID</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white font-mono h-8 text-sm"
|
||
placeholder="例如:wxb8bbb2b10dec74aa"
|
||
value={form.appId}
|
||
onChange={(e) => setForm((p) => ({ ...p, appId: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-300 text-sm">路径(可选)</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
|
||
placeholder="例如:pages/index/index"
|
||
value={form.path}
|
||
onChange={(e) => setForm((p) => ({ ...p, path: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-300 text-sm">排序</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm w-20"
|
||
value={form.sort}
|
||
onChange={(e) =>
|
||
setForm((p) => ({ ...p, sort: parseInt(e.target.value, 10) || 0 }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="gap-2 pt-1">
|
||
<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>
|
||
)
|
||
}
|