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

@@ -57,6 +57,7 @@
]
},
"usingComponents": {},
"navigateToMiniProgramAppIdList": [],
"__usePrivacyCheck__": true,
"lazyCodeLoading": "requiredComponents",
"style": "v2",

View File

@@ -78,11 +78,12 @@ Page({
async onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
// 预加载 linkTags 配置(供 onLinkTagTap 旧格式降级匹配 type 用
if (!app.globalData.linkTagsConfig) {
// 预加载 linkTags、linkedMiniprograms(供 onLinkTagTap 用密钥查 appId
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms) {
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg && Array.isArray(cfg.linkTags)) {
app.globalData.linkTagsConfig = cfg.linkTags
if (cfg) {
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
}
}).catch(() => {})
}
@@ -469,12 +470,13 @@ Page({
getApp().goBackOrToHome()
},
// 点击正文中的 #链接标签:小程序内页/预览页跳转
// 点击正文中的 #链接标签:小程序内页/预览页/唤醒其他小程序
onLinkTagTap(e) {
let url = (e.currentTarget.dataset.url || '').trim()
const label = (e.currentTarget.dataset.label || '').trim()
let tagType = (e.currentTarget.dataset.tagType || '').trim()
let pagePath = (e.currentTarget.dataset.pagePath || '').trim()
let mpKey = (e.currentTarget.dataset.mpKey || '').trim() || (e.currentTarget.dataset.appId || '').trim()
// 旧格式(<a href>tagType 为空 → 按 label 从缓存 linkTags 补充类型信息
if (!tagType && label) {
@@ -483,6 +485,7 @@ Page({
tagType = cached.type || 'url'
pagePath = cached.pagePath || ''
if (!url) url = cached.url || ''
if (cached.mpKey) mpKey = cached.mpKey
}
}
@@ -493,6 +496,28 @@ Page({
return
}
// 小程序类型:用密钥查 linkedMiniprograms 得 appId再唤醒需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
if (tagType === 'miniprogram') {
if (!mpKey && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) mpKey = cached.mpKey || cached.appId || ''
}
const linked = (app.globalData.linkedMiniprograms || []).find(m => (m.key || m.id) === mpKey)
if (linked && linked.appId) {
wx.navigateToMiniProgram({
appId: linked.appId,
path: pagePath || linked.path || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
return
}
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
if (internalPath) {

View File

@@ -48,7 +48,7 @@
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}">#{{seg.label}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "唤醒",
"pathName": "pages/read/read",
"query": "mid=209",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/my/my",
"pathName": "pages/my/my",
"query": "",
"scene": null,
"launchMode": "singlePage"
"launchMode": "singlePage",
"scene": null
},
{
"name": "pages/read/read",

View File

@@ -55,17 +55,21 @@ function parseBlockToSegments(block) {
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..."
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="..."
const urlMatch = tag.match(/data-url="([^"]*)"/)
const tagTypeMatch = tag.match(/data-tag-type="([^"]*)"/)
const pagePathMatch = tag.match(/data-page-path="([^"]*)"/)
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
const tagId = tagIdMatch ? tagIdMatch[1] : ''
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId })
const appId = appIdMatch ? appIdMatch[1] : ''
const mpKey = mpKeyMatch ? mpKeyMatch[1] : (tagType === 'miniprogram' ? appId : '')
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey })
} else if (/^<a /i.test(tag)) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)

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>

View File

