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:
@@ -57,6 +57,7 @@
|
||||
]
|
||||
},
|
||||
"usingComponents": {},
|
||||
"navigateToMiniProgramAppIdList": [],
|
||||
"__usePrivacyCheck__": true,
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"style": "v2",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 可能为空)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
283
soul-admin/src/pages/linked-mp/LinkedMpPage.tsx
Normal file
283
soul-admin/src/pages/linked-mp/LinkedMpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
212
soul-api/internal/handler/admin_linked_mp.go
Normal file
212
soul-api/internal/handler/admin_linked_mp.go
Normal 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
|
||||
}
|
||||
@@ -101,22 +101,40 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要知道 type,用于 ckb/miniprogram 的特殊处理)
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要 type;miniprogram 类型存 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user