@@ -0,0 +1,212 @@
package handler
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
const linkedMpConfigKey = "linked_miniprograms"
// LinkedMpItem 关联小程序项key 为 32 位密钥,链接标签存 key小程序端用 key 查 appId
type LinkedMpItem struct {
Key string `json:"key"`
ID string `json:"id,omitempty"` // 兼容旧数据,新数据 key 即主标识
Name string `json:"name"`
AppID string `json:"appId"`
Path string `json:"path,omitempty"`
Sort int `json:"sort"`
}
// AdminLinkedMpList GET /api/admin/linked-miniprograms 管理端-关联小程序列表
func AdminLinkedMpList(c *gin.Context) {
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
return
}
var list []LinkedMpItem
if err := json.Unmarshal(row.ConfigValue, &list); err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
return
}
if list == nil {
list = []LinkedMpItem{}
}
// 兼容旧数据:无 key 时用 id 作为 key
for i := range list {
if list[i].Key == "" && list[i].ID != "" {
list[i].Key = list[i].ID
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// AdminLinkedMpCreate POST /api/admin/linked-miniprograms 管理端-新增关联小程序
func AdminLinkedMpCreate(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
AppID string `json:"appId" binding:"required"`
Path string `json:"path"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写小程序名称和 AppID"})
return
}
body.Name = trimSpace(body.Name)
body.AppID = trimSpace(body.AppID)
if body.Name == "" || body.AppID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
return
}
key, err := genMpKey()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成密钥失败"})
return
}
item := LinkedMpItem{Key: key, Name: body.Name, AppID: body.AppID, Path: body.Path, Sort: body.Sort}
db := database.DB()
var row model.SystemConfig
var list []LinkedMpItem
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
list = []LinkedMpItem{}
} else {
_ = json.Unmarshal(row.ConfigValue, &list)
if list == nil {
list = []LinkedMpItem{}
}
}
list = append(list, item)
valBytes, _ := json.Marshal(list)
desc := "关联小程序列表,用于 wx.navigateToMiniProgram 跳转"
if row.ConfigKey == "" {
row = model.SystemConfig{ConfigKey: linkedMpConfigKey, ConfigValue: valBytes, Description: &desc}
if err := db.Create(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
return
}
} else {
row.ConfigValue = valBytes
if err := db.Save(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
// AdminLinkedMpUpdate PUT /api/admin/linked-miniprograms 管理端-编辑关联小程序
func AdminLinkedMpUpdate(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
Name string `json:"name" binding:"required"`
AppID string `json:"appId" binding:"required"`
Path string `json:"path"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数无效"})
return
}
body.Name = trimSpace(body.Name)
body.AppID = trimSpace(body.AppID)
if body.Name == "" || body.AppID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
return
}
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
return
}
var list []LinkedMpItem
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
return
}
found := false
for i := range list {
if list[i].Key == body.Key || (list[i].Key == "" && list[i].ID == body.Key) {
list[i].Name = body.Name
list[i].AppID = body.AppID
list[i].Path = body.Path
list[i].Sort = body.Sort
if list[i].Key == "" {
list[i].Key = list[i].ID
}
found = true
break
}
}
if !found {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
return
}
valBytes, _ := json.Marshal(list)
row.ConfigValue = valBytes
if err := db.Save(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminLinkedMpDelete DELETE /api/admin/linked-miniprograms/:id 管理端-删除(:id 实际传 key
func AdminLinkedMpDelete(c *gin.Context) {
key := c.Param("id")
if key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少密钥"})
return
}
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
return
}
var list []LinkedMpItem
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
return
}
newList := make([]LinkedMpItem, 0, len(list))
for _, item := range list {
if item.Key != key && (item.Key != "" || item.ID != key) {
newList = append(newList, item)
}
}
valBytes, _ := json.Marshal(newList)
row.ConfigValue = valBytes
if err := db.Save(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// genMpKey 生成 32 位英文+数字密钥,供链接标签引用
func genMpKey() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
// base64 编码后取 32 位,去掉 +/= 仅保留字母数字
s := base64.URLEncoding.EncodeToString(b)
s = strings.ReplaceAll(s, "+", "")
s = strings.ReplaceAll(s, "/", "")
s = strings.ReplaceAll(s, "=", "")
if len(s) >= 32 {
return s[:32], nil
}
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
}

View File

@@ -101,22 +101,40 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["userDiscount"]; !has {
out["userDiscount"] = float64(5)
}
// 链接标签列表(小程序 onLinkTagTap 需要知道 type,用于 ckb/miniprogram 的特殊处理
// 链接标签列表(小程序 onLinkTagTap 需要 typeminiprogram 类型存 mpKey用 key 查 linkedMiniprograms 得 appId
var linkTagRows []model.LinkTag
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
tags := make([]gin.H, 0, len(linkTagRows))
for _, t := range linkTagRows {
tags = append(tags, gin.H{
"tagId": t.TagID,
"label": t.Label,
"url": t.URL,
"type": t.Type,
"pagePath": t.PagePath,
"appId": t.AppID,
})
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
if t.Type == "miniprogram" {
h["mpKey"] = t.AppID // miniprogram 类型时 AppID 列存的是密钥
} else {
h["appId"] = t.AppID
}
tags = append(tags, h)
}
out["linkTags"] = tags
}
// 关联小程序列表key 为 32 位密钥,小程序用 key 查 appId 后 wx.navigateToMiniProgram
var linkedMpRow model.SystemConfig
if err := db.Where("config_key = ?", "linked_miniprograms").First(&linkedMpRow).Error; err == nil && len(linkedMpRow.ConfigValue) > 0 {
var linkedList []gin.H
if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err == nil && len(linkedList) > 0 {
// 确保每项有 key兼容旧数据用 id 作为 key
for _, m := range linkedList {
if k, _ := m["key"].(string); k == "" {
if id, _ := m["id"].(string); id != "" {
m["key"] = id
}
}
}
out["linkedMiniprograms"] = linkedList
}
}
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
c.JSON(http.StatusOK, out)
}

View File

@@ -40,6 +40,10 @@ func DBLinkTagSave(c *gin.Context) {
if body.Type == "" {
body.Type = "url"
}
// 小程序类型:只存 appId + pagePath不存 weixin:// 到 url
if body.Type == "miniprogram" {
body.URL = ""
}
db := database.DB()
var existing model.LinkTag
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
@@ -52,6 +56,7 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
// body.URL 已在 miniprogram 类型时置空
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})

View File

@@ -68,6 +68,10 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
admin.GET("/settings", handler.AdminSettingsGet)
admin.POST("/settings", handler.AdminSettingsPost)
admin.GET("/linked-miniprograms", handler.AdminLinkedMpList)
admin.POST("/linked-miniprograms", handler.AdminLinkedMpCreate)
admin.PUT("/linked-miniprograms", handler.AdminLinkedMpUpdate)
admin.DELETE("/linked-miniprograms/:id", handler.AdminLinkedMpDelete)
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)